BML examples
Representative source files for the current grammar. These are hand-authored language, renderer, and integration examples.
The files are meant to live in one project. lists.bml uses list-item.bml and the generated
client for its ListOps contract; list-ops.kt shows the matching server implementation and
dispatcher wiring. task-list.bml pairs with task-list-view-model.kt and reuses the same list row.
Place the BML files under src/main/bml and the Kotlin files under src/main/kotlin/example.
| File | Kind | Notable constructs exercised |
|---|---|---|
list-item.bml | component | a reusable list row with typed props, scoped CSS, bound and interpolated attributes, <if>, and a default <slot/> for row actions |
lists.bml | page | a complete HTML document, route + typed param {id:UUID}, <script server provides>, a <contract> called through its generated ListOps TypeScript client, the list-item component, and an isolated Sortable island |
list-ops.kt | Kotlin contract implementation | implements the generated ListOps server interface with the request's GraphQLClient and wires ListOpsDispatcher into BmlServer |
task-list.bml | page | a no-client-code live ViewModel with scope="server-session", declarative @submit/@click actions, form.title, island re-rendering, and slotted list-item actions |
task-list-view-model.kt | Kotlin ViewModel | the @Serializable, mutable server model used by task-list.bml |
card.bml | component | <component tag>, <prop> (required, expr :default="null", attr default="1"), component-scoped <script server>, attribute spread {...attrs}, <if>/<else>, <slot/>, bound attr :href="…" |
anchor.bml | component | overriding a built-in (tag="a"), the html: native escape hatch (<html:a>) to avoid recursion, <prop>, <script server provides>, bound attrs :href="…"/:rel="…" |
site-header.bml | component | reusable <component>, typed prop, native HTML, default <slot/>, and HTML entity © |
welcome-email.bml | message (email channel) | typed message payload, localized <subject>, <style bml-inline>, table-safe HTML, raw output {@ … }, <if>/<else-if>/<else>, and <for> |
Construct coverage (grammar §): elements/void/self-closing, QName/html: (§3.1, §7);
static / bound (:attr="expr") / spread attributes (§3.2); <script server>/<script client>/
<contract>/<style> raw regions (§3.3); <page> (§4.1); component <slot> (§4.2);
<component>/<prop> (§4.3); <for>/<if>/<else-if>/<else> (§4.4); <data> (§4.5);
<island> (§4.6); provides scope + route params + props (§5); live ViewModels and declarative
server actions; expressions and {@ } raw output (§6, §8); comments {# #} (§2.2).
Deferred islands and declarative server actions are covered in the islands reference. Features still reserved for future work are listed in grammar §10.
Source files
anchor.bml
{# Component: override the built-in `a` tag.
Exercises: overriding an overrideable built-in, the `html:` native escape hatch
(so emitting an anchor does not recurse into this override), and a server `provides`.
Protected tags (page/island/for/…) cannot be overridden this way. #}
<component tag="a">
<prop name="href" type="String" required/>
<prop name="external" type="Boolean" default="false"/>
<script server provides="rel">
if (external) "noopener noreferrer" else null
</script>
<html:a :href="href" :rel="rel" data-tracked="true">
<slot/>
</html:a>
</component>
card.bml
{# Component: a reusable `card` tag.
Exercises: <component tag>, typed <prop> (required / expr default / attr default),
a component-scoped <script server>, interpolation in attributes, <if>/<else>, <slot>. #}
<component tag="card">
<prop name="title" type="String" required/>
<prop name="href" type="String?" :default="null"/>
<prop name="elevation" type="Int" default="1"/>
<article class="card card-elev-{ elevation }" {...attrs}>
<header class="card-head">
<if href != null>
<a :href="href">{ title }</a>
<else>
<h3>{ title }</h3>
</if>
</header>
<div class="card-body">
<slot/>
</div>
</article>
</component>
list-item.bml
{# Reusable row rendered by lists.bml and task-list.bml. #}
<component tag="list-item">
<prop name="id" type="String" required/>
<prop name="label" type="String" required/>
<prop name="pinned" type="Boolean" default="false"/>
<prop name="position" type="Int" required/>
<style scoped>
.item { display: flex; align-items: center; gap: 0.75rem; }
.label { flex: 1; }
.badge { font-size: 0.75rem; text-transform: uppercase; }
</style>
<li :data-id="id" class="item item-{ position }">
<span class="label">{ label }</span>
<if pinned>
<span class="badge">pinned</span>
</if>
<slot/>
</li>
</component>
lists.bml
{# Page: a sortable list.
`ListOps` is compiled into a typed TypeScript client and a Kotlin dispatcher.
`list-item` comes from list-item.bml in the same BML source set. #}
<page route="/lists/{id:UUID}">
<script server provides="listView">
// `bosca` is the generated typed Kotlin GraphQL client; GetListView is a generated operation.
bosca.query(GetListView(id = id)).list
</script>
<contract>
interface ListOps {
suspend fun reorder(listId: String, ids: List<String>)
}
</contract>
<html lang="en">
<head><title>{ listView.title }</title></head>
<body>
<header class="list-header">
<h1>{ listView.title }</h1>
<if listView.items.isEmpty()>
<p class="empty">This list has no items yet.</p>
<else>
<p class="count">{ listView.items.size } items</p>
</if>
</header>
<island name="sortable" client="ts" :id="id.toString()">
<ul data-items class="sortable">
<for (index, item) in listView.items>
<list-item
:id="item.id.toString()"
:label="item.label"
:pinned="item.pinned"
:position="index"
/>
</for>
</ul>
<script client>
import Sortable from "sortablejs"
import { ListOps } from "./ListOps"
// Each instance scopes DOM access to its root and gets a unique Sortable group.
const listId = ctx.props.id as string
const list = ctx.root.querySelector<HTMLUListElement>("[data-items]")!
const sortable = Sortable.create(list, {
animation: 150,
group: ctx.scoped("items"),
onEnd: async () => {
const ids = [...list.children].map((item) => (item as HTMLElement).dataset.id!)
await ListOps.reorder(listId, ids)
},
})
ctx.onUnmount(() => sortable.destroy())
</script>
</island>
</body>
</html>
</page>
list-ops.kt
package example
import bml.generated.ListOps
import bml.generated.ListOpsDispatcher
import bosca.bml.graphql.GraphQLClient
import bosca.bml.render.BmlContractDispatcher
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/** Implements the server half generated from the `ListOps` contract in lists.bml. */
class ListOpsImpl : ListOps {
override suspend fun reorder(gql: GraphQLClient, listId: String, ids: List<String>) {
gql.execute(
query = REORDER_LIST,
variables = buildJsonObject {
put("listId", listId)
put("ids", JsonArray(ids.map { JsonPrimitive(it) }))
},
operationName = "ReorderList",
)
}
private companion object {
val REORDER_LIST = """
mutation ReorderList(${ '$' }listId: ID!, ${ '$' }ids: [ID!]!) {
reorderList(listId: ${ '$' }listId, ids: ${ '$' }ids)
}
""".trimIndent()
}
}
/** Pass this list as `BmlServer(..., contracts = listContracts())`. */
fun listContracts(): List<BmlContractDispatcher> =
listOf(ListOpsDispatcher(ListOpsImpl()))
task-list.bml
{# A no-client-code live ViewModel example.
BML sends actions to TaskListViewModel and re-renders this island after each method call. #}
<page route="/tasks">
<script server provides="tasks" scope="server-session">
example.TaskListViewModel()
</script>
<html lang="en">
<head><title>Tasks</title></head>
<body>
<main>
<h1>Tasks</h1>
<island name="task-list">
<form @submit="tasks.add(form.title)">
<label for="task-title">New task</label>
<input id="task-title" name="title" required/>
<button type="submit">Add</button>
</form>
<if tasks.items.isEmpty()>
<p>No tasks yet.</p>
<else>
<ul>
<for (index, item) in tasks.items>
<list-item
:id="item.id"
:label="item.title"
:position="index"
>
<button @click="tasks.toggle(item.id)">
<if item.completed>Reopen<else>Complete</if>
</button>
<button @click="tasks.remove(item.id)">Remove</button>
</list-item>
</for>
</ul>
</if>
</island>
</main>
</body>
</html>
</page>
task-list-view-model.kt
package example
import kotlinx.serialization.Serializable
@Serializable
data class TaskItem(
val id: String,
val title: String,
val completed: Boolean = false,
)
/** Mutable, serializable state used by task-list.bml's declarative server actions. */
@Serializable
class TaskListViewModel {
var items: List<TaskItem> = listOf(
TaskItem(id = "1", title = "Read the BML guide", completed = true),
TaskItem(id = "2", title = "Build an island"),
)
private set
private var nextId: Int = 3
fun add(title: String) {
val trimmed = title.trim()
if (trimmed.isEmpty()) return
items = items + TaskItem(id = (nextId++).toString(), title = trimmed)
}
fun toggle(id: String) {
items = items.map { item ->
if (item.id == id) item.copy(completed = !item.completed) else item
}
}
fun remove(id: String) {
items = items.filterNot { it.id == id }
}
}
site-header.bml
{# Component: reusable site chrome. Pages still own their full document skeleton. #}
<component tag="site-header">
<prop name="home" type="String" default="/"/>
<header class="site-header">
<a :href="home"><slot/></a>
<span class="copyright">© Bosca</span>
</header>
</component>
welcome-email.bml
{# Message: a welcome template with an email channel. #}
<message key="welcome">
<script server provides="m">message.payload(WelcomePayload.serializer())</script>
<email>
<subject t="welcome.subject">Welcome, { message.recipientName ?: "friend" }!</subject>
<style bml-inline>
.greeting { font-size: 18px; font-weight: 600; }
.muted { color: #667085; }
</style>
<html>
<body>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td>
<p class="greeting">Welcome, { m.firstName }!</p>
<div class="intro">{@ m.welcomeHtml }</div>
<if m.organizations.isEmpty()>
<p class="muted">Create your first organization to get started.</p>
<else-if m.organizations.size == 1>
<p class="muted">You're a member of 1 organization.</p>
<else>
<p class="muted">You're a member of { m.organizations.size } organizations.</p>
</if>
<if m.organizations.isNotEmpty()>
<ul>
<for org in m.organizations>
<li>{ org.name }</li>
</for>
</ul>
</if>
<a :href="m.dashboardUrl">Open your dashboard</a>
</td>
</tr>
</table>
</body>
</html>
</email>
</message>