tutuca-mb

A small SPA framework, written in MoonBit

You write components as HTML. The framework supplies the rest. One codebase, three backends.

Try it live ▸ Examples GitHub ★
// counter.tutu — the state, the behaviour AND the view
spec:
  Counter:
    field count :: Int

logic:
  Counter:
    receive inc:
      it.count += 1

view:
  Counter:
    button(~on_click: inc): @(it.count)

// counter.mbt — `tutuca gen` generated CounterState and
// counter_component from the three sections above, checked `it.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 an example. Then press Ctrl/Cmd + Enter, or press the Run button. The MoonBit compiler runs in your browser. It links your program against the tutuca-mb library and mounts the result. The first run loads the compiler. This takes some time. Later examples compile in a few seconds.

Each editor has three tabs. Component shows the MoonBit code of the example. View shows the .html file with its views. Generated shows what tutuca gen makes from that view: the compiled view tree and a typed message enum, compiled as part of the same package. You can edit the View tab. The Generated tab then changes also. Add an @on handler in the view and answer it in the same file's logic: section. If you do not answer it there, the next run fails until the component's match handles it.

Hello, Counter

Toggle — no update code

The schema declares a boolean field. The generator makes a $toggleOpen mutator for it. @show reads the field. No hand-written update code is necessary.

A card — the same file, with no MoonBit toolchain

The examples above compile MoonBit in your browser, and that costs a 5.5 MB payload. This example does not. A card is the same view file: schema, script, and templates. The page compiles the card itself — into a WebAssembly module it then instantiates — and that compiler is part of the runtime already on this page. Thus the card compiles and mounts in milliseconds. Edit the card. It mounts again while you type. There is no run step.

You lose the parts that need the MoonBit compiler. A card cannot name a MoonBit value. Thus there are no imports and no closures, and a handler gen would refuse has nowhere to go. Every handler must use the language in the logic: section. You keep everything else, including the checks. The diagnostics below the preview are the same ones tutuca gen reports. They have the same line and column numbers.

The card carries no CSS. Its views name the margaui component classes — card, btn, input, badge — with Tailwind utility classes beside them. <mb-card margaui> compiles the classes that this card used. The compilation happens in your browser.

The card tutorial builds a card step by step, with live examples at each step. It covers the state block and its generated code, the handler language, derived values, loops, messages, and the limits of a card. The card playground uses the same compiler and has more panels: the state tree, the dispatch log, the card's test scenes, the compiled module itself, and a structured editor that shows one block of the file at a time.

Examples

These examples are ports of the examples on the Tutuca landing page. Each example is editable. Change the code and run it again. For the full set, compiled ahead of time, see the storybook gallery.

Basics

To-Do List

This example shows a list of child Item components. You can edit each item. <x render-it> renders the current instance of the loop. .items.removeAt @key deletes an item by key — a generated mutator, reached by writing to the field, with no handler between.

Two-way Text Binding

:value reads a field. @on.input=".name = e.value" writes it back. @text shows the new value immediately.

Live Markdown, Sanitized

@setinnermd takes a markdown string. It replaces the children of the element with the nodes from the parsed string: headings, lists, tables, GFM task lists, and footnotes. Type in the left pane. The right pane then renders again.

Scroll the source to the marked rule. The text after the rule is attack markup. None of it runs. The sanitizer removes SVG <script>, <iframe>, <base>, on* handlers, and javascript: URLs. SVG <script> is not the same element as the HTML <script>. Its name includes its namespace. The sanitizer judges every node in the tree with the same rules. Thus there is no allow-list only for markdown. If the sanitizer refuses a URL, it removes the attribute. It keeps the element. The link keeps its text and loses its target.

There are two failure modes. Both are safe. Markup that the parser knows becomes an element. The sanitizer judges it and removes it. A line in the report log records this. Markup that the parser does not know — a bare <script> is one example — never becomes an element. It stays on the page as visible text. Text reaches the DOM through createTextNode. Thus the browser never parses text as markup.

