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:
- a text editor; and
- a web browser—which you're probably using now (if you're reading this on triplescripts.org; or at the very least you probably have one installed and within easy reach)
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
- Triple script fundamentals and first code
- Main program logic using the system interface
- System stub and the program entry point
- More stubs and host environment concerns
- Completing the system interface implementation
- Switching to the triple script file format
- 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:
- CRLF
- LF
- CR
- "none"
(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:
- use triple slash import and export pragmas
- remove the double slash script comments and insert delimiters around each constituent module
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:
- Choose a software stack/ecosystem that you'd like to work in
- Install the appropriate tooling—which might itself take a lot of work
- 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>