Skip to content

Repository files navigation

Markout

Build Test Coverage CodeQL

Markout is an HTML extension that adds modularity, reactivity and isomorphism to plain HTML. It is not an application framework. Framework-shaped features live in kits, written in Markout itself rather than built into the language. You can use the ones that ship, the standard kit and bootstrap-kit (Bootstrap 5.3 as components), or write your own.

Orbit, an operations console: a KPI row with sparklines, a traffic chart, a regional breakdown and a cost forecast

Orbit is an operations console demo written in Markout + bootstrap-kit, over a directory of JSON files and no back end. Its numbers are fetched while the page renders, so every one of them is in the HTML that arrives and the browser asks for nothing, as can be seen in its source. Its source · all the demos

The page stays HTML. Anything without a ${...} or a : is plain markup and stays plain markup, so adopting Markout means adding an attribute to a page you already have, and you can stop at any point. What it buys is the two things a static page cannot do for itself: recurring markup becomes a tag you name once, and the page keeps itself in step with its own data.

Markout is the presentation layer, and only that. The DOM is the view, your application's data is the model, and Markout is the logic between them: deriving what is shown from what is true, and folding what a user does back into data. In the vocabulary of model-view-presenter it is the presenter, written declaratively rather than as imperative view-pushing. A page's model is whatever values it declares, plus whatever a datasource fetches: std-data in the standard kit, which is a component rather than a language feature.

Reactivity, in the page itself

<html :count=${0} :light=${true}>
  <head>
    <style>
      body {
        color: ${light ? 'black' : 'white'};
        background-color: ${light ? 'white' : 'black'};
      }
    </style>
  </head>
  <body>
    <button :on-click=${() => count++}>
      Clicked ${count} time${count !== 1 ? 's' : ''}
    </button>
    <button :on-click=${() => light = !light}>
      Switch theme
    </button>
  </body>
</html>

Design philosophy

The objective is to remove as much needless complexity as possible from reactive web development. The whole language is a handful of rules:

  • HTML is the syntax. Anything without a ${...} or a : is plain markup and stays plain markup.
  • ${...} is the only expression syntax — plain JavaScript, in text, attributes and CSS, with no separate expression language to learn. Anything holding one is reactive, so href=${data.link} needs no further marking.
  • : names what HTML has no name for, always in the same :family-name shape: :class-, :style-, :attr-, :prop-, :on-, :did-/:will-, :for-, :handle-, :server-, :slot — plus :name=${...} to declare a value, ::name to declare one the compiler works out and drops, and :aka to name a scope.
  • A few directives take a reserved word rather than a prefix — :if, :else-if, :else — and can, because a value has to be something an expression can say: ${if} does not parse, so no page could ever have declared one by that name. A word you could not have used is a word a directive can take with no prefix and no possibility of collision.
  • Scopes nest lexically, like variables: a value is visible to every descendant with no separate wiring — no provide/inject, no Context.
  • An expression resolves where it was written. A custom tag's body sees the scope it was defined in; what you pass at a usage site sees yours. That is what lets a component be moved without its meaning changing.
  • Two intents get two spellings rather than one guessing from the shape of a value: title=${v} sets an attribute's value, :attr-title=${v} sets whether it is there at all.

The full syntax is a single page: syntax reference. The reasoning behind each part is in docs/.

Compare that to what's required to be productive in most other frameworks (hooks and dependency arrays, computed vs watch, whole directive sets, dependency injection, change detection, ...): the goal is for this list to stay short.

No rule above has a "convenient" exception (e.g. class/style silently merging instead of overriding when re-assigned, or a callback attribute accepting a bare expression sometimes and requiring a function other times). A shortcut that only saves a few characters at the call site but requires every future reader to remember a special case isn't a simplification, it's deferred, compounding complexity: better to always type a couple more characters than to hide behavior that depends on context.

Source level modularity

<!-- lib.htm -->
<lib :light=${true}>

  <style>
    body {
      color: ${light ? 'black' : 'white'};
      background-color: ${light ? 'white' : 'black'};
    }

    .theme-switcher {
      font-size: bold;
    }
  </style>

  <:define tag="theme-switcher:button"
    :class-theme-switcher
    :on-click=${() => head.light = !head.light}>
    Switch theme
  </:define>
