A reactive library that makes one HTML file a working app

For a small app that lives in one HTML file and keeps its data as elements in that file. Stores all its state in the DOM.

A sapjs todo app.
  • Buy milk
  • Call the landlord
1 task left

The tasks are elements

<main sap id="hero-demo">
sap marks the app. sapjs reads everything inside it again after each change.
  <ul items="todos">
items names a list. Each child marked item is one task.
    <li item template>
template is the blank task that every new one is copied from.
      <input type="checkbox" bind="done" persist>
bind makes the checkbox a field of the task. persist writes its checked state into the markup, so saving the file keeps it.
      <span bind="title"></span>
    </li>
    <li item><input type="checkbox" bind="done" persist checked> <span bind="title">Buy milk</span></li>
    <li item><input type="checkbox" bind="done" persist> <span bind="title">Call the landlord</span></li>
  </ul>

Adding a task

  <form trigger-add="todos">
trigger-add copies the template into the list when the form is submitted, fills in the title you typed, and clears the form.
    <input bind="title" placeholder="Add a task" required>
  </form>

The count

  <output text="plural(count(state.todos, t => !t.done), 'task', 'tasks') + ' left'">1 task left</output>
text holds a formula. sapjs reruns it after every change and writes the result inside the element, where it saves with the file.
</main>
Build an app with the guide See the demos run

When to use it

Use sapjs when your whole app lives in one HTML file, different parts of the page need to react to each other and you want all the state stored in the document itself.

How it works

Say you keep a reading log as one HTML file. Each book is a <tr> in a table. The day you started and the day you finished are attributes on that row. An <output> under the table shows how many books you finished. Open the file in a text editor and every book is there as markup.

reading-log.html the file, as markup <tr data-finished="Sep 14"> <tr data-finished="Oct 2"> <tr data-finished=""> in the browser the same rows, drawn Piranesi finished Sep 14 The Overstory finished Oct 2 Middlemarch reading 2 finished The count is a formula in the same file <output text="count(state.books, b => b.finished) + ' finished'">

The count is a formula in an attribute on that <output>. Add a book or change a date, and sapjs re-reads the page and updates the count. Change a date by hand in devtools and the count updates, the same as after a click. A changed book is read from its row again, and an unchanged one keeps exactly what its row says, so there is no JavaScript copy to drift from the page. How it works shows each step of that re-read.

before PiranesiSep 14 The OverstoryOct 2 Middlemarchno date 2 finished 1Middlemarch is finished 2reads every row 3counts: 3 finished 4paints the count sapjs after PiranesiSep 14 The OverstoryOct 2 MiddlemarchOct 14 3 finished
Add a finished date by a click, in devtools or in a text editor, and sapjs reads the rows again and paints the new count.

The last count is written inside the <output> in the file, so it shows before any script runs.

I think sapjs works best when the interface and the data both fit in that one file. You edit the file and share it like any document. Add a finished date to a row in a text editor, open the file, and the count goes up by one. Send the file to a friend and they get the app with every book in it.

ClayJS is the library that saves an HTML file back to its host and syncs it between tabs. Saving takes ClayJS, or another client like it, on a host that accepts saves. Put persist on each input, textarea and select in a record, so what you type reaches the saved markup.

What you can build

The apps that fit are lists of records. An expense tracker is rows of expenses and a total. A board is columns of cards. With sapjs, each record is an element in the file, and each number is a formula in an attribute.

Expense tracker

A form adds an expense as a new row. A calc: attribute sums the amounts, and text:usd2 paints the total as $54.50.

trigger-add · calc: · text:usd2

Kanban board

Columns hold cards as nested lists. Drag a card to another column with ClayJS sortable, and the move saves as the new order of the markup. Click a card and a <dialog> opens to edit it.

items= · detail=

Notes

The notes are saved records. The list you see is an each= view, sorted by title and filtered by a search box. The search box carries transient, an attribute that keeps the search text out of the saved file. A detail= panel edits the open note.

each= · transient · detail=

Reading log

Each book stores the day you started and the day you finished. days() shows how long each one took, and count() shows how many you finished.

calc: · days() · count()

Contacts

Click a name and a detail= form edits that contact. A sort: button sorts the list by any field, and confirm= asks before a delete.

detail= · sort: · confirm=

Invoice

Each line computes its own total. The invoice sums the lines and paints $1,234.50. move:up and move:down reorder the lines.

calc: · sum() · move:

If you want running code to read, the guide walks through an expense tracker, a Kanban board and a notes app, and each one runs in the sapjs test suite.

Where this library struggles

Tens of thousands of records

At 10,000 records a change still takes under 40 ms in Chrome. The cost is the file itself: every row is saved markup, so saving takes about 400 ms and a list of 10,000 text boxes takes over a second to lay out. Past that, measure before you build on it.

Data some viewers must not see

The data is in the markup. Anyone with the file has all of it.

Mostly a window onto a server

If the records live behind an API, keeping them in the DOM gives you nothing back.

An editor built on a rich-text library

sapjs state is plain text. A rich-text editor goes inside an element with sap-ignore, an attribute that marks a region sapjs does not read. An app built around such an editor ends up mostly editor code.

Quick start

On its own, the app runs and recomputes. Nothing saves.

<script src="https://cdn.jsdelivr.net/npm/@panphora/sapjs/dist/sap.min.js"></script>

With ClayJS, the file saves itself on a host that accepts saves. Load sap.js after clay.js, and see the guide for the rest.

<html autosave>
<script src="https://clayjs.com/v1/clay.js?plugins=sync,sortable,undo"></script>
<script src="https://clayjs.com/v1/sap.js"></script>

sapjs by David Miranda. MIT-0 license.