Sign in

    tutuca

    MoonBit port of the tutuca UI framework (value language, templates, vdom, components, app runtime, lint, CLI)

    ui
    framework
    vdom
    reactive
    moonbit
    Download zip
    Version
    0.5.1
    License
    MIT
    Last updated
    3 months ago
    Downloads
    532

    #marianoguerra/tutuca

    A MoonBit port of tutuca, a small UI framework built around a reactive value language, HTML-ish templates, and a virtual DOM.

    It runs on all three MoonBit backends: wasm-gc (the default, for target-agnostic logic and the browser demos), js (the real-DOM adapter, via mizchi/js), and native (the CLI).

    Live demos, playground and storybook: https://marianoguerra.github.io/tutuca-moonbit/ — source: https://github.com/marianoguerra/tutuca-moonbit. The published mooncakes package carries the library, CLI and storybook packages; the demo, playground and wasm-component guest hosts live in the repo only (see exclude in moon.mod).

    #What's in it

    tutuca is a stack of small packages, each a MoonBit package with its own tests and formal spec.mbt. From the bottom up:

    LayerPackage(s)What it does
    Value languagecore/ — marianoguerra/tutuca/core (value_*.mbt, path_*.mbt)The tutuca value syntax — parse/tokenize/eval — plus the reactive path/dispatch system (COW spine rebuild, handler dispatch, change sets).
    Templatesanode/Parses the HTML-ish view syntax into an AST: attributes, directives, x- ops, macros, whitespace handling, optimization.
    Virtual DOMvdom/ (+ vdom/memdom, vdom/browser, vdom/wasm)Builds and incrementally morphs a VDOM against any DOM implementing the DomNode trait.
    Renderrender/Turns a parsed view + a value stack into a @vdom.Vdom tree (loops, scopes, event-path metas, teleport).
    Components / Appcomponent/, app/ (+ app/browser, app/wasm), transactor/Typed-state component definitions (a plain derive(ToJson, FromJson) struct + one Dispatch update match), the app runtime, and the transactor that routes events at the root and settles state.
    Toolinglint/, inspector/, viewgen/, cli/The linter (parse-issue rules + a WHATWG-tokenizer structural HTML linter), a schema inspector, the ahead-of-time view compiler, and the native tutuca CLI.
    Testingtesting/harnessA reusable harness to mount and drive a ModuleDef on the in-memory DOM.
    Demos & docsdemo/, playground/, storybook/51 ported examples (storybook/examples/), browser/CLI/wasm demo hosts, an in-browser playground, and a compiled storybook gallery.

    The tutuca CLI exposes get / list / examples / show / lint / render / storybook / gen-views / watch / install-skill.

    #Views (views~ + gen-views)

    component(...) takes its views as views~ : Map[String,@anode.View] — a view is a built @anode.View, never a raw string. There are two ways to build them: ahead of time with tutuca gen-views (the recommended path, below), or at runtime with @anode.View::new("main",raw_view="…") for a genuinely dynamic or throwaway view.

    Ahead of time is the default everywhere in this repo. The runtime path is reserved for cases that cannot be generated: views that arrive as source at run time (the dyncomp guest bundles), views a macro or the component itself assembles programmatically, deliberately-broken lint fixtures, the playground's editable teaching examples, and test fixtures.

    A component keeps its views in an .html file and compiles them ahead of time into a companion MoonBit module, so the view's vocabulary stops being strings the compiler cannot see:

    moon run --target native cmd/main -- gen-views demo/counterlib/counter.html --name Counter # -> demo/counterlib/counter_view_gen.mbt (the view vocabulary as types) # -> demo/counterlib/counter_view_ir_gen.mbt (the compiled `counter_views()`) # both checked in; regenerate, never edit

    The file is either one bare view, or several <template> elements whose id attributes name them — the one with no id is main. A <style> inside a template is that view's style; one at file level is the component's common style, or its global style with data-global.

    For a component named Counter the generated module declares counter_views() (the built views, for views~), CounterInput and CounterMsg (@on handler names, with payload types inferred from the argument shapes at the call sites, plus CounterMsg::of_dispatch), CounterMethod with counter_mutate / counter_compute / counter_swap (the $-callables, as exhaustive matches), CounterView / CounterId, and counter_fields / counter_missing_fields. The package it lands in must import "marianoguerra/tutuca/core" @tutuca, "marianoguerra/tutuca/component" and "moonbitlang/core/debug".

    The payoff is in update (see demo/counterlib/ for the worked example):

    update=(s : CounterState, msg, _ctx) => match CounterMsg::of_dispatch(msg) {
    Some(Add(d)) => ... // `d` is a Double: `@on.click="add 1"`
    Some(SetLabel(l)) => ... // `l` is a String: `@on.input="setLabel value"`
    Some(ResetCount) => None
    Some(Unknown(_, _)) | None => None
    }

    Adding @on.click="del 1" to counter.html and regenerating makes that match non-exhaustive — a compile error naming Some(Del(_)), where the old string-matched _ => None arm silently did nothing.

    #Several components, and macros

    A view file belongs to a MoonBit module, not to a single component — template ids say what each one is:

    id
    (none)the single unnamed component's main view
    row…its row view
    Counter:mainthe Counter component's main view
    Countershorthand for Counter:main
    macro:icona macro shared by every component in the file

    A component name is Uppercase-initial, which is what tells Counter (a component) from row (a view). A file either names its components or does not; mixing the two is an error.

    A macro's data-* attributes are the defaults for the ^var references in its body, and the generator expands every call ahead of time — which is why macros belong in the view file rather than being registered from MoonBit:

    <template id="macro:icon" data-size="'24'" data-color="'currentColor'"> <svg :width="^size" :height="^size" :stroke="^color"><path :d="^path"></path></svg> </template> <template id="Gallery"> <x:icon :size=".size" :path=".heart"></x:icon> </template>

    #The compiled tree

    gen-views also emits <stem>_view_ir_gen.mbt: the @anode.ANode tree and event table each view parses into, as MoonBit literals. Pass it as views~ and the template parser never runs at startup:

    @component.component(
    name="Counter",
    views=counter_views(),
    init=CounterState::{ label: "Counter", count: 0, history: [] },
    update=...,
    )

    There is no serialization format and no decoder: the AST is pub(all), so the tree is expressed as plain constructor syntax. @anode.ParseContext::from_ir recovers the node table from the tree itself (every registered node carries its node_id), and View::from_ir stamps data-vid and runs the constant-subtree optimization at load — RenderOnce ids are process-global renderer memo keys, so they must be minted at load time, not baked in.

    A view that calls a macro cannot be compiled ahead of time (macros are registered from MoonBit at runtime), so the whole file falls back to the source path; --no-ir opts out by hand. Across the ported example library, every view compiles to a tree except the macro-using ones.

    Regenerate through the task, not the CLI — moon fmt owns the layout of the generated pair:

    moon run --target native cmd/dev -- gen-views # generate + fmt git diff --exit-code # drift check

    While authoring, tutuca watch removes the regenerate step entirely:

    tutuca watch demo/counterlib # or a file, or bare for the whole project

    It generates every managed view once, then again on each save, so the types are always current and the MoonBit compiler is what tells you a view and a component have drifted apart. A directory contributes the .html files that already have a generated sibling — that is what distinguishes a view file from a page like index.html. A view that fails to generate prints and the watch keeps going; the next save is expected to fix it.

    #In the playground

    The playground runs the same generator in the browser. Its left pane has three tabs:

    Tab
    Componentthe MoonBit you write
    Viewthe .html its views live in (name the component with <!-- name: Counter -->)
    Generatedread-only: what gen-views makes of the View tab, updating as you type

    The generated modules are compiled as extra files of your package, so the Component tab names counter_views() / CounterMsg / CounterId with no import — and adding an @on handler in the View tab fails the build with Partial match … Some(Del(_)) until the Component tab handles it. Load the "Counter (view tab)" example to see it.

    The generator reaches the browser as viewgen/ compiled to js (playground/viewgen_js), which publishes globalThis.__tutucaViewgen; playground/build/check-viewgen-tab.mjs drives that whole path headlessly (generate → compile → link) as part of the playground task.

    #vdom

    The virtual DOM (src/vdom.js in the original): Vdom trees built with h/text/comment/fragment, rendered and incrementally morphed against any DOM that implements the DomNode trait.

    • vdom/ — core types and algorithms (h, to_dom, diff_props, morph_node, morph_children, render, unmount), backend-agnostic.
    • vdom/memdom/ — in-memory DOM. Runs on every backend; the primary test substrate (unit suites ported from the JS tests plus quickcheck properties: morph ≡ fresh render, keyed-reorder identity preservation, diff_props roundtrip).
    • vdom/browser/ — js-backend adapter over the real DOM via mizchi/js (supported_targets = "js").

    ///|
    test "render a tree into memdom" {
    let doc = @memdom.document()
    let container = @vdom.DomNode::create_element(doc, "DIV", None, None)
    let opts = @vdom.RenderOpts::new(doc)
    let prev = @vdom.render(
    @vdom.h("ul", attrs={ "className": Str("list") }, childs=[
    @vdom.h("li", key="a", childs=[@vdom.text("one")]),
    @vdom.h("li", key="b", childs=[@vdom.text("two")]),
    ]),
    container,
    opts,
    )
    // incremental re-render: morphs in place, preserves keyed nodes
    let _ = @vdom.render(
    @vdom.h("ul", attrs={ "className": Str("list") }, childs=[
    @vdom.h("li", key="b", childs=[@vdom.text("two")]),
    @vdom.h("li", key="a", childs=[@vdom.text("one!")]),
    ]),
    container,
    opts,
    prev~,
    )
    inspect(
    container.to_html(),
    content=(
    #|<div><ul class="list"><li>two</li><li>one!</li></ul></div>
    ),
    )
    }

    In a browser (js backend):

    ///|
    let opts = @browser.window_opts()

    ///|
    let container = @browser.BrowserNode::from_element(
    @dom.window().document().getElementById("app").unwrap(),
    )

    ///|
    let prev = @vdom.render(view(state), container, opts)

    #Differences from the JS vdom

    • Attribute values are a closed enum (Str/Num/Bool/Html); dangerouslySetInnerHTML: { __html } is spelled Html("...").
    • Namespaces are an enum (Svg/MathMl/Other(uri), None = HTML), only converted to URI strings at the DOM boundary.
    • h() takes childs : Array[Vdom] — the JS iterable-flattening and primitive→text coercion don't apply; text() is explicit. Fragment children are still spliced.
    • key/namespace are labeled arguments (key="a", ns=Svg), though "key"/"namespace" entries in the attrs map are also honored.
    • Object/array-valued custom-element properties are spelled Data(json) (the JS h(tag, { items: [1, 2, 3] }) case); they always take the property path and diff by VALUE, so an equal-content new object does not re-invoke the element's setter (JS compares by reference).
    • Out of scope: event handlers (tutuca delegates events at the root; vdom never routed them).
    • Double::to_string matches JS String(n) for attribute-realistic values; extremes like 1e21 format differently.

    #Building, testing, running

    Common workflows live in a MoonBit task runner (cmd/dev) rather than loose commands:

    moon run --target native cmd/dev -- setup # npm install (happy-dom) + enable git hooks moon run --target native cmd/dev -- check # moon check across wasm-gc, js, native moon run --target native cmd/dev -- test # moon test across the three targets moon run --target native cmd/dev -- build # moon build wasm-gc + native CLI + js moon run --target native cmd/dev -- dist # assemble a self-contained dist/

    Run cmd/dev with no task to print the full list. The raw moon commands the tasks run underneath still work directly. See AGENTS.md for the tooling and testing details, and storybook/examples/README.md for how a JS example becomes a MoonBit one.

    dist produces dist/index.html (a landing page), the js and wasm-gc demos, the storybook gallery, and the native tutuca binary — serve it with any static file server (cd dist && python3 -m http.server) or dist/cli/tutuca storybook. The wasm pages need a browser with the JS String Builtins proposal (e.g. Chrome).

    #Targets

    preferred_target is wasm-gc, so a bare moon check / moon test covers only the target-agnostic packages. Full coverage needs all three: moon test (wasm-gc), moon test --target js (the browser adapters, happy-dom based) and moon test --target native (the CLI shells). The check / test dev tasks run all three for you.

    #License

    MIT — see LICENSE. This is a port of the MIT-licensed tutuca by the same author.

    Ctx

    What a handler can do besides transforming its leaf (JS EventContext). Defined here so Handler can carry it without depending on the transactor; every method defaults to a no-op so tests and Path::update can run handlers without a dispatcher. The transactor implements the real thing.

    Obj

    Component-instance protocol (the port of JS's runtime Records tagged with a hidden component symbol). ONE trait serves both hybrid representations: the Value-backed Instance (component package) and user-defined typed structs. Instances live INSIDE Value so they flow through every Value-typed seam (binds, handler args, dynamic lookups, iterated seqs). Fns returned by obj_field / obj_render_handler and the Handler returned by obj_handler are SELF-PRE-BOUND (they close over the concrete Self — the no-downcast analogue of JS late this) and ignore args element 0. Every method defaults to an empty/None answer.

    RequestOpts

    Options for Ctx::request (JS pushRequest opts): response-handler routing (on_ok_name / on_error_name / on_res_name) and live_path, which opts out of pinning field-resolved keys at request time.

    Value

    Dynamic runtime value: what eval(stack) reads and returns.

    Source Files