</lib>
<html>
  <head>
    <:import src="lib.htm" />
  </head>
  <body>
    <theme-switcher />
  </body>
</html>

NOTE: root level attributes in imported fragments (*.htm files) are applied to <:import>'s container tag unless they are already defined there: this allows defaults and override.

NOTE: <html>, <head>, and <body> always have their own scopes and by default they are named page, head, and body respectively: that's why, combined with NOTE above, head.light = !head.light works

NOTE: :class- prefixes "class attributes", which dynamically add/remove a CSS class name depending on their value (if a value is unspecified as in this example, it's taken as true)

NOTE: <:import> is only allowed in page <head> (or recursively in imported fragments), so imported fragments can rely on their root attributes being available as head scope values

Componentization

Reactivity aside, <:define> alone is enough to turn recurring markup into a tag: no build step, no component base class, no separate file format — a fragment of HTML, given a name.

demos/bootstrap/index.html and demos/bootstrap/index-plain.html render the same page, and almost all of the difference between them is in the first 35 lines. Plain Bootstrap needs 5 lines of <head> boilerplate (charset, viewport, CDN links with their integrity hashes) and 22 lines of navbar (nested nav > div > ul > li > a, a toggler button, data-bs-target matched by hand to the collapse id, four ARIA attributes). With a kit of Markout fragments, the same thing is:

<head>
  <:import src="/npm/@markout-lang/bootstrap-kit/all.htm" />
  <title>Northstar Studio | Product Design for Growing Teams</title>
</head>

<body>
  <bs-navbar :items=${[
    { name: 'Services', link: '#services' },
    { name: 'Our work', link: '#work' },
    { name: 'Insights', link: '#insights' },
    { name: 'Start a project', link: '#contact', button: true },
  ]}>
    Northstar Studio
  </bs-navbar>

The markup that was only ever mechanical becomes data. The pinned Bootstrap version, the integrity hashes, the toggler/collapse id wiring and the accessibility attributes are written once in @markout-lang/bootstrap-kit and can't drift from page to page. The kit is an installed package here, which is what /npm/ in the import says — see npm kits; a kit vendored into the docroot is imported by its path instead.

NOTE: the kit itself is plain HTML too — see parts/navbar.htm, where the <li> is the original Bootstrap one with :for-each=${items} and a few :class-x=${...} attributes added; there's no component API to learn beyond the rules above

NOTE: the toggler/collapse wiring is built from $id, so each <bs-navbar> gets ids of its own — the reason a component carrying internal id/aria-* references can be used more than once on a page at all

NOTE: fragments compose — all.htm imports parts/base.htm and parts/navbar.htm, so a page can pull in the whole kit or just the parts it needs

NOTE: since a custom tag is just a tag, the rest of the page stays plain HTML: you lift out what is boilerplate and leave your content alone, rather than rewriting the page into a template language

Conditionals

<html :n=${0}>
  <body>
    <p :if=${n === 0}>nothing yet</p>
    <p :else-if=${n === 1}>one thing</p>
    <p :else>${n} things</p>
  </body>
</html>

:if is plain truthiness, so ${count} and ${name} mean what they look like. :else-if and :else continue it, on the element immediately after — the chain shows the first branch whose condition holds and no other, which two :ifs cannot do, since the branch that has to give up its position is the one whose own condition did not change.

Nothing inside a branch that isn't showing is evaluated, which is what makes ${user.name} safe to write in one. And the element is parked in a <template> rather than rebuilt, so a scroll position, a focused input or a playing video survives a round trip.

Replication

<html>
  <body>
    <ul :for-each=${[[1, 2, 3], [4, 5]]}>
      <li :for-each=${data}>
        Item ${data}
      </li>
    </ul>
  </body>
</html>

NOTE: :for- prefixes anything related to replication/optional data, a shared namespace for :for-each/:for-as/:for-key/:for-data

NOTE: by default the bound value is named data (:for-as can change that)

NOTE: :for-each treats null/undefined as zero elements (nothing is rendered) and otherwise expects an iterable; it never guesses at a scalar meaning "one", since that would make its meaning depend on the incidental shape of the value rather than being one fixed rule

Optional rendering

<html :user=${undefined}>
  <body>
    <p :for-data=${user}>Welcome, ${data.name}</p>
  </body>
</html>

:for-data=${expr} renders its tag once if expr is neither null nor undefined, and not at all otherwise — the same data/:for-as binding as :for-each, but for an optional single item rather than a list.

It is the same arity as :if asked a different question, and the difference is the point. :for-data is != null, so 0 and '' are data — right for an item, wrong for a condition — and it binds what it found. Use :for-data when there is something to show, and :if when there is something to decide.

That is also why :for-each doesn't quietly accept a non-iterable and render it once: two intents, two attributes, rather than one inferring which you meant from the shape of the value.

The body doesn't evaluate while there is nothing to show, which is the point rather than an optimisation — ${data.name} above has to be safe to write. And the element itself is moved rather than rebuilt, so whatever the DOM was holding survives a round trip.

A value's whole life on one element

<html>
  <body>
    <:logic :aka="timer"
      :count=${0}
      :_timer=${null}
      :did-init=${() => _timer = setInterval(() => count++, 100)}
      :will-dispose=${() => clearInterval(_timer)} />
    <div>Ticks ${timer.count}</div>
  </body>
</html>

:did-init and :will-dispose are the two ends of a scope's life, and the pair is what lets a value that needs starting and stopping say so where it is declared rather than in a lifecycle method somewhere else.

NOTE: <:logic> is a scope with no element of its own. State that belongs to the page rather than to anything on it had to invent a <span> to live on before this existed — and that <span> is then real: in the document, in the accessibility tree, and in the way of :first-child

NOTE: :_timer is private by convention, not by rule — a leading underscore is how the kits mark a value that is the component's own business

NOTE: the other two moments are :did-attach and :will-detach, which fire as markup enters and leaves the page rather than as the scope is built and destroyed — the pair a region that comes and goes needs

If you're already reaching for Alpine or htmx

These are the tools a page usually picks up when it needs behavior, so here is the honest comparison rather than one that flatters us.

Alpine.js htmx Markout
Behavior written in HTML attributes yes yes yes
What it needs to run a <script> tag a <script> tag Node serving the page, or a build step
Mistakes caught before the page loads no, silent at runtime n/a yes, with a file and a line
Content present in the served HTML no, x-cloak hides the gap yes, the server wrote it yes, in both delivery modes
Same source renders on the server no, client only server owns the HTML yes
Reusable components in markup x-data + <template> server-side partials <:define> + <:slot>
Parametric CSS inline styles, or CSS variables set inline whatever the server renders ${...} inside <style>
Interaction without a server round-trip yes no, by design yes

And the costs, which are real: Alpine's ecosystem, community and documentation are far larger, and it is a mature project. It also asks for strictly less to get started — one <script> tag, on any host, behind any backend — where Markout wants Node in the request path or a build step. htmx is solving a different problem, server-driven UI, and composes fine with either.

The row that is worth the trade, if any is: a mistake in an Alpine attribute is silent until someone loads the page and notices. Here it is a compile error naming the file and the line, in the terminal or in the editor.

Two decisions, not one

This is the argument for why you are on a CSS framework in the first place, rather than an argument about what to use for logic. Choosing a framework today usually settles a second question at the same time: which UI components you get to use. Ant Design and MUI mean React, Vuetify means Vue, PrimeNG means Angular. Teams routinely adopt a framework they have no particular opinion about because the component library they need exists only there — and from then on neither decision can be revisited without the other.

Those are separable concerns. A CSS framework is a markup convention; a web component library is a set of custom elements. Neither needs a framework at all. What they need is a way to pass values in, set properties that aren't strings, and listen to events — which is what :attr-x, :prop-x and :on-x are. (It's also why React needed wrapper packages to consume custom elements for most of its life.)

