BoscaBML Reference

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.

FileKindNotable constructs exercised
list-item.bmlcomponenta reusable list row with typed props, scoped CSS, bound and interpolated attributes, <if>, and a default <slot/> for row actions
lists.bmlpagea 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.ktKotlin contract implementationimplements the generated ListOps server interface with the request's GraphQLClient and wires ListOpsDispatcher into BmlServer
task-list.bmlpagea 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.ktKotlin ViewModelthe @Serializable, mutable server model used by task-list.bml
card.bmlcomponent<component tag>, <prop> (required, expr :default="null", attr default="1"), component-scoped <script server>, attribute spread {...attrs}, <if>/<else>, <slot/>, bound attr :href="…"
anchor.bmlcomponentoverriding 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.bmlcomponentreusable <component>, typed prop, native HTML, default <slot/>, and HTML entity &copy;
welcome-email.bmlmessage (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">&copy; 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>