html_dsl

Type safe HTML & SVG DSL to build html for server rendered HTML. Modelled after Rabbita and Luna

html
dsl
svg
moon add kaashyapan/html_dsl@0.5.0
Download zip
Version
0.5.0
License
Apache-2.0
Last updated
16 hours ago
Downloads
12
README

#html_dsl.mbt

A type-safe HTML DSL for MoonBit that renders to a plain HTML string. No external dependencies. Works on all MoonBit targets — JS, Wasm, and native.


#Features

  • Typed nodesHtml and Attr enums catch mistakes at compile time
  • Named-argument API — optional id, class, style, attrs labeled args keep call sites clean
  • XSS-safe rendering — text content is automatically escaped
  • Void element awareness<br>, <input>, <meta> etc. never get a closing tag
  • Escape hatchnode() and Prop() handle any tag or attribute not in the DSL
  • HTMX friendlyOnRawEvent renders arbitrary data-* and event attributes


#Installation

Add to your moon.pkg:

import { "kaashyapan/html_dsl/html", "kaashyapan/html_dsl/svg" }


#Quick start

let page : Html =
html_el(lang="en", [
head([
title("My App"),
link(rel="stylesheet", href="/styles.css"),
]),
body([
div(class="container mx-auto p-4", [
h1([text("Hello, MoonBit!")]),
p(class="text-gray-600", [
text("A type-safe HTML DSL that renders to a string."),
]),
]),
]),
])

let html_string = render(page)

Output:

<html lang="en"> <head> <title>My App</title> <link rel="stylesheet" href="/styles.css" /> </head> <body> <div class="container mx-auto p-4"> <h1>Hello, MoonBit!</h1> <p class="text-gray-600"> A type-safe HTML DSL that renders to a string. </p> </div> </body> </html>


#Core types

#Html

pub(all) enum Html {
Node(String, Array[Attr], Array[Html]) // element
Text(String) // escaped text
Unsafe(String) // unsafe raw html
Fragment(Array[Html]) // no wrapper element
Nothing // renders to ""
}

#Attr

pub(all) enum Attr {
Id(String) | Class(String) | Href(String)
Src(String) | Alt(String) | Type_(String)
Value(String) | Placeholder(String)
Name(String) | Rel(String) | Lang(String)
Prop(String, String) // arbitrary attribute
Style(Array[(String, String)]) // inline styles
On(Event) // typed event handler
OnRawEvent(String, String) // raw attribute name + value
Disabled(Bool) | Checked(Bool) | Required(Bool)
// … and more
}


#Element builders

All common HTML tags are available as functions. Optional attributes are passed as labeled arguments; children are the final positional argument.

#Structural

html_el(lang="en", [...])
head([...])
body([...])

#Block elements

div(id="app", class="flex", style=[("gap", "1rem")], [...])
p(class="text-sm text-gray-500", [...])
span(class="font-bold", [...])
h1([text("Page title")])
h2([text("Section")])

#Lists

ul([
li([text("Apples")]),
li([text("Bananas")]),
li([text("Cherries")]),
])

ol([
li([text("First")]),
li([text("Second")]),
])

#Tables

table([
thead([
tr([th([text("Name")]), th([text("Score")])]),
]),
tbody([
tr([td([text("Alice")]), td([text("98")])]),
tr([td([text("Bob")]), td([text("87")])]),
]),
])

// href defaults to "#", target defaults to Self_
a(href="https://moonbitlang.com", target=Blank, [text("MoonBit")])

img(src="/logo.png", alt="Logo")

#Forms

form(on_submit="handleSubmit(event)", [
label(for_="email", [text("Email")]),
input(
input_type=Email,
placeholder="you@example.com",
attrs=[Attr::Name("email"), Attr::Required(true)],
nothing(),
),
button(on_click="", [text("Subscribe")]),
])

input takes a dummy nothing() as its last argument to keep the API consistent with other elements that accept children.