So Markout wrapped around Bootstrap, Tailwind or Shoelace keeps both choices open: change how the page is put together without touching the components, or change the components without touching the logic.

NOTE: the honest cost — the framework-neutral component ecosystem is smaller and shallower than React's. Decoupling buys freedom at the price of reach, and that trade is only worth it if the components you need exist

Two ways to deliver a page

A compiled page is one artifact, and it runs in two places, so there are two ways to put it in front of a visitor. Which one you pick decides how much of the page arrives already rendered — not how it is written.

Served by Node, with the CLI below or the Express middleware. The render runs per request, so the page can read what a request has: :server- values run on the server, and a datasource fetches before the page is serialized. The visitor gets finished HTML that then comes alive. This is the isomorphic mode, and it is the one that makes SSR come for free.

Compiled ahead of time into static assets, for everyone else — a project served by Rails, Django, Laravel, PHP, or a bucket behind a CDN. Markout becomes a build step rather than something in your request path: the backend stays exactly as it is, and what it serves is plain HTML and JavaScript.

The second mode is not "client-side rendering" in the usual sense. The same render pass runs at build time, so the markup is in the file — a page's static content does not flash in after JavaScript loads. What it cannot carry is only what a request would have supplied: a :server- value has no result, and a datasource has to be marked :client so the browser fetches it on arrival. Isomorphism has the details.