The markup never becomes a string. The payload is parsed one time and built into vdom nodes. The code never assigns innerHTML. This removes the second parse that mutation-XSS needs. See docs/sanitizer.md.

HTML and SVG, Sanitized

These two directives are about markup itself, not a source language. @setinnerhtml and @setinnersvg take a string that is already markup: a CMS body, a server-rendered fragment, or a chart that another program drew. They replace the children of the element with the nodes from the parsed string. Developers usually reach for @dangerouslysetinnerhtml to solve this case.

You do not give these two directives a permission. This is not a shortcut. The dangerous directive needs a permission because its fallback is innerHTML. If you mount an app without the filter, the payload goes to the browser without checks. These two directives fail closed: with no filter, they show an empty element. Thus there is no unchecked path for a host to permit.

Edit either pane and look at the rule. Below the rule: <script> in both namespaces, <iframe>, <base>, SVG <use>, on* handlers, and javascript: URLs, with or without entity obfuscation. Two cases are worth a look in devtools. The sanitizer refuses SVG <animate> as an element, not because of anything in its value. SMIL writes href in the browser after all checks finish. Thus the href that the URL rule checked is not the one that would navigate. Also, the sanitizer removes the style attribute. This is the one rule these two directives add and @dangerouslysetinnerhtml does not have. url(…) requests a resource from an origin that the payload chose. Nothing here parses CSS. Thus it cannot tell a background image from a beacon.

The payload in the SVG pane has no <svg> root of its own. This shows the difference between the two directives. @setinnersvg parses in SVG context. A bare fragment works. The payload cannot leave the namespace it was promised.

Interaction Patterns

Tree Navigation

Click a folder to open or close it. Click a file to select it.

Drag and Drop

Drag an entry to change its position. Type in the input to filter the list.

Filter and Paginate

The search box narrows the list. The pager shows one part of the matches at a time. You can edit or delete a row on page 2 of a filtered view, and the correct row changes. @loop-with returns the original keys of the matching rows. Thus filtering and paging do not change the identity of a row.

Web Component & Custom Event

tutuca can host a third-party web component and react to its CustomEvent (@on.emoji-click="onEmojiClick e.value"). This is a core tutuca feature. The example needs an external custom element on the page. See it in the storybook gallery.

Full Applications

JSON Editor

This is a recursive editor. Every JSON node is a component. Each component renders its children.

Personal Site

This is a list of entries. You can filter by category and role, sort the entries, and solo or unsolo an entry with alt-click. After mount, the app raises an ask(…, ~route: lex) to load the data. A handler on the module answers the intent.

Visual WebAssembly

This is a larger app. It is an editor for WebAssembly modules. An instruction table builds its approximately 19 views at run time. Thus this is the one example that cannot compile its views ahead of time. See it in the storybook gallery.

Composability

All Together Now

Every example exports its components independently. No example knows that another example composes it. This root imports several examples. It renders each one in its own tab. The glue code is small.

Storybook

A storybook shows components in several states, with a sidebar, a live preview, and a Lint panel for each story. tutuca-mb ships a compiled storybook. See the full storybook. You can also serve it with ./cli/tutuca storybook.

Testing Components

tutuca-mb components are plain values. Thus you can unit-test their methods, input handlers, and loop logic. Tests run in the toolchain, not in this browser sandbox. Use moon test for the library. Use the Lint panel in the storybook for checks on single components.

Get Started

tutuca-mb is the MoonBit port of Tutuca. You write a component as a plain MoonBit value. A component has a name, an HTML-like view string, and optional fields, methods, and event handlers. You mount components with a host adapter. The same components compile to three backends:

This folder is a complete build. The browser demos need a static file server. ES modules do not load from file:// URLs:

python3 -m http.server 8000

Then open http://localhost:8000/ in your browser:

What's Next