BML tag & syntax reference
Bosca owns the entire tag vocabulary. See the full grammar.
Expressions & text
| Form | Meaning |
|---|---|
{ expr } | server-evaluated Kotlin expression, HTML-escaped |
{@ expr } | server expression, raw (trusted HTML) |
{# … #} | build-only comment (never emitted) |
<!-- … --> | HTML comment (emitted) |
\{ \} \< \\ | literal { } < \ in text |
Attributes
| Form | Meaning |
|---|---|
name="literal" | static value (may interpolate: class="c-{ size }") |
name | boolean attribute (bare) |
:name="expr" | bound — value is a server expression (no braces) |
{...expr} | spread a Map<String, Any?> of attributes |
@event="model.method(…)" | dispatch a live-model method for any DOM event; supports debounce, throttle, coalesce, keepalive, and pagehide modifiers |
Special tags (protected — cannot be overridden)
| Tag | Purpose |
|---|---|
<page route="…" [contentType="…"] [requireAuth] [cache="shared" maxAge="seconds" staleWhileRevalidate="seconds"]> | a routable SSR response whose body owns the full document skeleton; shared caching renders a public/anonymous HTML shell and supports Cloudflare background revalidation |
<route path="…" [contentType="…"] [cache="shared" maxAge="seconds" staleWhileRevalidate="seconds"]> | a response-oriented route using the same renderer, cache policy, and route registry as <page> |
<template name="…"> | reserved syntax; template/layout code generation is not active |
<slot [name]> | component children; named slots currently use the default children and emit a warning |
<component tag="…" [scope="page|site"]> | declare a custom tag or override a built-in; live state has page lifetime by default or site lifetime explicitly |
<prop name type [required] [default|:default]> | a typed component input |
<import library="group:artifact" [as="alias"]/> | select exports from a resolved binary library, globally in imports.bml or at file scope |
<for item in expr> / <for (i, item) in expr> | iteration |
<if expr> … <else-if expr> … <else> … </if> | Kotlin conditionals (one </if>; bare separators) |
<if flag="key" [variation="name" | :when="expr"]> | feature-flag boolean, variation, or full-evaluation condition |
<data provides="name">{ expr }</data> | sugar for a server binding |
<inject [server] name="value" type="Type" [provider="key"] [init="method"] [scope="page|client-session|client-local|server-session"] [clear-on-sign-out]/> | resolve Bosca DI provide<Type>(); server promotes a serializable injected view model to live server-action state; sign-out cleanup is opt-in for browser-backed state |
<island name client hydrate render="request|prerender|deferred" [:prop]> | interactive island; deferred bodies render privately after shell load (see the islands reference) |
<fallback> | initial/error content for its direct deferred-island parent |
<script server [provides="name"] [scope="page|client-session|client-local|server-session"] [clear-on-sign-out]> | embedded Kotlin; live model scope defaults to the current page; sign-out cleanup is opt-in for browser-backed state |
<script client> | embedded TypeScript (island module) |
<contract> | a Kotlin interface for typed client↔server RPC |
<style> | global CSS (inlined as written) |
<style scoped> | component-scoped CSS — see Scoped styles |
Feature flag conditions
<if flag="new-checkout">
<checkout-v2/>
<else>
<checkout-v1/>
</if>
<if flag="checkout-layout" variation="compact">
<compact-checkout/>
</if>
<if flag="checkout" :when='flag.variationKey == "compact" && !flag.degraded'>
<compact-checkout/>
</if>
The simple form expects a JSON boolean. variation compares the selected variation key. :when
accepts an arbitrary Kotlin expression and binds flag to the complete evaluation, including
value, variationKey, experimentId, and degraded. Use either variation or :when, not both.
Flag and variation keys are literal values; ordinary <if kotlinExpression> remains unchanged.
If feature evaluation is unavailable, BML logs the failure and selects the ordinary fallback branch;
the failure does not abort page rendering. A :when predicate can inspect flag.degraded explicitly.
Feature-flag conditions are unavailable in <message> renders because messages do not currently
carry a recipient feature identity.
Application sitemap
A site can provide a dynamic sitemap as a BML route at the conventional path. The server responds
as application/xml and does not inject page CSS, client scripts,
or development reload markup. Emit the XML as trusted raw output:
<route path="/sitemap.xml" contentType="application/xml">
<script server provides="sitemap">
loadSitemapXml()
</script>
{@ sitemap }
</route>
When the project has no exact /sitemap.xml page or route, the server continues to serve the
sitemap from CompiledProject.discovery.sitemap. Parameterized routes do not capture the discovery URL.
Built-in HTML tags
The standard HTML element set is built in (a, div, p, h1–h6, ul/li,
table/tr/td, form/input, img, br, …). Void elements (br, img,
meta, link, hr, input, …) take no children/closing tag. The names BML reuses
as special tags (data, inject, slot, template, script, style) resolve to the special
tag — reach the native element via the html: namespace.
Overriding a built-in & custom tags
<component tag="a">
<prop name="href" type="String" required/>
<html:a :href="href" rel="noopener"><slot/></html:a> <!-- html:a = the native anchor -->
</component>
- Declaring
<component tag="a">overrides every<a>. Emit the native tag via thehtml:namespace (<html:a>) to avoid recursion. - Custom tags (
<component tag="card">) register a new tag with typed<prop>s. - Protected tags (the special set above) cannot be overridden — it's a compile error.
Forwarding component attributes
Use {...attrs} on the element that should receive additional caller attributes:
<component tag="button">
<prop name="loading" type="Boolean" default="false"/>
<html:button class="button" :disabled="loading" {...attrs}><slot/></html:button>
</component>
The compiler supplies attrs from the caller's inputs, excluding declared props and internal
action markers. Component authors do not maintain prop exclusion lists. A caller's id, name,
aria-*, data-*, and other undeclared attributes reach the explicit target. Nothing is forwarded
to surrounding labels or layout elements unless they also use the spread.
Classes from the target and caller are combined into one attribute. Other explicit target
attributes take precedence regardless of spread placement, including null or false to omit
an inherited attribute. Declare a prop when callers should control a value that the component
also renders; for example, declare disabled and bind :disabled="disabled || loading".
Native ref becomes data-bml-ref, and attribute values are HTML-escaped when written.
Spreads also work on component calls, so a wrapper can use <button class="compact" {...attrs}/>.
Each receiving component consumes its own declared props. An explicitly declared prop or server
binding named attrs takes precedence over the compiler's default binding. As with other
component inputs, outer attributes are not captured by independently rendered island bodies.
Declarative action markers continue to follow the component root.
Resolution order
<name> resolves: protected syntax → local component/override → unprefixed library export → native HTML/SVG → error.
Aliased imports use <alias:name>. Two different unaliased libraries exporting the same name produce
a diagnostic; alias one to disambiguate.
The html: namespace always resolves to the native HTML handler, bypassing overrides.
Scoped styles
A <style scoped> inside a <component> is isolated to that component, Nuxt-style —
you write plain selectors and BML guarantees they can't leak out and outside styles can't
leak in:
<component tag="badge">
<prop name="tone" type="String" default="info"/>
<style scoped>
.badge { padding: 2px 8px; border-radius: 4px; }
.badge-hot { background: #ffe0e0; color: #b00020; }
</style>
<span class="badge badge-{ tone }"><slot/></span>
</component>
How it works:
- The compiler stamps a marker attribute (
data-bml-c="badge") onto every element the component renders, and rewrites each selector to require it —.badgebecomes.badge[data-bml-c="badge"]. Descendant/child combinators scope the last compound (.list li→.list li[data-bml-c="badge"]);@media/@supports/@container/@layerrecurse;@keyframes/@font-faceare left untouched. - Slotted content is scoped to the caller, not the component (it's the caller's markup).
- The scoped CSS is inlined once per page even if the component renders many times.
- A plain
<style>(noscoped) stays global, exactly as written.
The scoped CSS is also exposed on the generated component object (
BadgeComponent.styles,.scope) so the asset pipeline can serve it as a cached per-component stylesheet instead of inlining — see Asset tiers below.
Asset tiers
CSS and JS ship in three tiers, so a page loads only what it uses and shared chunks stay cached:
| Tier | What | How it's delivered |
|---|---|---|
| Global | site-wide app.css / app.js | linked on every page (/_bml/app.css, /_bml/app.js) |
| Per-page | the page's own <style> / <script client> | page CSS inlined in document order; page JS bundled to <Page>.js |
| Per-component | a component's <style scoped> (and, later, its island JS) | served as a cached chunk (/_bml/css/<tag>.css), linked only on pages that render the component |
The compiler records, per page, the components it renders (BmlPageRenderer.componentTags) and emits a
bml.generated.BmlComponents registry. The server expands that list to the transitive closure (a
component can render other components) and links exactly those component stylesheets — nothing more. In
this linked mode the per-component <style> is not inlined (the scope markers remain, so the
linked stylesheet still matches); zero-config SSR / email rendering keeps inlining instead.
Wire it up by handing the server the generated registries and your globals:
BmlServer(
project = CompiledProject("my-site", "1.0.0"),
pages = bml.generated.BmlPages.all,
components = bml.generated.BmlComponents.all, // enables linked per-component CSS
globalCss = File("app.css").readText(),
globalJs = File("app.js").readText(),
).start()