markout build below is what produces the second kind. It is what lets the server-rendered majority of projects adopt Markout without moving off the stack they already run.

CLI

Serve a directory of Markout HTML files:

npm i -g @markout-lang/cli
markout ./site

Name the directory markout/ and there is nothing to type at all: markout serves it, and markout build compiles it into dist/ beside it. Everything else — building for a host that isn't Node, mounting the middleware in an application that has its own routes, and the error pages both modes serve — is in running a page.

Editor support

markout-vscode puts the compiler in the editor: the same diagnostics the CLI reports, on the right line, without saving — for every page in the workspace, not only the ones that are open. With go to definition on a name, a custom tag or an <:import> path; completion of what is in scope, the tags a kit defines and the parameters one takes; hover, rename and find-references across the pages and fragments a name actually reaches; and formatting that knows a > inside ${...} does not end a tag.

The compiler is bundled, so it works on a project that has installed nothing.

How it's built

A TypeScript monorepo on npm workspaces, MIT licensed.

packages/core the compiler and the client runtime — HTML in, a scope tree with every name resolved out, plus the payload of expressions and their dependency lists that a page comes alive from
packages/cli markout <dir> to serve, markout build <dir> <out> to compile ahead of time
packages/express the same render as middleware, for an application that has its own routes
packages/vscode the editor integration
kits/ bootstrap-kit (every component on Bootstrap's 5.3 cheatsheet, one file each) and std-kit, both written in Markout rather than in TypeScript
sites/site markout.dev and its demos, written in Markout and served by the Express package

2,248 tests across 103 files, with coverage and CodeQL on every push.

Three decisions, rather than the rest of the inventory:

One compiler, four ways to run it. The dev server, the Express middleware, markout build and the editor all run the same Compiler. The editor is the interesting one: readFile is a parameter of the compiler so the language server can hand it the buffer instead of the file, which is why every diagnostic in VS Code is the compiler's own and packages/vscode/src/diagnostics.ts re-implements no rule. What you get, in the terminal or on the line you are typing, names a file, a line and a column:

/parts/ui.htm:323:5: Unknown reference: "URLSearchParams"

A value that crosses from the server is settled before the page is. A :server- value is a promise the render waits on and serialises the result of, so a page arrives complete rather than arriving and then filling in. A rejected one fails the build instead of shipping a page with a hole in it, because such a value crosses frozen and the browser has no way to retry it — value transfer has the reasoning, and silent failures has the standard the rest of the compiler is held to.

A page pays for what it uses. The compiled output is the rendered markup plus one payload of expressions and their dependencies; a page with nothing reactive on it ships no runtime at all.

Markout is in production on ubimate.com, which is where the sharp edges get found.

About

An HTML extension that adds modularity, reactivity and isomorphism to plain HTML. Compiler, isomorphic runtime, and a VS Code language server that runs the compiler itself.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages