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 222 lines in length 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 working with 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. Adding an implementation of the system interface)
  4. Revisting the shunting block
  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 0xA 0xD), or something completely different.

Next, we discuss the implementation strategy. Here we 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.

In a triple script, the recommended approach is to define separate platform support modules designed to abstract over the details of the underlying platform. Following this design, the platform-specific code should be confined to these modules, which implement a simple, consistent system-level interface. The rest of the code, including the main application logic of our program, should consist of portable code written in the triple script dialect that doesn't directly access any of the underlying platform's bindings—instead, it should target the system-level interface defined and implemented in the platform support modules.

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.

We're almost at a good enough place that we can jump in and begin writing code soon. But first, understand that there are actually four different types of line endings we'll deal with:

(If there's confusion about "none", consider a file containing a single line. 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 newline 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, 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 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 simple software 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 implement 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 ECMA-262 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 file input, analysis, and 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 and 5, and (b) doing a little massaging so things are wired correctly for the triple script file format in parts 4 and 6.

Part 3: System

To begin with, we'll implement the browser-based system-level operations first, because everyone has access to a browser.

In general, this is a good approach for writing triple scripts. Suppose you start out with the intention of writing a triple script and begin work on the command-line version targeting, say, NodeJS instead of targeting the browser first. There are two potential pitfalls:

The first is that if your time, energy, or attention 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 a half-finished state, you haven't limited your audience at all, because—once again—everyone can run the browser-based implementation, even those who would prefer not 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 making assumptions and doing things that will need to be reworked and turned inside out by the time you finish.

To get our program to work in the browser, we transform our existing LineChecker module by:

  1. surrounding it with double slash script delimiters "// <script>" and "// </script>", respectively
  2. replacing the export keyword with a comment "// export" reminding us that this was the original export of the module before we had to mangle the source text to get it to run in the browser (although we could just remove it entirely, but we won't—more on this later)
  3. renaming the file to lines.app.htm, which when used in conjunction with the delimiters from (1) gets the browser to actually run our code when we open up the file (since browsers don't execute .js files directly; they operate on web pages that may optionally rely on scripts)

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

// export
class BrowserSystem {
  constructor() {
    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 BrowserSystem 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 BrowserSystem implementation, there's a straightforward way to use it with our existing LineChecker code:

let system = new BrowserSystem();
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, rather than hardcoding the path of the file—which we don't and can't know at the time of development—we'll instead introduce a system-level run method that will be responsible for making sure we get the user-specified file, and our shunting block will defer to it:

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. The _file property will be initialized in the _onFileSelected method:

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

Briefly, if we create a BrowserSystem 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 BrowserSystem and invoke its run method. We'll do this in the shunting block, which we have already mentioned.

In the code archive the shunting block for part 3 appears in its non-finalized form at the bottom of line.app.htm as:

// <script>
// import "BrowserSystem.js"

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

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

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.

In part 4, we'll do an in-depth discussion of the role of a triple script's shunting block, and rework ours into a form fit for use for when lines.app.htm is transformed to work from the command-line, which will be covered in part 5.

Part 4: Shunting

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 we might say makes these "d-blocks" instead.)

Let's revist the shunting block we defined in part 3 and get it into the form we want it to take in the "complete" version of lines.app.htm. Remember that the shunting block will need to be able to route execution whether our program is running in the browser or running in the terminal.

Here's a revised shunting block that works for these purposes:

// <script>
// import "BrowserSystem.js"
// import "NodeJSSystem.js"

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

  if (!system) var system = init(BrowserSystem);
  if (!system) var system = init(NodeJSSystem);

  if (!system) {
    // Might be running in an Inaft test context
    if (typeof(module) != "undefined") {
      module.exports.LineChecker = LineChecker;
    } else {
      throw new Error("Unknown environment");
    }
  }

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

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.

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. Failing the attempt to initialize BrowserSystem, the shunting block will try to initialize NodeJSSystem instead. Failing that, we make a last ditch effort in the event that we might be running inside a test environment. Failing that, the shunting block will throw an error. 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.

The final line system && system.run() will transfer control to the platform-specific run method. We have already seen the BrowserSystem implementation in part 3.

Previously, we hadn't defined a NodeJSSystem module. We can add a stubbed out implementation now:

// <script>
class NodeJSSystem {
  constructor() {
    throw new Error; // TODO
  }
}
// </script>

With these changes added to our lines.app.htm, we can see that our program will still run in the browser, as before. However, we are now in a place from which we can comfortably move to the last part of this walkthrough that involves writing any substantial code: where we fill out the NodeJSSystem to turn our program into homologous app capable of running in the terminal.

What follows in part 5 is slightly less involved than the work we did defining the BrowserSystem implementation in part 3, since there is no DOM-based, graphical UI programming involved in the NodeJSSystem implementation.

Part 5: Completing

Recall that our LineChecker program requires the system layer to provide a read method and a print method. Additionally, it needs to implement a run method to handle control transfer from the shunting block.

Here is our modest implementation of this interface for the NodeJS platform support module:

// <script>
// import "LineChecker.js"

// export
class NodeJSSystem {
  constructor() {
    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) {
    console.log(message);
  }
}
// </script>

Once again, our read implementation uses ECMA-262 Promises to handle the asynchronous file access operation.

The NodeJS system layer's implementation of run 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

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 two 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. The non-destructive compilation process allows anyone to take any given triple script and reverse the process and decompile it back into the original source modules. We call this automorphism, and it's achievable with the use of triple slash delimited "t-blocks".

Recall that in part 3 when we began implementing the system layer for the browser, we began by adding "// <script>" and "// </script>" line comments to surround our modules.

Valid triple scripts, however, do not use delimiters 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-compliant engine is concerned—but the use of triple slashes also serves an important role. 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 on the users system and know that it's intended to satisfy the three triple script invariants. The goal of triplescripts.org is to popularize this way of authoring programs 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 the principles of the triple script ecosystem.

In part 3 we also commented out 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 the 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 far as any conformant ECMAScript engine is concerned, these are also just ordinary line comments.

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 NodeJS system layer, we've included a comment:

// import "LineChecker.js"

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 above, it will need to become:

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

(However, the triple script dialect has much stricter rules about the form of the import pragma than the freeform nature of ES6; the from of the pragma above is the only form allowed in triple scripts.)

In summary, to transform our lines.app.htm program into proper concatenated t-block format, we make the following changes:

After making the changes above, we will now have a syntactically valid triple script.

Note that, 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 delimiters, as we've done here, and then switch to triple slash script delimiters when your program actually satisfies the three triple script invariants—especially the homologous invariant.

Now that we have a valid triple script, 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 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 GraalJS or NodeJS to run it using node Build.app.htm decomplie lines.app.htm. This is because trplkt itself is implemented as a triple script.)

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 keeping the project repository in, for any project corresponding to 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, this encourages that you embed them directly into the project source tree by checking them into the repository.

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. At triplescripts.org, we've arrived at this point as a response to the fact that the state of development in 2019 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) is 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 meaningfully contributing

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

We call the problem described in the above steps "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>
/// import { LineChecker } from "LineChecker.js";

/// export
class BrowserSystem {
  constructor() {
    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 { LineChecker } from "LineChecker.js";

/// export
class NodeJSSystem {
  constructor() {
    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) {
    console.log(message);
  }
}
/// </script>
/// <script>
/// import { BrowserSystem } from "BrowserSystem.js";
/// import { NodeJSSystem } from "NodeJSSystem.js";

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

  if (!system) var system = init(BrowserSystem);
  if (!system) var system = init(NodeJSSystem);

  if (!system) {
    // Might be running in an Inaft test context
    if (typeof(module) != "undefined") {
      module.exports.LineChecker = LineChecker;
    } else {
      throw new Error("Unknown environment");
    }
  }

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