Example triple script — an in-depth walkthrough

Here, we'll go through an example where we implement a program as a triple script, starting from a blank slate.

The finished result is a program of 5.9 kbytes usable by anyone anywhere, because the system requirements are trivial to satisfy—as a triple script, the program is capable of running in the browser even if the user has no special-purpose runtime installed. And as a triple script, our program offers a homologous UI, so those who do have some optional, CLI-based environment already installed and set up on their machines and who prefer not to leave the terminal will be able to launch our script from the shell as a command-line app.

In observance of the same principles, there is no "setting up a development environment" step associated with this walkthrough. The only tools necessary to follow along are:

Having NodeJS installed to test the resulting program at the command-line is helpful, but not required.

In light of the above, the advantage of making our program a triple script should be obvious, but just to make it clear: even if the project were to grow in size and scope there will never be any "implicit step zero" that other potential contributors would need to satisfy before being able to use our program, because it targets the browser as a baseline runtime.

This walkthrough is split into seven parts. Only the first five parts are necessary for the implementation of the program. Parts 6 and 7 deal with things like simple formatting considerations, reorganizing our source tree, and adding a README with build instructions. No build step (or any prior experience with a particular build system) is needed during development.

You may follow along here by typing out (or copying and pasting) the snippets shown here, or you can download a copy of the archive containing all parts of this walkthrough as well as a variant of this guide itself.

Table of contents

  1. Triple script fundamentals and first code
  2. Main program logic using the system interface
  3. System stub and the program entry point
  4. More stubs and host environment concerns
  5. Completing the system interface implementation
  6. Switching to the triple script file format
  7. Rounding things out with trplkt and a README

Appendix: lines.app.htm source code


Part 1: Fundamentals

We start out by defining a use case—some problem to be solved. For our demonstration, we are going to create a program that's concerned with newlines. Specifically, we are concerned with the types of newlines that appear in a file. The program developed here will ingest a file and then print statistics about the sorts of newlines it uses—whether the file uses Unix-style LF (linefeed: ASCII 0xA), or DOS-style CR+LF (carriage return followed by linefeed: ASCII 0xD 0xA), or something completely different.

Let's discuss an implementation strategy. In doing so, we'll cover some fundamental patterns helpful for creating triple scripts.

Before starting work on this program—or any triple script—we need to consider its needs, especially with respect to system-level operations and how (not) to work with the bindings exposed by the underlying platform.

For example, we'll need our program to be able to perform file IO (or really, in our case, just a file read), and some way to output the results of our analysis.

When creating a program as a triple script, the recommended approach is to define separate platform support modules designed to abstract over the details of the underlying platform. By following this design, the platform-specific code can be confined to these modules alone. So long as the modules at this layer implement a simple, consistent interface, then the rest of the code, including the main application logic of our program, can consist of portable code written in the triple script dialect that doesn't directly access any of the underlying platform's bindings.

For example, in a program whose main application logic needs only one system-level operation "read", the browser support modules will implement a method read, and the support modules for other environments will implement a method with the same interface. From the perspective of the main application logic, all file access will go through this system.read.

In the case of our program, we'll need such a read call ourselves (to get the text of the file), and for outputting feedback to the user, we'll define a system-level print.

Having reasoned about our program ahead of time and outlined our need for these two system-level operations alone, we're almost at a good enough place that we can jump in and begin writing code. But first, it's useful to have a good understanding of the problem domain.

There are actually four different types of line endings we'll want to be able to deal with:

(If there's confusion about "none", consider a file containing a single line of text. The file may or may not end abruptly—with no line terminator. In fact, this is the default for DOS-style text-processing utilities. In contrast to Unix, which typically expects LF at the end of all lines, on DOS and Windows, the CRLF sequence is generally used as a line separator, rather than a line terminator. So for a file of n lines, there will be n-1 CRLF sequences in between them according to DOS file conventions, and no such sequence at the end of the file—unless the user meant for the nth line to be a blank one.)

To start out, let's just go ahead and define the routines that comprise the heart of our program. We'll implement this as LineChecker, and it will have a method called getStats that returns the counts of the different types of line endings.

Here's how we'll implement the getStats method we mentioned before:

export
function LineChecker(text) {
  this.text = text;
  this.position = 0;
}

LineChecker.prototype.getStats = function() {
  let stats = [];

  stats[LineChecker.TYPE_NONE] = 0;
  stats[LineChecker.TYPE_CR] = 0;
  stats[LineChecker.TYPE_LF] = 0;
  stats[LineChecker.TYPE_CRLF] = 0;

  while (this.position < this.text.length) {
    let end = LineChecker.findLineEnd(this.text, this.position);
    let kind = LineChecker.getEOLType(this.text, end);
    ++stats[kind];
    if (kind == LineChecker.TYPE_CR || kind == LineChecker.TYPE_LF) {
      this.position = end + 1;
    } else if (kind == LineChecker.TYPE_CRLF) {
      this.position = end + 2;
    } else {
      this.position = end;
    }
  }

  return stats;
}

// NB: not ASCII codes--just enum-likes doubling as indexes into stats.
LineChecker.TYPE_NONE = 0;
LineChecker.TYPE_CR = 1;
LineChecker.TYPE_LF = 2;
LineChecker.TYPE_CRLF = 3;

The getStats method defined above will return an array of four elements describing the counts of the various types of line endings. To figure out how many lines end with CRLF, for example, we can check:

stats[LineChecker.TYPE_CRLF]

... where stats is the value returned.

We could have taken a different approach, such as creating and returning an anonymous object with propertes named after the different types of line endings, and there are no strictures preventing that. The use of an array is just an implementation choice made here.

Given that the code above relies on the existence of two other functions LineChecker.findLineEnd and LineChecker.getEOLType, we'll take care of those, too:

LineChecker.findLineEnd = function(contents, position) {
  const LF_CODE = 0x0A;
  const CR_CODE = 0x0D;
  while (position < contents.length) {
    let unit = contents.codePointAt(position);
    if (unit != CR_CODE && unit != LF_CODE) {
      ++position;
      continue;
    }
    break;
  }
  return position;
}

LineChecker.getEOLType = function(contents, position) {
  const LF_CODE = 0x0A;
  const CR_CODE = 0x0D;
  if (position < contents.length) {
    let unit = contents.codePointAt(position);
    if (unit == CR_CODE) {
      if (LineChecker.getEOLType(contents, position + 1) ==
          LineChecker.TYPE_LF) {
        return LineChecker.TYPE_CRLF;
      }
      return LineChecker.TYPE_CR;
    } else if (unit == LF_CODE) {
      return LineChecker.TYPE_LF;
    }
  }
  return LineChecker.TYPE_NONE;
}

At this point, near the end of part 1, we should be able to give almost any kind of text to our line checker and get stats about the line endings in use.

let checker = new LineChecker("foo\r\nbar");
checker.getStats(); // returns [ 0, 0, 0, 1 ]

We can write tests for this to make sure everything is working correctly. The project archive's subtree for Part 1 includes some tests like:

test(function single_line_no_terminator() {
  let text = "This ends with no line terminator";
  $verify(text, { none: 1 });
})

test(function multi_line_CRLF_no_terminator() {
  let text =
    "first line" + "\r\n" +
    "second line";

  $verify(text, { none: 1, crlf: 1 });
})

... and these tests can be run with Inaft. Inaft allows tests to be written in JS, which is very similar to the triple script dialect. Inaft itself is a triple script, and a copy is included at tests/harness.app.htm. Running this test harness and feeding it the project source code should show 13 tests (defined in tests/stats/index.js) similar to those above, all passing. However, further covering either Inaft or these tests in-depth is outside the scope of this document.

With getStats having been written, LineChecker now makes for a nice, simple, reusable software module (or "library"), but it's not a full-fledged executable program capable of taking arbitrary input from users and printing as its output the results of our analysis. In the next two sections, we'll take steps to flesh out the rest of our program to do exactly that.

Part 2: Program

What our program needs at this point are routines for taking a file as input and then printing the formatted results of our getStats implementation.

Let's extend our LineChecker with a (static) method LineChecker.analyze:

LineChecker.analyze = function(system, path) {
  return system.read(path).then((contents) => {
    let checker = new LineChecker(contents);
    let stats = checker.getStats();

    system.print("  CR: " + stats[LineChecker.TYPE_CR]);
    system.print("  LF: " + stats[LineChecker.TYPE_LF]);
    system.print("CRLF: " + stats[LineChecker.TYPE_CRLF]);

    if (stats[LineChecker.TYPE_NONE]) {
      system.print("\nThis file doesn't end with a line terminator.");
    }
  });
}

This is our first encounter with the abstract system interface that was previously only discussed in part 1. We'll cover it more in-depth in part 3. The lines above are the only additional code we'll be adding in this section.

Overall, LineChecker.analyze is fairly simple.

LineChecker.analyze makes use of ECMAScript-style promises for handling asynchronous input. It expects the system read call to return a promise that resolves to the file's contents.

Output (in the form of system print calls) is handled synchronously, because the underlying platform bindings that we'll be using in later sections have very simple sychronous interfaces themselves.

With the addition of the ~15 lines above, our LineChecker is essentially "finished". In it, we have the brains of our program. LineChecker.analyze serves more or less as the main program logic, given that it covers all our program concerns, including taking file input, initiating analysis on the file contents, and printing user feedback. We won't need to make further substantial modifications to LineChecker itself; what follows in the next sections is (a) filling in the system-level read and print routines in parts 3, 4, and 5, and (b) doing a little massaging so things are wired correctly for the triple script file format in part 6.

Part 3: Entry

Briefly, it's important now for our code to grow beyond the size of a single module, in order to accommodate the rest of our program.

We'll introduce two short pieces of code: a stub implementation of the abstract system interface (the read and print methods that LineChecker.analyze depends on), and a main function that handles our program "startup":

// SystemA module ////////////////////////////////////////////////////////////

class SystemA {
  run() {
    throw new Error("unimplemented!");
  }

  read(path) {
    throw new Error("unimplemented!");
  }

  print(message) {
    throw new Error("unimplemented!");
  }
}

// program entry point (depends on SystemA) //////////////////////////////////

void function main() {
  let system = new SystemA();

  system.run();
} ()

We add these directly to the same file we were working with before. If you follow along with the code in the archive, you'll notice that we've renamed the file from LineChecker.src to lines.app to reflect the fact that this is no longer a single module. This is not ideal—and we'll revisit this decision in the next section—but it suffices for this step of the walkthrough.

The code labelled the "program entry point" (containing the main function) is referred to as shunting block.

Our main here is an immediately invoked function expression, so it runs as soon as it is encountered. An IIFE is used here since the triple script dialect has certain prohibitions on the sort of top-level code that can appear in a triple script's global scope, to avoid littering the namespace with incidental values.

For now, the shunting block creates an instance of the system stub, and calls its run method. Predictably, this should fail, given that the stub method is not implemented. Having just completed the LineChecker.analyze implementation, you might guess that when we revisit this in later sections, a given implementation of the system interface should use the run method to eventually make a call to LineChecker.analyze.

Our code has gone from a single completed module to a larger, but unfinished, program. This is of course ideal, which is why we're not finished yet. In next section, we'll deal with a subtle but important concern.

Part 4: Environment

Recall that in the triple scripts ecosystem, the browser is the baseline environment—the universally consistent runtime that everyone already has available. The ubiquity of the Web browser exposes a convenient substrate for computation. Browsers, however, were not designed foremost to run programs. This poses a problem that we'll need to address. Browsers are written to expect content in terms of documents—specifically, Web pages—and that is the level of abstraction that we must deal with to obtain an execution context to run our program. Browsers can deal with offline documents, thus requiring no network delivery of our program, which is convenient for us, but a given piece of content is expected to be written in a markup language like HTML, XML (or one of its dialects like SVG), etc. To make sure the browser will treat our program appropriately, we use an old trick.

Let's observe briefly that (a) despite the fact that triple scripts are not written in JavaScript, the code we've written so far does conform to the ECMA-262 grammar, and (b) if we were to wrap our code in script tags before handing the file to the browser, the browser's JS engine will dutifully process it without raising an error. For compatibility reasons, instead of bare open and close script tags, we make sure to place each tag on its own line preceded by a double slash sequence: // <script> and // </script>. This design means that this character sequence can be similarly processed under the grammar recognized by ECMAScript-conformant software without raising an error.

Refer to the changes in the code archive corresponding to this Part 4 to see this in full—we place a single open sequence on the first line of the file and a close sequence at the end of the file. By also renaming it from lines.app to lines.app.htm, we ensure that on most systems, the file will open in the user's preferred Web browser when double clicked.

A second set of changes associated with this section introduces two new modules derived from SystemA:

// SystemB module (depends on SystemA) ///////////////////////////////////////

class SystemB extends SystemA { }

// SystemA module (depends on SystemA) ///////////////////////////////////////

class SystemC extends SystemA { }

These are also recognizable as stubs. This, along with what has appeared of the shunting block earlier in part 3, should hint towards our strategy: one module is permitted to make use of APIs accessible to the program when running in a browser environment, and the other module may use use other-platform specific facilities to allow the program to run from the command-line.

By convention, in the triple scripts ecosystem, SystemB implements the abstract system interface for the browser and SystemC implements it for command-line based runtimes (e.g. NodeJS).

The shunting block mentioned in part 3 serves a crucial purpose in a triple script: it's a platform-neutral way to initialize the system modules and transfer control to the application when it first starts up. For this reason, we sometimes describe the shunting block as the "entry point" to a triple script—and this is the same reason that by convention the function in the shunting block is called main. However, it is by necessity—not just convention—that the shunting block is the last t-block in the file.

(Because our program is not yet homologous—it only works in the browser—we don't yet actually have any t-blocks in our file, because t-blocks use triple slash script delimiters, whereas we are using "// <script>" for now, which have no special significance in the triple script grammar and are treated as ordinary comments.)

In the next section, we will fill out the exact details of each implementation and introduce code within the shunting block to select the appropriate system support module at runtime.

Part 5: System

To begin with, we'll ensure that we implement the browser-based system-level operations first. This is smart, because everyone has access to a Web browser.

In general, this is the correct approach for writing triple scripts. Suppose you start out with the intention of writing a triple script and begin work to get the program operational with some other, less ubiquitous runtime (such as NodeJS). There are two potential pitfalls with this approach:

The first is that if the time, energy, or attention in your budget dries up and you stop work after getting your program to work on the command-line using NodeJS but before ever getting anything to work in the browser, then you limit the reach and usefulness of your program to only the parts of your audience who have NodeJS installed and are comfortable using it to run programs. On the other hand, if you start by targeting the browser and quit before filling in the parts that allow it to work from the command-line, then even in this half-finished state, you haven't limited your audience at all, because—once again—everyone has access to a Web browser (even if some prefer to not have to leave the terminal.)

The second pitfall is that if you begin working on the command-line version first, you may make what turn out to be the wrong design decisions early on and end up with an overreliance on the platform-specific behavior of the underlying environment, especially if you're targeting NodeJS for the command-line. The same risk exists if you start working on the browser-based implementation, but the practices and conventions promoted in the NodeJS community are more apt to lead you to make bad assumptions and do things that will need to be undone, reworked, and turned inside out by the time you finish.

Let's now look at an implementation of print and read written for the browser:

class SystemB extends SystemA {
  constructor() {
    super();

    if (typeof(window) == "undefined") {
      throw new Error("Not a browser environment");
    }

    this._page = window.document.body;

    this._file = null;
    this._fileInput = null;
  }

  read(path) {
    return new Promise((resolve, reject) => {
      if (path == this._file.webkitRelativePath) {
        if (typeof(FileReader) != "undefined") {
          let reader = new FileReader();

          reader.addEventListener("loadend", (event) => {
            resolve(event.target.result);
          });

          return void(reader.readAsText(this._file));
        }
        return reject(Error("FileReader API not implemented"));
      }
      return reject(Error("Cannot read file with path: " + path));
    });
  }

  print(message) {
    this._page.textContent += "\n" + message;
  }
}

There are two important things to point out here before moving further, which are the two _file and _fileInput properties. The latter is neither initialized (to any useful value besides null) nor used anywhere in the excerpt above, and the former is used but never initialized. We'll discuss this, but let's first point out the obvious benefit of the existence of the SystemB class.

Recall that the first parameter of the LineChecker.analyze method defined in part 2 should be an object implementing the abstract system interface. Given the above SystemB implementation, there's a straightforward way to use it with our existing LineChecker code:

let system = new SystemB();
LineChecker.analyze(system, "path/to/file.txt");

In fact, something like this will form the basis of our first pass at our shunting block. However, instead of hardcoding the path of the file—which we don't and can't know now at the time of development—we'll instead introduce a system-level run method seen referenced in the shunting block earlier. This run method will be responsible for making sure we get the user-specified file. For the browser environment, an appropriate run implementation could look something like this:

run() {
  this._page.onload = (() => {
    this._page.innerHTML = "";

    this._page.style.fontFamily = "monospace";
    this._page.style.whiteSpace = "pre-wrap";

    let doc = this._page.ownerDocument;
    let $$ = doc.createElement.bind(doc);

    let button = $$("input");
    button.type = "button";
    button.value = "Load\u2026";
    button.style.float = "right";
    button.onclick = () => void(this._fileInput.click());
    this._page.appendChild(button);

    let fileInput = $$("input");
    fileInput.type = "file";
    fileInput.style.display = "none";
    fileInput.onchange = this._onFileSelected.bind(this);
    this._fileInput = this._page.appendChild(fileInput);
  })
}

In the run method above, we see the initialization and use of the _fileInput property, which refers to a browser-based filepicker. The _file property will be initialized in the _onFileSelected method, called when the user has proceeded to specify one using the filepicker:

_onFileSelected(event) {
  this._file = event.target.files[0];
  let path = this._file.webkitRelativePath;
  LineChecker.analyze(this, path);
}

To reiterate briefly, if we create a SystemB instance and call its run method, it will add a button to the page allowing the user to select the desired file. After it's selected, the browser system module transfers control to the main application logic we defined in parts 1 and 2 in the LineChecker.analyze method. The only thing missing at this point to make this actually usable in the browser is code to instantiate SystemB and invoke its run method. We'll do this in the shunting block, which we have already mentioned.

void function main() {
  if (!system) var system = init(SystemB);
  if (!system) var system = init(SystemC);

  function init($kind) {
    try {
      return new $kind;
    } catch (ex) {
      return null;
    }
  }

  if (!system) {
    throw new Error("Unknown environment");
  }

  system.run();
} ()

Recall from the SystemB constructor that we fail if we do not detect the availability of a window object. This is crucial to work in tandem with the logic of the shunting block.

Internally, our main has a macro-like init helper function defined to make it easier to try to initialize the system support modules in series The shunting block attempts to obtain (initialize) a host-appropriate implementation of the abstract system interface. Failing the attempt to initialize SystemB, the shunting block will try to initialize SystemC (used for NodeJS) instead. Failing that, we we bail. There are other runtimes available that might be able to run our programs, but for the sake of brevity in this document we are only concerned with supporting the browser and NodeJS.

You'll find now—with the above changes made, including the file rename to use the .app.htm extension mentioned before—that you can open lines.app.htm in your browser (e.g. by double clicking it), and it performs as expected. For any text file fed to our program, our analyzer will print the stats for the various types of line endings used in that file.

What follows in the implementation of SystemC is less involved than the work we did defining the SystemB implementation, since there is no DOM-based, graphical UI programming involved here. Here is our modest implementation of the system interface for the NodeJS platform support module:

class SystemC extends SystemA {
  constructor() {
    super();

    if (typeof(process) == "undefined") {
      throw new Error("Not a NodeJS environment");
    }

    this._nodeFS = process.mainModule.require("fs");

    let [ engine, script, ...args ] = process.argv;
    this.args = args;
  }

  read(path) {
    return new Promise((resolve, reject) => {
      this._nodeFS.readFile(path, { encoding: "utf8" }, (err, data) => {
        if (err) {
          reject(err);
        } else {
          resolve(data);
        }
      });
    });
  }

  print(message) {
    if (typeof(console) != "undefined") {
      console.log(message);
    } else { // should not happen, but the typeof check is unavoidable
      throw new Error("Cannot print message " + JSON.stringify(message));
    }
  }

  run() {
    if (this.args.length == 1) {
      return void(LineChecker.analyze(this, this.args[0]));
    }
    throw new Error("Expected file path");
  }
}

Once again, our read implementation uses ECMAScript-style promises to handle the asynchronous file access operation.

The implemenation of run in the NodeJS support module differs somewhat from the browser form, because on the command-line we take a file path as an argument passed directly from the OS shell—rather than adding a visual file picker control, as in the case of the browser-based UI—but notice how our main application code itself, i.e., the routine we originally wrote as LineChecker.analyze, is able to remain unchanged.

With these new additions to lines.app.htm, we have a self-contained program with a homologous UI: it runs on the most widespread platform in existence—the browser—and the same program can be used in the terminal when executed as a command-line app from the OS shell. If you have NodeJS installed, you can try running it in the terminal now by passing the path of a text file as the argument:

node lines.app.htm foo.txt

You can even try running the program on itself:

node lines.app.htm lines.app.htm

The applications of this should be exciting. If you're a developer who spends a lot of time in the terminal, triple scripts are nice because it means being able to target a hugely popular platform—the browser—for maximum compatibility with your potential audience without needing to make any assumptions about what the audience's computing setup looks like, and at the same time you yourself don't have to sacrifice the convenience of leaving the terminal just to run the program.

Despite being a single-file, homologous app, our program is not yet a triple script because it doesn't adhere to the triple script file format. In the next sections, we will go over some refactoring and reorganization that doesn't substantially change the implementation or functionality of our program. However, we will end with the program in the concatenated t-block format that is standard for triple scripts, and a light introduction to trplkt, triplescript.org's reference compiler.

Part 6: Format

At this time, we have a program usable from within the browser or the terminal, and it's self-contained by virtue of the fact that all our modules are embedded within the same file and it doesn't dynamically load any external code modules.

Consider, though, what would happen if a program like ours were to grow. It wouldn't necessarily be convenient to continue development if the size of our project reached, say, tens of thousands of lines of code in a single file. Developers are used to being able to split things up into modules that are ideally manageable with file-level abstractions. The triple script format addresses this.

With triple scripts, it's possible to organize the project source tree by splitting areas of code into conventional, file-based modules, and then given such a collection of modules, we can perform a traditional build step to give us a compiled version our program in a self-contained app file bearing some similarity to the version of lines.app.htm that we have now. Importantly, the compilation process for triple scripts has been designed to be non-destructive and reversible. The non-destructive compilation process allows anyone to take any given triple script and reverse the process to decompile it back into the original source modules. We call this automorphism, and it's achievable due to the use of triple slash delimited "t-blocks".

Recall that in part 4 we added "// <script>" and "// </script>" lines to surround our program.

Valid triple scripts, however, do not use sequences of this form. Instead they use triple slash delimiters ("/// <script>" and "/// </script>"). The mechanism by which these work is still the same—they are valid line comments as far as any ECMAScript-conformant engine is concerned—but the use of triple slashes also serves an important role. A triple slash delimiter appearing at the beginning of a file is an assertion that it's valid a triple script. That is, it's a machine-readable way to distinguish the file from others so that we know that a given program where they appear is intended to satisfy the three triple script invariants. The goal of triplescripts.org is to popularize this way of authoring programs and grow the ecosystem of triple script-based tooling, in light of the unrivaled accessibility and convenience afforded to users and potential contributors. The idea with the use of triple slash script delimeters, then, is to be able to clearly and unambiguously represent to downstream recipients of a triple script the authors' intent to adhere to these principles.

Having said that, we will not use these triple slash delimiters here. Instead, we will use the variants //? <script> and //? </script>. It's a good idea while developing a triple script not to use the triple slash delimiters, even if you can confirm that the program your code produces is a triple script. Only when you are ready to publish your triple script should you replace the use of these delimiters with the triple slash form. (The triplescripts.org tooling support this workflow, through the publish command in the reference compiler, which we will take a look at in the next section.)

In part 3 when we moved from LineChecker.src in favor of a whole-program source file, we quietly dropped the use of the export keyword as a result of the mangling we had to do to get it to actually be conformant to the grammar expected by legacy runtimes. (Since the .app.htm is not associated with an ES6 module context, neither browsers nor NodeJS will accept the export keyword appearing in the program text.) Triple scripts, however, support triple slash export pragmas in the compiled form. As with the triple slash script delimiters, these are also just ordinary line comments as far as any conformant ECMAScript engine is concerned.

Additionally, up to this point, we've been using developer-oriented comments explaining the module dependencies in lieu of any ES6-style import statements for reasons similar to the prohibition on the export keyword. For example, in the first incarnation of SystemB, we included a comment:

// SystemB module (depends on SystemA) ///////////////////////////////////////

These, too, will need to be changed—this time to use a triple slash import pragma. The format of an import pragma is the same as the result of the constraints imposed by an ES6 import statement. So rather than writing the natural language "depends on[...]" form above, it will need to become:

/// import { LineChecker } from "./src/LineChecker.src";

(However, the triple script dialect has much stricter rules about the form of the import pragma than the freeform nature of ES6. This is a good time for a reminder that triple scripts are not written in JavaScript. The style of import declaration above is the only form allowed in triple scripts, unlike JavaScripts myriad forms.)

In summary, to transform our lines.app.htm program into the interchange format suitable for the triple scripts ecosystem, we make the following changes:

After making the changes above, a file that is syntactically (and semantically) valid under the grammar supported by triplescripts.org.

To repeat, despite needing to make these changes, it's always a good idea to start out not using triple slash script delimiters especially while you're still implementing its basic parts. Remember, the triple slash script delimiters are an assertion that your script is a valid triple script. But if your script's basic parts are still unimplemented, then this assertion is untrue. So always start out using double slash script comment or the g-block-style //? <script> delimiters, as we've done here, and only distribute files using triple slash script delimiters when your program is published for release and actually satisfies the three triple script invariants.

Finally, in part 7 we cover some of the benefits of following the constraints of the dialect outlined here—namely, the availability of tooling from the triple scripts ecosystem and that has been created for processing the concatenated t-block/g-block file format.

Part 7: Details

We mentioned before in part 6 that a valid triple script is automorphic, and that this makes it trivial to take a compiled triple script and decompile it into its original source code. Let's do that now, since developers tend to prefer working with source code modules instead of monolithic files. A copy of trplkt is included in the code archive under the name Build.app.htm, and you can also obtain a copy from releases.triplescripts.org for your own projects.

If you double click Build.app.htm, it should open in your browser.

From there, you can load the directory where you extracted the archive for this example project, and then run the following command:

decompile lines.app.htm

(Alternatively, if you prefer to work from the terminal, you can use NodeJS or even GraalJS to run it using node Build.app.htm decompile lines.app.htm. You might notice something familiar here. Indeed, Build.app.htm is itself a triple script. It is a copy of triplescripts.org's reference compiler trplkt.)

Successful completion of this command should leave you with the source code modules LineChecker, BrowserSystem, NodeJSSystem, and main, which make up our program. Although our program is fairly small, it's conceivable that for a larger program made of many more modules and/or containing an order of magnitude more lines of code, it would be preferable to do regular development within a deconstructed source tree. And this is probably the form that that will be preferred by a project maintainer for any project intended to be used as a triple script. Nonetheless, the text-based app file can be trivially opened and modified just as readily as you can make changes to the source tree containing "raw" source files, and in any case, the property of automorphism means that the two forms, compiled and uncompiled, are equivalent, given that they can be seamlessly transformed from one form into the other (and back), infinitely with perfect fidelity.

Although the lines.app.htm is a general purpose utility, it should be obvious at this point, having used both the copy of trplkt that's embedded in the source code archive as Build.app.htm and the copy of Inaft under the path tests/harness.app.htm, that the original use case that triplescripts.org has envisioned for the application of triple scripts is as project metatooling meant to ease the software development process.

Because triple scripts are by design self-contained and automorphic, along with the relatively small size of trplkt and Inaft, you are encouraged to embed them directly into the project source tree by checking them into the repository.

By doing so, and because this triple script tooling runs in the browser, you need not worry about anyone not having some prerequisite installed just to be able to run something. The triplescripts.org group has arrived at this point as a response to the fact that the state of development even in 2020/2021 unfortunately tends to work like this:

  1. Choose a software stack/ecosystem that you'd like to work in
  2. Install the appropriate tooling—which might itself take a lot of work
  3. Hope that any potential contributor with an interest in the problem domain (i.e. the one that your project exists as a solution for) are themselves is either comfortable and familiar with the tooling and ecosystem from (1) and (2), or they're willing to put in a lot of upfront work to get to the point where they can begin to meaningfully contribute

In fact, this problem is a main factor behind the existence imprimatur of triplescripts.org.

We call the problem described above "implicit step zero"—meaning it's all the stuff that constitutes the zeroeth step that any given project maintainer assumes of contributors. The implicit step zero encapsulates everything that a potential contributor will need to accomplish before getting into a position to carry out step 1 in the respective project's README instructions for doing a build, or running any tests, or performing with any other task that is typical for the software development process today.

Our goal is to attack the problem of implicit step zero by trying to eliminate it completely, through the use of triple script-based tooling, as we've demonstrated here. For more info about the work of this group, visit triplescripts.org.

The entire source code listing for our line checking app follows below. Note that you can also download a copy of the source code archive containing lines.app.htm in its final form, along with all of these steps and a variant of this guide.

Appendix: lines.app.htm source code

Here is our program lines.app.htm in its final, compiled form:

/// <script>
/// export
function LineChecker(text) {
  this.text = text;
  this.position = 0;
}

LineChecker.analyze = function(system, path) {
  return system.read(path).then((contents) => {
    let checker = new LineChecker(contents);
    let stats = checker.getStats();

    system.print("  CR: " + stats[LineChecker.TYPE_CR]);
    system.print("  LF: " + stats[LineChecker.TYPE_LF]);
    system.print("CRLF: " + stats[LineChecker.TYPE_CRLF]);

    if (stats[LineChecker.TYPE_NONE]) {
      system.print("\nThis file doesn't end with a line terminator.");
    }
  });
}

LineChecker.prototype.getStats = function() {
  let stats = [];

  stats[LineChecker.TYPE_NONE] = 0;
  stats[LineChecker.TYPE_CR] = 0;
  stats[LineChecker.TYPE_LF] = 0;
  stats[LineChecker.TYPE_CRLF] = 0;

  while (this.position < this.text.length) {
    let end = LineChecker.findLineEnd(this.text, this.position);
    let kind = LineChecker.getEOLType(this.text, end);
    ++stats[kind];
    if (kind == LineChecker.TYPE_CR || kind == LineChecker.TYPE_LF) {
      this.position = end + 1;
    } else if (kind == LineChecker.TYPE_CRLF) {
      this.position = end + 2;
    } else {
      this.position = end;
    }
  }

  return stats;
}

LineChecker.findLineEnd = function(contents, position) {
  const LF_CODE = 0x0A;
  const CR_CODE = 0x0D;
  while (position < contents.length) {
    let unit = contents.codePointAt(position);
    if (unit != CR_CODE && unit != LF_CODE) {
      ++position;
      continue;
    }
    break;
  }
  return position;
}

LineChecker.getEOLType = function(contents, position) {
  const LF_CODE = 0x0A;
  const CR_CODE = 0x0D;
  if (position < contents.length) {
    let unit = contents.codePointAt(position);
    if (unit == CR_CODE) {
      if (LineChecker.getEOLType(contents, position + 1) ==
          LineChecker.TYPE_LF) {
        return LineChecker.TYPE_CRLF;
      }
      return LineChecker.TYPE_CR;
    } else if (unit == LF_CODE) {
      return LineChecker.TYPE_LF;
    }
  }
  return LineChecker.TYPE_NONE;
}

// NB: not ASCII codes--just enum-likes doubling as indexes into stats.
LineChecker.TYPE_NONE = 0;
LineChecker.TYPE_CR = 1;
LineChecker.TYPE_LF = 2;
LineChecker.TYPE_CRLF = 3;
/// </script>
/// <script>
/// export
class SystemA {
  run() {
    throw new Error("unimplemented!");
  }

  read(path) {
    throw new Error("unimplemented!");
  }

  print(message) {
    throw new Error("unimplemented!");
  }
}
/// </script>
/// <script>
/// import { SystemA } from "./SystemA.src"
/// import { LineChecker } from "./LineChecker.src"

/// export
class SystemB extends SystemA {
  constructor() {
    super();

    if (typeof(window) == "undefined") {
      throw new Error("Not a browser environment");
    }

    this._page = window.document.body;

    this._file = null;
    this._fileInput = null;
  }

  run() {
    this._page.onload = (() => {
      this._page.innerHTML = "";

      this._page.style.fontFamily = "monospace";
      this._page.style.whiteSpace = "pre-wrap";

      let doc = this._page.ownerDocument;
      let $$ = doc.createElement.bind(doc);

      let button = $$("input");
      button.type = "button";
      button.value = "Load\u2026";
      button.style.float = "right";
      button.onclick = () => void(this._fileInput.click());
      this._page.appendChild(button);

      let fileInput = $$("input");
      fileInput.type = "file";
      fileInput.style.display = "none";
      fileInput.onchange = this._onFileSelected.bind(this);
      this._fileInput = this._page.appendChild(fileInput);
    })
  }

  _onFileSelected(event) {
    this._file = event.target.files[0];
    let path = this._file.webkitRelativePath;
    LineChecker.analyze(this, path);
  }

  read(path) {
    return new Promise((resolve, reject) => {
      if (path == this._file.webkitRelativePath) {
        if (typeof(FileReader) != "undefined") {
          let reader = new FileReader();

          reader.addEventListener("loadend", (event) => {
            resolve(event.target.result);
          });

          return void(reader.readAsText(this._file));
        }
        return reject(Error("FileReader API not implemented"));
      }
      return reject(Error("Cannot read file with path: " + path));
    });
  }

  print(message) {
    this._page.textContent += "\n" + message;
  }
}
/// </script>
/// <script>
/// import { SystemA } from "./SystemA.src"
/// import { LineChecker } from "./LineChecker.src"

/// export
class SystemC extends SystemA {
  constructor() {
    super();

    if (typeof(process) == "undefined") {
      throw new Error("Not a NodeJS environment");
    }

    this._nodeFS = process.mainModule.require("fs");

    let [ engine, script, ...args ] = process.argv;
    this.args = args;
  }

  run() {
    if (this.args.length == 1) {
      return void(LineChecker.analyze(this, this.args[0]));
    }
    throw new Error("Expected file path");
  }

  read(path) {
    return new Promise((resolve, reject) => {
      this._nodeFS.readFile(path, { encoding: "utf8" }, (err, data) => {
        if (err) {
          reject(err);
        } else {
          resolve(data);
        }
      });
    });
  }

  print(message) {
    if (typeof(console) != "undefined") {
      console.log(message);
    } else { // should not happen, but the typeof check is unavoidable
      throw new Error("Cannot print message " + JSON.stringify(message));
    }
  }
}
/// </script>
/// <script>
/// import { LineChecker } from "./src/LineChecker.src"

/// import { SystemB } from "./src/SystemB.src"
/// import { SystemC } from "./src/SystemC.src"

void function main() {
  if (!system) var system = init(SystemB);
  if (!system) var system = init(SystemC);

  function init($kind) {
    try {
      return new $kind;
    } catch (ex) {
      return null;
    }
  }

  if (!system) {
    throw new Error("Unknown environment");
  }

  system.run();
} ()
/// </script>