Introduction
This tutorial is Part 1 of a series of five tutorials about engineering frontend web
applications with plain JavaScript. It shows how to build such an app
with minimal effort, not using any (third-party) framework or library. A frontend web
app can be provided by any web server, but it is executed on the user's computer
device (smartphone, tablet or notebook), and not on the remote web server. Typically, but not
necessarily, a frontend web app is a single-user application, which is not shared with
other users.
The minimal version of a JavaScript frontend data management app discussed in this
tutorial only includes a minimum of the overall functionality required for a complete app. It
takes care of only one object type ("books
") and supports the four standard data management
operations (Create/Read/Update/Delete), but it needs to be enhanced by styling the user interface with CSS rules,
and by adding further important parts of the app's overall functionality:
Part 2: Handling constraint validation
Part 3: Managing unidirectional associations
Part 4: Managing bidirectional associations
Part 5: Handling subtype (inheritance) relationships between object types
These additional parts will be published soon.
Background
This section provides a brief discussion of HTML and some elements of JavaScript, assuming that the reader is already
familiar with basic programming concepts and has some experience with programming, for
instance, in PHP, Java or C#.
HTML
We adopt the symbolic equation
HTML = HTML5 =
XHTML5
stating that when we just say "HTML", or "HTML5", we actually mean
XHTML5, because we prefer the clear syntax of XML documents over the liberal and confusing
HTML4-style syntax that is also allowed by HTML5.
JavaScript Objects
JavaScript objects are different from classical OO/UML objects. In particular, they need not instantiate a class. And they can have their own (instance-level) methods in the form of method slots, so they do not only have (ordinary) property slots, but also method slots. In addition, they may also have key-value slots.
So, they may have three different kinds of slots, while classical
objects (called "instance specifications" in UML) only have property
slots.
JavaScript objects can be used in many different ways for different
purposes. Here are five different use cases for, or possible meanings
of, JavaScript objects:
A record is a set of property slots like, for instance,
var myRecord = { firstName:"Tom", lastName:"Smith", age:26}
An associative array (or 'hash map') is a set of key-value slots. It supports look-ups of values based on keys like, for instance,
var numeral2number = { "one":1, "two":2, "three":3}
which associates the numeric value 1 with the key "one", 2 with "two",
etc. A key need not be a valid JavaScript identifier, but can be any
kind of string
(e.g. it may contain blank spaces).
An untyped object does not instantiate a class. It may have property slots and method slots like, for instance,
var person1 = {
lastName: "Smith",
firstName: "Tom",
getInitials: function () {
return this.firstName.charAt(0) + this.lastName.charAt(0);
}
};
A namespace may be defined in the form
of an untyped object referenced by a global object variable, the name of
which represents a namespace prefix. For instance, the following object
variable provides the main namespace of an application based on the
Model-View-Controller (MVC) architecture paradigm where we have three
subnamespaces corresponding to the three parts of an MVC application:
var myApp = { model:{}, view:{}, ctrl:{} };
A typed object o
that instantiates a class defined by a JavaScript constructor function C
is created with the expression:
var o = new C(...)
The type/class of such a typed object can be retrieved with the introspective expression:
o.constructor.name
Associative Arrays
An associative array is processed with the help of a special loop where we loop over all
keys of the associative array using the pre-defined function Object.keys(a)
,
which returns an array of all keys of an associative array a
. For
instance,
for (var i=0; i < Object.keys( numeral2number).length; i++) {
key = Object.keys( numeral2number)[i];
alert('The numeral '+ key +' denotes the number '+ numeral2number[key]);
}
For adding a new
element to an associative array, we simply create a new key-value
entry as in:
numeral2number["thirty two"] = 32;
For deleting an element from an associative array, we can use the pre-defined
JavaScript delete
operator as
in:
delete numeral2number["thirty two"];
Defining and Instantiating a Class
A class can be defined in two steps. First, define the constructor function that defines
the properties of the class and assigns them the values of the constructor's
parameters:
function Person( first, last) {
this.firstName = first;
this.lastName = last;
}
Next, define the instance-level methods of the class as
function slots of the prototype object property of the constructor function:
Person.prototype.getInitials = function () {
return this.firstName.charAt(0) + this.lastName.charAt(0);
}
Finally, class-level ("static
") methods can be defined as function slots of the constructor function, as
in:
Person.checkName = function (n) {
...
}
An instance of a class is created by applying the new
operator to the
constructor function:
var pers1 = new Person("Tom","Smith");
The method getInitials
is invoked on the Person
object pers1
by
using the 'dot notation':
alert("The initials of the person are: " + pers1.getInitials());
Coding the App
The purpose of
our example app is to manage information about books. That is, we deal with a single object
type: Book
, as depicted in the following figure.

What do we need for such an information management application? There are four standard use
cases, which have to be supported by the application:
Create: Enter the data of a book that is to be added to
the collection of managed books.
Read: Show a list of all books in the collection of
managed books.
Update the data of a book.
Delete a book record.
For entering data with the help of the keyboard and the screen of our computer, we can use
HTML forms, which provide
the user interface
technology for web applications.
For maintaining a collection of data objects, we need a storage technology that allows to
keep data objects in persistent records on a secondary storage device, such as a harddisk or a
solid state disk. Modern web browsers provide two such technologies: the simpler one is called
Local Storage, and the more powerful one is called
IndexDB. For our minimal example app, we use Local
Storage.
Step 1 - Set up the Folder Structure
In the first step, we set up our folder structure for the application. We pick a name for
our app, such as "Public Library", and a corresponding (possibly abbreviated) name for the
application folder, such as "publicLibrary". Then we create this folder on our computer's disk
and a subfolder "src" for our JavaScript source code files. In this folder, we create the
subfolders "model", "view" and "ctrl", following the Model-View-Controller paradigm for software application architectures. And
finally we create an index.html file for the app's start page, as discussed
below. Thus, we end up with the following folder structure:
publicLibrary
src
ctrl
model
view
index.html
The start page of the app loads the Book.js model class file and provides a
menu for choosing one of the CRUD data management operations performed by a corresponding page
such as, for instance, createBook.html, or for creating test data with the help
of the procedure Book.createTestData()
, or clearing all data with
Book.clearData()
:
The minimal app's start page index.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta charset="UTF-8" />
<title>Minimal JS Frontend App Example</title>
<script src="src/model/Book.js"></script>
</head>
<body>
<h1>Public Library</h1>
<h2>An Example of a Minimal JavaScript Frontend App</h2>
<p>This app supports the following operations:</p>
<menu>
<li><a href="listBooks.html"><button type="button">List all books</button></a></li>
<li><a href="createBook.html"><button type="button">Add a new book</button></a></li>
<li><a href="updateBook.html"><button type="button">Update a book</button></a></li>
<li><a href="deleteBook.html"><button type="button">Delete a book</button></a></li>
<li><button type="button" onclick="Book.clearData()">Clear database</button></li>
<li><button type="button" onclick="Book.createTestData()">Create test data</button></li>
</menu>
</body>
</html>
Step 2 - Write the Model Code
In the second step, we write the code of our model class in a specific JavaScript file. In
the information design model shown in above, there is only one class, representing the object
type Book
. So, in the folder src/model, we create a file
Book.js that initially contains the following code:
function Book( slots) {
this.isbn = slots.isbn;
this.title = slots.title;
this.year = slots.year;
};
The model class Book
is encoded as a JavaScript constructor function with a
single slots
parameter, which is supposed to be a record object with properties
isbn
, title
and year
, representing values for the
ISBN, the title and
the year attributes of the class Book
.
Therefore, in the constructor function, the values of the slots
properties are
assigned to the corresponding attributes whenever a new object is created as an instance of
this class.
In addition to defining the model class in the form of a constructor function, we also
define the following items in the Book.js file:
A class-level property Book.instances
representing the collection of all
Book
instances managed by the application in the form of an associative array.
A class-level method Book.loadAllInstances
for loading all managed Book
instances from the persistent data store.
A class-level method Book.saveAllInstances
for saving all managed Book
instances to the persistent data store.
A class-level method Book.createRow
for creating a new Book
instance.
A class-level method Book.updateRow
for updating an existing Book
instance.
A class-level method Book.deleteRow
for deleting a Book
instance.
A class-level method Book.createTestData
for creating a few example book
records to be used as test data.
A class-level method Book.clearData
for clearing the book datastore.
1. Representing the collection of all Book instances
For representing the collection of all Book
instances managed by the application, we define
and initialize the class-level property Book.instances
in the following
way:
Book.instances = {};
So, initially our collection of books is empty. In fact, it's defined as an empty object,
since we want to represent it in the form of an associative array (a set of key-value slots,
also called 'hashmap') where an ISBN is a key for accessing the corresponding book object
(as the value associated with the key). We can visualize the structure of such an
associative array in the form of a lookup table, as shown in Table 1.
Table 1: An associative array representing a collection of books
Key |
Value |
006251587X |
{ isbn:"006251587X," title:"Weaving the Web", year:2000 } |
0465026567 |
{ isbn:"0465026567," title:"Gödel, Escher, Bach", year:1999 } |
0465030793 |
{ isbn:"0465030793," title:"I Am A Strange Loop", year:2008 } |
Notice that the values of this associative array are simple objects corresponding to
table rows. Consequently, we could represent them also in a simple table, as shown in Table
2.
Table 2: A collection of book objects represented as a table
ISBN |
Title |
Year |
006251587X |
Weaving the Web |
2000 |
0465026567 |
Gödel, Escher, Bach |
1999 |
0465030793 |
I Am A Strange Loop |
2008 |
2. Loading all Book instances
For persistent data storage, we use Local Storage, which
is a HTML5 JavaScript API supported by modern web browsers. Loading the book records from
Local Storage involves three steps:
Retrieving the book table that has been stored as a large string
with the key
"bookTable
" from Local Storage with the help of the
assignment:
bookTableString = localStorage["bookTable"];
This retrieval is performed in line 5 of the program listing below.
Converting the book table string into a corresponding associative array
bookTable
with book rows as elements, with the help of the built-in function JSON.parse
:
bookTable = JSON.parse( bookTableString);
This conversion, performed in line 11 of the program listing below, is called deserialization.
Converting each row of bookTable
(representing an untyped record object)
into a corresponding object of type Book
stored as an element of the
associative array Book.instances
, with the help of the procedure
convertRow2Obj
defined as a "static
" (class-level) method in the
Book
class:
Book.convertRow2Obj = function (bookRow) {
var book = new Book( bookRow);
return book;
};
Here is the full code of the procedure:
Book.loadAllInstances = function () {
var key="", keys=[], bookTableString="", bookTable={};
try {
if (localStorage["bookTable"]) {
bookTableString = localStorage["bookTable"];
}
} catch (e) {
alert("Error when reading from Local Storage\n" + e);
}
if (bookTableString) {
bookTable = JSON.parse( bookTableString);
keys = Object.keys( bookTable);
console.log( keys.length +" books loaded.");
for (var i=0; i < keys.length; i++) {
key = keys[i];
Book.instances[key] = Book.convertRow2Obj( bookTable[key]);
}
}
};
Notice that since an input operation like localStorage["bookTable"]
may fail,
we perform it in a try
-catch
block, where we can follow up with an error message whenever
the input operation fails.
3. Saving all Book instances
Saving all book objects from the Book.instances
collection in main memory to
Local Storage in secondary memory involves two steps:
Converting the associative array Book.instances
into a string
with
the help of the predefined JavaScript function
JSON.stringify
:
bookTableString = JSON.stringify( Book.instances);
This conversion is called serialization.
Writing the resulting string
as the value of the key "bookTable
" to Local
Storage:
localStorage["bookTable"] = bookTableString;
These two steps are performed in line 5 and in line 6 of the following program
listing:
Book.saveAllInstances = function () {
var bookTableString="", error=false,
nmrOfBooks = Object.keys( Book.instances).length;
try {
bookTableString = JSON.stringify( Book.instances);
localStorage["bookTable"] = bookTableString;
} catch (e) {
alert("Error when writing to Local Storage\n" + e);
error = true;
}
if (!error) console.log( nmrOfBooks + " books saved.");
};
4. Creating a new Book instance
The Book.createRow
procedure takes care of creating a new Book
instance and adding it to the Book.instances
collection:
Book.createRow = function (slots) {
var book = new Book( slots);
Book.instances[slots.isbn] = book;
console.log("Book " + slots.isbn + " created!");
};
5. Updating an existing Book instance
For updating an existing Book
instance, we first retrieve it from
Book.instances
, and then re-assign those attributes the value of which has
changed:
Book.updateRow = function (slots) {
var book = Book.instances[slots.isbn];
var year = parseInt( slots.year);
if (book.title !== slots.title) { book.title = slots.title;}
if (book.year !== year) { book.year = year;}
console.log("Book " + slots.isbn + " modified!");
};
Notice that in the case of a numeric attribute (such as year
), we have to
make sure that the value of the corresponding input parameter (y
), which is
typically obtained from user input via an HTML form, is converted from String
to Number
with
one of the two type conversion functions parseInt
and
parseFloat
.
6. Deleting an existing Book instance
A Book
instance is deleted from the Book.instances
collection by first testing
if the associative array has an element with the given key (line 2), and then applying the
JavaScript built-in delete
operator:, which deletes a slot from an object, or,
in our case, an element from an associative array:
Book.deleteRow = function (isbn) {
if (Book.instances[isbn]) {
console.log("Book " + isbn + " deleted");
delete Book.instances[isbn];
} else {
console.log("There is no book with ISBN " + isbn + " in the database!");
}
};
7. Creating test data
For being able to test our code, we may create some test data and save it in our Local
Storage database. We can use the following procedure for this:
Book.createTestData = function () {
Book.instances["006251587X"] = new Book({isbn:"006251587X", title:"Weaving the Web", year:2000});
Book.instances["0465026567"] = new Book({isbn:"0465026567", title:"Gödel, Escher, Bach", year:1999});
Book.instances["0465030793"] = new Book({isbn:"0465030793", title:"I Am A Strange Loop", year:2008});
Book.saveAllInstances();
};
8. Clearing all data
The following procedure clears all data from Local Storage:
Book.clearData = function () {
if (confirm("Do you really want to delete all book data?")) {
localStorage["bookTable"] = "{}";
}
};
Step 3 - Initialize the Application
We initialize the application by defining its namespace and MVC subnamespaces. Namespaces
are an important concept in software engineering and many programming languages, including
Java and PHP, provide specific support for namespaces, which help grouping related pieces of
code and avoiding name conflicts. Since there is no specific support for namespaces in
JavaScript, we use special objects for this purpose (we may call them "namespace objects").
First, we define a root namespace (object) for our app, and then we define three subnamespaces,
one for each of the three parts of the application code: model, view and controller. In the case of our example app, we may use the following code for
this:
var pl = { model:{}, view:{}, ctrl:{} };
Here, the main namespace is defined to be pl
, standing for "Public Library",
with the three subnamespaces model
, view
and ctrl
being
initially empty objects. We put this code in a separate file initialize.js in the
ctrl folder, because such a namespace definition belongs to the controller part
of the application code.
Step 4 - Implement the List Objects Use Case
This use case corresponds to the "Read" from the four basic data management use cases
Create-Read-Update-Delete (CRUD).
The user interface for this use case is provided by the following HTML page containing an
HTML table for displaying the book objects. For our example app, this page would be called
listBooks.html (in the main folder publicLibrary) and would
contain the following HTML code:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta charset="UTF-8" />
<title>Minimal JS Frontend App Example</title>
<script src="src/ctrl/initialize.js"></script>
<script src="src/model/Book.js"></script>
<script src="src/view/listBooks.js"></script>
<script>
window.addEventListener( "load", pl.view.listBooks.setupUserInterface);
</script>
</head>
<body>
<h1>Public Library: List all books</h1>
<table id="books">
<thead><tr><th>ISBN</th><th>Title</th><th>Year</th></tr></thead>
<tbody></tbody>
</table>
<nav><a href="index.html">Back to main menu</a></nav>
</body>
</html>
Notice that this HTML file loads three JavaScript files: the controller file
src/ctrl/initialize.js, the model file src/model/Book.js and the
view file src/view/listBooks.js. The first two files contain the code for
initializing the app and for the model class Book
as explained above, and the
third one, which represents the UI code of the "list books" operation, is developed now. In
fact, for this operation, we just need a procedure for setting up the data management context
and the UI, called setupUserInterface
:
pl.view.listBooks = {
setupUserInterface: function () {
var tableBodyEl = document.querySelector("table#books>tbody");
var keys=[], key="", row={};
Book.loadAllInstances();
keys = Object.keys( Book.instances);
for (var i=0; i < keys.length; i++) {
key = keys[i];
row = tableBodyEl.insertRow();
row.insertCell(-1).textContent = Book.instances[key].isbn;
row.insertCell(-1).textContent = Book.instances[key].title;
row.insertCell(-1).textContent = Book.instances[key].year;
}
}
};
The simple logic of this procedure consists of two steps:
Read the collection of all objects from the persistent data store (in line 6).
Display each object as a row in a HTML table on the screen (in the loop starting in
line 9).
More specifically, the procedure setupUserInterface
first creates the book
objects from the corresponding rows retrieved from Local Storage by invoking
Book.loadAllInstances()
and then creates the view table in a loop over all
key-value slots of the associative array Book.instances
where each value
represents a book
object. In each step of this loop, a new row is created in the table body
element with the help of the JavaScript DOM operation insertRow()
, and then three
cells are created in this row with the help of the DOM operation insertCell()
:
the first one for the isbn
property value of the book object, and the second and
third ones for its title
and year
property values. Both insertRow
and insertCell
have to be invoked with the
argument -1
for making sure that new elements are appended to the list of rows and
cells.
Step 5 - Implement the Create Object Use Case
For a data management operation with user input, such as the "create object" operation, an
HTML page with an HTML form is required as a user interface. The form has a form field for
each attribute of the Book
class. For our example app, this page would be called
createBook.html (in the app folder publicLibrary) and would
contain the following HTML
code:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta charset="UTF-8" />
<title>Minimal JS Frontend App Example</title>
<script src="src/ctrl/initialize.js"></script>
<script src="src/model/Book.js"></script>
<script src="src/view/createBook.js"></script>
<script>
window.addEventListener("load", pl.view.createBook.setupUserInterface);
</script>
</head>
<body>
<h1>Public Library: Create a new book record</h1>
<form id="Book">
<p><label>ISBN: <input name="isbn" /></label></p>
<p><label>Title: <input name="title" /></label></p>
<p><label>Year: <input name="year" /></label></p>
<p><button type="button" name="commit">Save</button></p>
</form>
<nav><a href="index.html">Back to main menu</a></nav>
</body>
</html>
The view code file src/view/createBook.js contains two procedures:
setupUserInterface
takes care of retrieving the collection of all
objects from the persistent data store and setting up an event handler
(handleSaveButtonClickEvent
) on the save button for handling click button
events by saving the user input data;
handleSaveButtonClickEvent
reads the user input data from the form
fields and then saves this data by calling the Book.saveRow
procedure.
pl.view.createBook = {
setupUserInterface: function () {
var saveButton = document.forms['Book'].commit;
Book.loadAllInstances();
saveButton.addEventListener("click",
pl.view.createBook.handleSaveButtonClickEvent);
window.addEventListener("beforeunload", function () {
Book.saveAllInstances();
});
},
handleSaveButtonClickEvent: function () {
var formEl = document.forms['Book'];
var slots = { isbn: formEl.isbn.value,
title: formEl.title.value,
year: formEl.year.value};
Book.createRow( slots);
formEl.reset();
}
};
Step 6 - Implement the Upate Object Use Case
Again, we have a user interface page (updateBook.html
) and a view
code file (src/view/updateBook.js). The HTML form for the UI of the "update object"
operation has a selection field for choosing the book to be updated, and a form field for each
attribute of the Book
class. However, the form field for the standard identifier
attribute (ISBN) is read-only because we do not allow changing the standard identifier of an
existing
object.
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta charset="UTF-8" />
<title>Minimal JS Frontend App Example</title>
<script src="src/ctrl/initialize.js"></script>
<script src="src/model/Book.js"></script>
<script src="src/view/updateBook.js"></script>
<script>
window.addEventListener("load", pl.view.updateBook.setupUserInterface);
</script>
</head>
<body>
<h1>Public Library: Update a book record</h1>
<form id="Book">
<p>
<label>Select book:
<select name="selectBook"><option value=""> --- </option></select>
</label>
</p>
<p><label>ISBN: <input name="isbn" readonly="readonly" /></label></p>
<p><label>Title: <input name="title" /></label></p>
<p><label>Year: <input name="year" /></label></p>
<p><button type="button" name="commit">Save Changes</button></p>
</form>
<nav><a href="index.html">Back to main menu</a></nav>
</body>
</html>
The setupUserInterface
procedure now has to set up a selection field by
retrieveing the collection of all book objects from the
persistent data store for populating the select
element's option list:
pl.view.updateBook = {
setupUserInterface: function () {
var formEl = document.forms['Book'],
saveButton = formEl.commit,
selectBookEl = formEl.selectBook;
var key="", keys=[], book=null, optionEl=null;
Book.loadAllInstances();
keys = Object.keys( Book.instances);
for (var i=0; i < keys.length; i++) {
key = keys[i];
book = Book.instances[key];
optionEl = document.createElement("option");
optionEl.text = book.title;
optionEl.value = book.isbn;
selectBookEl.add( optionEl, null);
}
selectBookEl.addEventListener("change", function () {
var book=null, key = selectBookEl.value;
if (key) {
book = Book.instances[key];
formEl.isbn.value = book.isbn;
formEl.title.value = book.title;
formEl.year.value = book.year;
} else {
formEl.isbn.value = "";
formEl.title.value = "";
formEl.year.value = "";
}
});
saveButton.addEventListener("click",
pl.view.updateBook.handleUpdateButtonClickEvent);
window.addEventListener("beforeunload", function () {
Book.saveAllInstances();
});
},
handleUpdateButtonClickEvent: function () {
var formEl = document.forms['Book'];
var slots = { isbn: formEl.isbn.value,
title: formEl.title.value,
year: formEl.year.value
};
Book.updateRow( slots);
formEl.reset();
}
};
Step 7 - Implement the Delete Object Use Case
For the "delete object" use case, the UI form just has a selection field for choosing the
book to be
deleted:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta charset="UTF-8" />
<title>Minimal JS Frontend App Example</title>
<script src="src/ctrl/initialize.js"></script>
<script src="src/model/Book.js"></script>
<script src="src/view/deleteBook.js"></script>
<script>
window.addEventListener("load", pl.view.deleteBook.setupUserInterface);
</script>
</head>
<body>
<h1>Public Library: Delete a book record</h1>
<form id="Book">
<p>
<label>Select book:
<select name="selectBook"><option value=""> --- </option></select>
</label>
</p>
<p><button type="button" name="commit">Delete</button></p>
</form>
<nav><a href="index.html">Back to main menu</a></nav>
</body>
</html>
The view code in src/view/deleteBook.js consists of the following two
procedures:
pl.view.deleteBook = {
setupUserInterface: function () {
var deleteButton = document.forms['Book'].commit;
var selectEl = document.forms['Book'].selectBook;
var key="", keys=[], book=null, optionEl=null;
Book.loadAllInstances();
keys = Object.keys( Book.instances);
for (var i=0; i < keys.length; i++) {
key = keys[i];
book = Book.instances[key];
optionEl = document.createElement("option");
optionEl.text = book.title;
optionEl.value = book.isbn;
selectEl.add( optionEl, null);
}
deleteButton.addEventListener("click",
pl.view.deleteBook.handleDeleteButtonClickEvent);
window.addEventListener("beforeunload", function () {
Book.saveAllInstances();
});
},
handleDeleteButtonClickEvent: function () {
var selectEl = document.forms['Book'].selectBook;
var isbn = selectEl.value;
if (isbn) {
Book.deleteRow( isbn);
selectEl.remove( selectEl.selectedIndex);
}
}
};
Run the App and Get the Code
You can run the
minimal app from our server, and find more resources about web engineering, including open access books, on web-engineering.info.
Points of Variation
Instead of using the Local Storage API, the IndexDB API could be used for the persistent storage of the application data..
Points of Extension
The code of this app should be extended by adding some CSS styling for the user interface pages and constraint validation. We plan to publish a follow-up tutorial that shows how to do this.
History
-
2 April 2014: First version created
- 3 April 2014: Corrected typos, added link, added new subsection "HTML" and new section "Points of Variation"
- 11 April 2014: Improved table formating, updated code, resolved mismatches between article and code
- 12 May 2014: Updated hyperlinks