tutuca-mb

The SPA framework that fits in your head — now in MoonBit

HTML-first components. Batteries included. One codebase, three backends.

Try it live ▸ Examples GitHub ★
<!-- counter.html — the state, the behaviour AND the view -->
<script type="tutuca/state">
  state Counter { count : Int }
</script>

<script type="tutuca/script">
  on inc { .count += 1 }
</script>

<template id="Counter">
  <button @on.click="inc" @text=".count"></button>
</template>

// counter.mbt — `tutuca gen-views` generated CounterState and
// counter_component from the three blocks above, checked `.count` against the
// schema, and compiled `inc` into the update the wrapper already passes.
// A whole counter, and nothing left to say about it.
let counter = counter_component()

Try It

Every example on this page is a real MoonBit program. Edit it and press Ctrl/Cmd + Enter (or the Run button) — the MoonBit compiler runs in your browser, links against the tutuca-mb library, and mounts the result live. The first run loads the compiler, so give it a moment; every example after that compiles in a couple of seconds.

Each editor has three tabs. Component is the MoonBit the example is written in; View is the .html its views live in; Generated is what tutuca gen-views makes of that view — the compiled tree plus a typed message enum, compiled as part of the same package. Edit the View tab and watch Generated follow: add an @on handler there and answer it in the same file's tutuca/script block — or leave it unanswered, and the next run fails until the component's match handles it.

Hello, Counter

Toggle — no logic needed

A boolean field gets a generated $toggleOpen mutator for free; @show reads it. No hand-written update code.

A card — the same file, with no compiler

Every example above compiles MoonBit in your browser. This one does not: a card is the same view file — schema, script, templates — interpreted, so it parses and mounts in milliseconds against a runtime that is already on this page. Edit it and it re-mounts as you type; there is nothing to run.

What you give up is the part the compiler was for. A card cannot name a MoonBit value, so there are no imports, no closures, and no child components built at run time — and every handler has to fit the language in the tutuca/script block. What it keeps is everything else, including the checking: the diagnostics below the preview are the same ones tutuca gen-views reports, with the same line and column.

It carries no CSS either. The views name margaui's component classes — card, btn, input, badge — with Tailwind utilities beside them, and <mb-card margaui> compiles the ones this card actually used, in your browser.

The card tutorial builds one of these from an empty schema up, six live examples at a time — the state block and what it generates, the handler language, derived values, loops, messages, and the line past which you want the compiler back. The card playground is the same loader with more panels — the state tree, the dispatch log, and a structured editor that shows one block of the file at a time.

Examples

The same examples as the Tutuca landing page, ported to MoonBit. Each is editable — change the code and re-run. For the full set compiled ahead of time, see the storybook gallery.

Basics

To-Do List

A list of editable child Item components. <x render-it> renders the loop's current instance; the generated $removeInItemsAt mutator deletes by key.

Two-way Text Binding

:value reads a field; @on.input="setName value" writes it via the generated mutator; @text mirrors it live.

Live Markdown, Sanitized

@setinnermd takes a markdown string and replaces the element's children with the nodes it parses to — headings, lists, tables, GFM task lists and footnotes. Type in the left pane and the right one re-renders.

Scroll the source past the rule: everything below it is an attack, and none of it executes. SVG <script> (a different element from HTML's, since identity is namespace-qualified), <iframe>, <base>, on* handlers and javascript: URLs all go — judged by the SAME sanitizer that judges every other node in the tree, so there is no markdown-specific allow-list to fall behind. A denied URL drops the attribute, not the element, so the link keeps its words and loses its destination.

Two failure modes, both safe, and the demo shows both: markup the block parser recognises becomes an element, gets a verdict, and vanishes with a line in the report log. Markup it does not recognise — a bare <script> among them — never becomes an element at all and stays on the page as visible characters. Text reaches the DOM through createTextNode, so it can never be parsed as markup.

The markup never becomes a string: the payload is parsed once and built into vdom nodes, and innerHTML is never assigned. That is what removes the second parse a mutation-XSS depends on — see docs/sanitizer.md.

HTML and SVG, Sanitized

The same argument, about markup itself rather than a source language. @setinnerhtml and @setinnersvg take a string that is already markup — a CMS body, a server-rendered fragment, a chart another program drew — and replace the element's children with the nodes it parses to. This is the case people reach for @dangerouslysetinnerhtml to solve.

Neither takes a permission, and that is not a shortcut. The dangerous directive needs one because its fallback is innerHTML: mount an app without the filter and the payload goes to the browser unexamined. These two fail closed — no filter, empty element — so there is no unchecked path for a host to permit.

Edit either pane and watch the rule. Below it: <script> in both namespaces, <iframe>, <base>, SVG <use>, on* handlers and javascript: URLs, entity-obfuscated or not. Two are worth opening devtools for. SVG <animate> is refused as an element, not for anything in its value — SMIL writes href in the browser after every check has run, so the href the URL rule inspected is not the one that would navigate. And style is dropped, which is the one rule these two have that @dangerouslysetinnerhtml does not: url(…) is a request to an origin the payload chose, and nothing here parses CSS to tell a background image from a beacon.

The SVG pane's payload has no <svg> root of its own. That is the difference between the two directives: @setinnersvg parses in SVG context, so a bare fragment works and the payload cannot leave the namespace it was promised.

Interaction Patterns

Tree Navigation

Click folders to toggle them open or closed, and click files to select them.

Drag and Drop

Drag entries to reorder them, and use the input to filter the list.

Filter and Paginate

Search narrows the list, pages slice the matches, and editing or deleting a row works on the right item even on page 2 of a filtered view — @loop-with returns the matching rows' original keys, so identity survives filtering and paging.

Web Component & Custom Event

Hosting a third-party web component and reacting to its CustomEvent is a core tutuca feature (@on.emoji-click="onEmojiClick value"). Because it needs an external custom element loaded on the page, see it running in the storybook gallery.

Full Applications

JSON Editor

A recursive editor: every JSON node is a component that renders its children.

Personal Site

A filterable list of entries with category and role facets, sorting, and a solo/unsolo (alt-click) interaction. Data loads through a request handler after mount.

Visual WebAssembly

A larger app: a WebAssembly module editor whose ~19 views are BUILT AT RUNTIME from an instruction table, so it is the one example that cannot compile its views ahead of time. See it running in the storybook gallery.

Composability

All Together Now

Each example exports its components independently, with no knowledge of being composed. This root imports several of them and renders each in its own tab — the glue code is minimal.

Storybook

A storybook aggregates components in several states under a sidebar, with a live preview and a Lint panel per story. tutuca-mb ships a compiled one: see the full storybook (also served by ./cli/tutuca storybook).

Testing Components

tutuca-mb components are plain values, so their methods, input handlers, and iteration logic are unit-testable. Tests run through the toolchain rather than in this browser sandbox — moon test for the library, and the storybook's Lint panel for per-component checks.

Get Started

tutuca-mb is the MoonBit port of Tutuca. You author components as plain MoonBit values — a name, an HTML-ish view string, and optional fields, methods, and event handlers — and mount them with a host adapter. The same components compile to three backends:

This folder is a self-contained build. Browser demos need a static file server (opening files via file:// won't load ES modules):

python3 -m http.server 8000

Then open http://localhost:8000/ and explore:

What's Next