#Void elements

br()
hr()
meta(charset="utf-8")
meta(attrs=[Name("viewport"), Prop("content", "width=device-width, initial-scale=1")])
link(rel="stylesheet", href="/app.css")
script(defer_=true, type_="module", src="/app.js")


#Inline styles

Pass (property, value) pairs to the style labeled argument or the Style constructor:

div(
style=[("display", "flex"), ("gap", "1rem"), ("padding", "1rem")],
[
span(style=[("color", "#0ea5e9"), ("font-weight", "bold")], [text("Blue")]),
span(style=[("color", "#f97316")], [text("Orange")]),
],
)


#Event handlers

Typed events compile to the correct on* attribute:

button(on_click="doSomething()", [text("Click me")])

// For other events, use attrs=
div(attrs=[
on_mouseenter("showTooltip()"),
on_mouseleave("hideTooltip()"),
], [...])


#Arbitrary attributes with Prop and Data and Aria

Use Prop(key, value) for any attribute not covered by the DSL, and Data(name, value) and Aria(name, value) for data attributes or framework-specific bindings:

// HTMX
div(attrs=[
Prop("hx-get", "/partial"),
Prop("hx-target", "#result"),
Prop("hx-trigger", "click"),
], [text("Load more")])

// Datastar signals
main_(attrs = [
Data("signal-locale", "window.navigator.language"),
Data("signal-theme", "localStorage.getItem('theme')"),
], [...])


#Custom elements and escape hatch

Node(tag, attrs, children) renders any tag not in the DSL:

// Web components
Node("my-modal", [Prop("open", ""), Class("z-50")], [
Node("my-modal-header", [], [text("Confirm?")]),
Node("my-modal-footer", [], [
button(on_click="modal.close()", [text("Cancel")]),
button(on_click="modal.confirm()", [text("OK")]),
]),
])

// Custom elements with no children
Node("dataspa-inspector", [], [])
Node("subtitle", [], [])


#Conditional rendering

Use Nothing to conditionally include nodes:

fn alert_banner(show : Bool, message : String) -> Html {
if show {
div(class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded", [
text(message),
])
} else {
Nothing
}
}

Or filter a list with fragment:

let items = ["Alice", "Bob", ""]

Fragment(
items
.filter(fn(s) { s != "" })
.map(fn(name) { li([text(name)]) }),
)


#Full page example

pub fn login_page() -> Html {
html_el(lang="en", [
head([
title("Sign in"),
meta(charset="utf-8"),
meta(attrs=[
Name("viewport"),
Prop("content", "width=device-width, initial-scale=1"),
]),
link(rel="stylesheet", href="/styles.css"),
]),
body([
div(class="min-h-screen flex items-center justify-center bg-gray-50", [
div(class="max-w-md w-full space-y-8 p-8 bg-white rounded-xl shadow", [
h1([text("Sign in to your account")]),
form(on_submit="handleLogin(event)", [
div(class="space-y-4 my-4", [
div(class="flex gap-8", [
label(class="w-16", for_="email", [text("Email")]),
input(type_=Email, placeholder="you@example.com", attrs=[
Name("email"),
Required(true),
]),
]),
div([
label(class="w-16", for_="password", [text("Password")]),
input(type_=Password, placeholder="••••••••", attrs=[
Name("password"),
Required(true),
]),
]),
]),
button(
type_=Submit,
class="w-full py-2 px-4 bg-blue-600 text-white rounded-lg",
[text("Sign in")],
),
]),
p(class="text-center text-sm text-gray-500", [
text("Don't have an account? "),
a(href="/register", [text("Register")]),
]),
]),
]),
]),
])
}

// Render to a string ready to send as an HTTP response
let html_string = render(login_page())


#Rendering

// Html -> String
pub fn render(node : Html) -> String

// Escape text manually if needed
pub fn escape(s : String) -> String