Add Milano to an app you already have.
A promotional banner, an image, a title, a line of copy, and a button, described in a JSON document and rendered by your own views. The document is checked by the same gate the app runs, so what passes on your machine renders on the device, and changing the banner later means editing a document, not app code.
Your app is built with
- A producer folder the CLI scaffolds, with the vocabulary and the documents, checked by
npm run check. - A vocabulary of four components and one action, and typed bindings generated from it.
- Four renderers mapping the document onto your SwiftUI components, and one engine that hosts the banner.
- A banner you change by editing a document, with the app code untouched.
Two roles appear in the steps. The producer writes the vocabulary and the documents; the app registers a view per vocabulary type and renders whatever passes the gate. In a small team both are you, and the folder layout keeps them apart anyway, because the documents will outlive any one build of the app. Steps 1 to 3 and 8 are the producer's and do not depend on the platform; steps 4 to 7 follow the platform you picked.
Start the producer folder
Inside the app's repository, next to the project, let the CLI scaffold a folder for the documents:
$ cd MyApp
$ npx @get-milano/cli init milano --name promo
$ cd milano && npm install && npm run checkmilano init writes a working producer setup and npm run check proves it: the schema for your editor is regenerated and every document is validated with the gate the engines run. The folder contains:
| File | What it is |
|---|---|
vocabulary.json | The contract between producer and app: component types, their properties and events, the custom actions. |
documents/welcome.json | A first document using the starter vocabulary. The banner goes next to it. |
documents.schema.json, .vscode/settings.json | The document schema specialized to your vocabulary, and the editor settings pointing at it, so typos get red squiggles as you type. |
package.json | validate, schema, and check scripts running the CLI. |
AGENTS.md, CLAUDE.md, .claude/skills/ | The authoring rules for an AI agent working in the folder, and the instruction to run the check after every change. |
These files are what the app will bundle in step 5: the vocabulary it registers renderers for, and the documents it renders. They stay in milano/, and the app reads them from there.
Declare the vocabulary
The vocabulary names what documents may use. The banner needs a column, text, an image, and a button, plus one action for the button to request. Replace vocabulary.json with:
{
"milano": "2.1.0",
"name": "promo",
"version": "1.1.0",
"components": {
"Column": { "children": true },
"Text": {
"properties": {
"text": "string",
"role": { "enum": ["title", "body"] }
}
},
"Image": {
"properties": {
"url": "string",
"height": "int?",
"cornerRadius": "int?"
}
},
"Button": {
"properties": {
"label": "string",
"enabled": "bool"
},
"events": { "tap": null }
}
},
"actions": {
"openUrl": { "parameters": { "url": "string" } }
},
"functions": {
"formatMoney": { "arguments": ["int", "string"], "returns": "string" }
}
}Each property has a type: "int?" is an optional integer, the enum is a closed set of values, and "tap": null declares an event with no payload. The action takes one parameter. Run npm run check again: the schema follows the new vocabulary, and welcome.json still validates, since everything it used is still declared.
Write the banner
{
"version": "2.1.0",
"vocabulary": { "name": "promo", "min": "1.0.0" },
"context": { "userName": "string" },
"root": {
"type": "Column",
"id": "banner",
"children": [
{
"type": "Image",
"properties": {
"url": "https://images.example.com/summer.jpg",
"height": 160,
"cornerRadius": 12
}
},
{
"type": "Text",
"properties": {
"text": { "$expr": "$concat('Summer sale, ', context.userName)" },
"role": "title"
}
},
{
"type": "Text",
"properties": {
"text": "Twenty percent off every espresso machine until Sunday.",
"role": "body"
}
},
{
"type": "Button",
"id": "cta",
"properties": { "label": "See the offer", "enabled": true },
"on": {
"tap": [ { "action": "openUrl", "url": "https://shop.example.com/sale" } ]
}
}
]
},
"metadata": { "campaign": "summer-2026" }
}Three things to notice. The title is an expression: the $expr wrapper reads context.userName, a value the app injects, and the document declares that it needs it under context. The button's on.tap binds the event to the openUrl action with its parameter. And vocabulary.min says which vocabulary the document was written for, so an app holding an older one fails the build with a typed error instead of rendering the wrong thing.
$ npm run check
documents/banner.json: valid
documents/welcome.json: validChange the title's role to "headline" and run the check. The gate answers with the rule it applied, the node (by path, since that node has no id), and what it expected:
$ npm run check
documents/banner.json: SchemaViolation: schema violation (property-type) at root/children[1]: expected enum, found stringThat is the same message the app would report, which is the point of running it here first. Put the role back.
Everything so far is the producer's side, and it is the same whatever the app is built with. The next four steps are the app's side, in the platform picked at the top of the page.
Generate the bindings
The vocabulary is machine-readable, so the app does not have to read properties by string. One command turns it into typed code, written straight into the app's source tree:
$ npx milano bindings vocabulary.json \
--swift-prefix Promo \
--swift-out ../MyApp/Milano/PromoBindings.swiftThe file holds one wrapper per component (PromoTextNode with text and role, PromoImageNode, PromoButtonNode with emitTap()), a Swift enum per declared enum (PromoTextRole), an exhaustive PromoAction with an unrecognized case, and PromoVocabulary.assertMatches.
$ npx milano bindings vocabulary.json \
--kotlin-package com.example.myapp.milano --kotlin-prefix Promo \
--kotlin-out ../app/src/main/kotlin/com/example/myapp/milano/PromoBindings.ktThe file holds one wrapper per component (PromoTextNode with text and role, PromoImageNode, PromoButtonNode with emitTap()), an enum class per declared enum (PromoTextRole), a sealed PromoAction with OpenUrl and Unrecognized and a from(action) decoder, and PromoVocabulary.assertMatches.
$ npx milano bindings vocabulary.json \
--ts-prefix Promo \
--ts-out ../src/milano/bindings.tsThe file holds one wrapper per component (PromoTextNode with text and role, PromoImageNode, PromoButtonNode with emitTap()), a string-literal union per declared enum (PromoTextRole), a discriminated PromoAction union with a promoAction(action) decoder, and PromoVocabulary.assertMatches. It imports only @get-milano/core.
A property the vocabulary declares non-optional is a non-optional property in the generated code: the gate guarantees it is there. Commit the file, and regenerate it whenever the vocabulary changes; the compiler then lists every place in the app the change touches.
Add the SDK and the producer files to the app
In Xcode, File, Add Package Dependencies, and enter the repository URL; choose "Up to Next Major" from 2.1.0 and add the MilanoSDK product to the app target. In a Package.swift it is:
dependencies: [
.package(url: "https://github.com/get-milano/sdk.git", from: "2.1.0")
]A tagged release resolves to a prebuilt, signed MilanoSDK.xcframework. Then add milano/vocabulary.json and milano/documents/banner.json to the app target: drag them into the project navigator, tick the target, and leave "Copy items if needed" unchecked, so the project references the files where the CLI wrote them. Xcode copies them into the bundle at build time, and an edited document is in the next build.
The engine is on Maven Central, so nothing but mavenCentral() is needed. In the app module's build.gradle.kts, the dependencies and a copy task that carries the producer folder's files into the assets before every build:
repositories {
mavenCentral()
}
dependencies {
implementation("dev.get-milano:engine-compose:2.1.0")
implementation("io.coil-kt:coil-compose:2.7.0") // the image loader the bridge below uses
}
// The producer folder's files travel with the app: copied into the assets
// before every build, so an edited document is in the next build.
val copyMilanoDocuments by tasks.registering(Copy::class) {
from("../milano") { include("vocabulary.json", "documents/banner.json") }
into("src/main/assets/milano")
}
tasks.named("preBuild") { dependsOn(copyMilanoDocuments) }Two other ways in, if you need them: every release also goes to GitHub Packages (whose Maven registry wants a token with read:packages even for public artifacts), and carries engine-compose-android-2.1.0.aar on the release page to drop into libs/. A checkout of the SDK can be consumed from source with includeBuild. The copy task keeps the app's assets/milano/ equal to the two files in milano/, so an edited document is in the next build and nothing is duplicated by hand.
$ npm install @get-milano/core @get-milano/reactTwo packages and nothing native: no autolinking, no pod install, because Milano draws nothing. The same two serve React on the web; only the components in the bridge change. The app imports the vocabulary and the banner straight from milano/ as JSON modules (resolveJsonModule), which is fine for this document since all its numbers are integers.
Milano distinguishes int from double, and JSON.parse does not: an imported 2.0 becomes 2. Once a document carries doubles, bundle it as text (the React Native sample's scripts/bundle-documents.mjs shows how) and hand the string to the engine.
Bridge your views
A renderer is one function: node in, view out. Each one reads the typed node and returns a view the app already has, or the platform's plain components, as here. Milano never draws anything; this file is where your design system meets the document.
import MilanoSDK
import SwiftUI
/// The bridge: one renderer per vocabulary type, each mapping a typed node
/// onto a view the app already has. Milano draws nothing itself.
enum PromoBridge {
static func registry() -> MilanoRegistry {
var registry = MilanoRegistry()
registry.register(ColumnRenderer(), for: "Column")
registry.register(TextRenderer(), for: "Text")
registry.register(ImageRenderer(), for: "Image")
registry.register(ButtonRenderer(), for: "Button")
return registry
}
}
final class ColumnRenderer: MilanoRenderer {
func render(_ node: MilanoNode) -> AnyView {
AnyView(
VStack(alignment: .leading, spacing: 12) {
ForEach(node.children) { $0 }
}
.padding(16)
)
}
}
final class TextRenderer: MilanoRenderer {
func render(_ node: MilanoNode) -> AnyView {
let text = PromoTextNode(node)
switch text.role {
case .title:
return AnyView(Text(text.text).font(.title2.bold()))
case .body:
return AnyView(Text(text.text).font(.body).foregroundStyle(.secondary))
}
}
}
final class ImageRenderer: MilanoRenderer {
func render(_ node: MilanoNode) -> AnyView {
let image = PromoImageNode(node)
return AnyView(
AsyncImage(url: URL(string: image.url)) { phase in
if case .success(let loaded) = phase {
loaded.resizable().scaledToFill()
} else {
Color.gray.opacity(0.2)
}
}
.frame(height: image.height.map(CGFloat.init))
.clipShape(RoundedRectangle(cornerRadius: CGFloat(image.cornerRadius ?? 0)))
)
}
}
final class ButtonRenderer: MilanoRenderer {
func render(_ node: MilanoNode) -> AnyView {
let button = PromoButtonNode(node)
return AnyView(
Button(button.label) { button.emitTap() }
.buttonStyle(.borderedProminent)
.disabled(!button.enabled)
)
}
}package com.example.myapp.milano
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import dev.getmilano.MilanoNode
import dev.getmilano.MilanoRegistry
import dev.getmilano.MilanoRenderer
/**
* The bridge: one renderer per vocabulary type, each mapping a typed node
* onto a composable the app already has. Milano draws nothing itself.
*/
fun promoRegistry(): MilanoRegistry {
val registry = MilanoRegistry()
registry.register("Column", ColumnRenderer)
registry.register("Text", TextRenderer)
registry.register("Image", ImageRenderer)
registry.register("Button", ButtonRenderer)
return registry
}
object ColumnRenderer : MilanoRenderer {
@Composable
override fun Render(node: MilanoNode) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(16.dp),
) {
for (child in node.children) {
key(child.key) { child.Render() }
}
}
}
}
object TextRenderer : MilanoRenderer {
@Composable
override fun Render(node: MilanoNode) {
val text = PromoTextNode(node)
when (text.role) {
PromoTextRole.Title -> Text(text.text, style = MaterialTheme.typography.titleLarge)
PromoTextRole.Body ->
Text(
text.text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
object ImageRenderer : MilanoRenderer {
@Composable
override fun Render(node: MilanoNode) {
val image = PromoImageNode(node)
var modifier: Modifier = Modifier.fillMaxWidth()
image.height?.let { modifier = modifier.height(it.toInt().dp) }
image.cornerRadius?.let { modifier = modifier.clip(RoundedCornerShape(it.toInt().dp)) }
AsyncImage(
model = image.url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = modifier,
)
}
}
object ButtonRenderer : MilanoRenderer {
@Composable
override fun Render(node: MilanoNode) {
val button = PromoButtonNode(node)
Button(onClick = { button.emitTap() }, enabled = button.enabled) {
Text(button.label)
}
}
}import { createMilanoRegistry } from "@get-milano/react";
import type { MilanoReactRegistry, MilanoRenderer } from "@get-milano/react";
import { Image, Pressable, Text, View } from "react-native";
import { PromoButtonNode, PromoImageNode, PromoTextNode } from "./bindings.ts";
/**
* The bridge: one renderer per vocabulary type, each mapping a typed node
* onto a component the app already has. Milano draws nothing itself.
*/
const ColumnRenderer: MilanoRenderer = ({ node }) => (
<View style={{ gap: 12, padding: 16 }}>{node.children}</View>
);
const TextRenderer: MilanoRenderer = ({ node }) => {
const text = new PromoTextNode(node);
if (text.role === "title") return <Text style={{ fontSize: 22, fontWeight: "700" }}>{text.text}</Text>;
return <Text style={{ fontSize: 15, color: "#6b6b6b" }}>{text.text}</Text>;
};
const ImageRenderer: MilanoRenderer = ({ node }) => {
const image = new PromoImageNode(node);
return (
<Image
source={{ uri: image.url }}
resizeMode="cover"
style={{
width: "100%",
height: image.height === null ? 160 : Number(image.height),
borderRadius: Number(image.cornerRadius ?? 0),
}}
/>
);
};
const ButtonRenderer: MilanoRenderer = ({ node }) => {
const button = new PromoButtonNode(node);
return (
<Pressable
accessibilityRole="button"
disabled={!button.enabled}
onPress={() => button.emitTap()}
style={{
alignSelf: "flex-start",
backgroundColor: "#d12360",
borderRadius: 999,
paddingHorizontal: 18,
paddingVertical: 10,
opacity: button.enabled ? 1 : 0.5,
}}
>
<Text style={{ color: "#fff", fontWeight: "600" }}>{button.label}</Text>
</Pressable>
);
};
export function promoRegistry(): MilanoReactRegistry {
const registry = createMilanoRegistry();
registry.register("Column", ColumnRenderer);
registry.register("Text", TextRenderer);
registry.register("Image", ImageRenderer);
registry.register("Button", ButtonRenderer);
return registry;
}node.children are the column's materialized children, already renderable: the container only places them. The button's emitTap() puts the event into Milano's dispatch, which runs the actions the document bound to it.
Create the engine and show the banner
One engine per app, created once with the vocabulary and the registry, then a builder per surface: the document, the context the document declared, and the handler for the actions it may request. Both files come from the producer folder the app bundled in step 5.
import MilanoSDK
import SwiftUI
/// One engine for the whole app, created once with the vocabulary the
/// bridge implements; every screen builds its views from it.
enum Milano {
static let engine: MilanoEngine = {
do {
let engine = try MilanoEngine(
vocabularyJSON: resource("vocabulary"),
registry: PromoBridge.registry())
// Refuses to run against a vocabulary the bindings were not generated from.
PromoVocabulary.assertMatches(engine)
return engine
} catch {
fatalError("Milano setup failed: \(error)")
}
}()
/// The banner: the document from the producer folder, bundled with
/// the app, plus the context it declared and the handler for its actions.
static func bannerBuilder() -> MilanoViewBuilder {
engine.viewBuilder(document: resource("banner"))
.context(["userName": .string("Ada")])
.actionHandler(handle(_:))
.label("banner")
}
/// The single funnel for custom actions. The gate proved `url` is a
/// string; whether it is safe to open is the app's decision.
@Sendable static func handle(_ action: MilanoAction) async throws -> MilanoValue? {
switch PromoAction(action) {
case .openUrl(let url):
guard let url = URL(string: url), url.scheme == "https" else { return nil }
await open(url)
case .unrecognized(let action):
print("unhandled action \(action.name)")
}
return nil
}
@MainActor private static func open(_ url: URL) {
UIApplication.shared.open(url)
}
private static func resource(_ name: String) -> Data {
guard let url = Bundle.main.url(forResource: name, withExtension: "json"),
let data = try? Data(contentsOf: url)
else { fatalError("\(name).json is not in the app bundle; add it to the target") }
return data
}
}
/// Drop it into any screen: the banner renders where this view sits.
struct PromoBannerView: View {
var body: some View {
MilanoHost(builder: Milano.bannerBuilder()) {
ProgressView()
} failure: { _ in
EmptyView() // an optional surface fails to nothing
}
}
}Put PromoBannerView() wherever the banner belongs, a home screen, a list header, a sheet.
package com.example.myapp.milano
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import dev.getmilano.MilanoAction
import dev.getmilano.MilanoEngine
import dev.getmilano.MilanoHost
import dev.getmilano.MilanoValue
import dev.getmilano.MilanoViewBuilder
import dev.getmilano.viewBuilder
/**
* One engine for the whole app, created once with the vocabulary the
* bridge implements; every screen builds its views from it.
*/
class Milano(private val context: Context) {
private val engine: MilanoEngine by lazy {
MilanoEngine(
vocabularyJson = asset("milano/vocabulary.json"),
registry = promoRegistry(),
).also { PromoVocabulary.assertMatches(it) }
// Refuses to run against a vocabulary the bindings were not generated from.
}
/**
* The banner: the document from the producer folder, copied into the
* assets at build time, plus the context it declared and the handler
* for its actions.
*/
fun bannerBuilder(): MilanoViewBuilder =
engine
.viewBuilder(asset("milano/documents/banner.json"))
.context(mapOf("userName" to MilanoValue.StringValue("Ada")))
.actionHandler { action -> handle(action) }
.label("banner")
/**
* The single funnel for custom actions. The gate proved `url` is a
* string; whether it is safe to open is the app's decision.
*/
private fun handle(action: MilanoAction): MilanoValue? {
when (val decoded = PromoAction.from(action)) {
is PromoAction.OpenUrl -> {
val uri = Uri.parse(decoded.url)
if (uri.scheme == "https") {
context.startActivity(Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
is PromoAction.Unrecognized -> println("unhandled action ${decoded.action.name}")
}
return null
}
private fun asset(name: String): String = context.assets.open(name).bufferedReader().use { it.readText() }
}
/** Drop it into any screen: the banner renders where this composable sits. */
@Composable
fun PromoBanner(milano: Milano) {
val builder = remember { milano.bannerBuilder() }
MilanoHost(
builder = builder,
loading = { CircularProgressIndicator() },
failure = { /* an optional surface fails to nothing */ },
)
}Create one Milano(applicationContext) for the app (in your Application or your dependency graph) and put PromoBanner(milano) wherever the banner belongs, a home screen, a list header, a bottom sheet. Events and view updates run on the main thread by default.
import { MilanoEngine, MilanoValue } from "@get-milano/core";
import type { MilanoAction } from "@get-milano/core";
import { MilanoHost } from "@get-milano/react";
import type { MilanoReactBuilder } from "@get-milano/react";
import { useMemo } from "react";
import type { ReactNode } from "react";
import { ActivityIndicator, Linking } from "react-native";
import { PromoVocabulary, promoAction } from "./bindings.ts";
import { promoRegistry } from "./bridge.tsx";
import banner from "../../milano/documents/banner.json";
import vocabulary from "../../milano/vocabulary.json";
/**
* One engine for the whole app, created once with the vocabulary the
* bridge implements; every screen builds its views from it.
*/
const engine = new MilanoEngine({ vocabularyJson: JSON.stringify(vocabulary), registry: promoRegistry() });
// Refuses to run against a vocabulary the bindings were not generated from.
PromoVocabulary.assertMatches(engine);
/**
* The single funnel for custom actions. The gate proved `url` is a
* string; whether it is safe to open is the app's decision.
*/
async function handle(action: MilanoAction): Promise<MilanoValue | null> {
const decoded = promoAction(action);
switch (decoded.kind) {
case "openUrl":
if (decoded.url.startsWith("https://")) await Linking.openURL(decoded.url);
return null;
case "unrecognized":
console.log(`unhandled action ${decoded.action.name}`);
return null;
}
}
/**
* The banner: the document from the producer folder, imported in place,
* plus the context it declared and the handler for its actions.
*/
export function bannerBuilder(): MilanoReactBuilder {
return engine
.viewBuilder(JSON.stringify(banner))
.context({ userName: MilanoValue.string("Ada") })
.actionHandler(handle)
.label("banner");
}
/** Drop it into any screen: the banner renders where this component sits. */
export function PromoBanner(): ReactNode {
const builder = useMemo(() => bannerBuilder(), []);
return <MilanoHost builder={builder} loading={<ActivityIndicator />} failure={() => null} />;
}Put <PromoBanner /> wherever the banner belongs. The builder is memoized because a new builder means a new build; MilanoHost subscribes to the view and tears it down when it unmounts.
Build and run: the image loads, the title reads "Summer sale, Ada", the button opens the offer. The handler is the last capability check: the gate proved url is a string, the app decides that only https leaves it. The failure content is where a rejected document lands; for an optional surface, nothing is the right thing to show.
Everything else the contract gives you
The banner uses a fraction of what a document can do. Everything below is the same contract, needs no new app code beyond what a feature explicitly asks for, and is documented in full in the SDK guides.
This document is a small basket. It repeats a list with a stable identity per row, edits that list in place, keeps a derived count in step, reacts to being shown, and formats money through a function the app computes:
{
"version": "2.1.0",
"vocabulary": {
"name": "promo",
"min": "1.1.0"
},
"state": {
"items": {
"array": {
"record": {
"id": "string",
"name": "string",
"cents": "int"
}
}
},
"count": "int",
"seen": "bool"
},
"root": {
"type": "Column",
"id": "basket",
"children": [
{
"type": "Text",
"id": "heading",
"properties": {
"text": {
"$expr": "$concat('Basket: ', $str(state.count), ' items')"
},
"role": "title"
}
},
{
"type": "$repeat",
"id": "rows",
"items": {
"$expr": "state.items"
},
"as": "item",
"key": {
"$expr": "item.id"
},
"children": [
{
"type": "Column",
"children": [
{
"type": "Text",
"id": "line",
"properties": {
"text": {
"$expr": "$concat(item.name, ' ', formatMoney(item.cents, 'EUR'))"
},
"role": "body"
}
},
{
"type": "Button",
"id": "upgrade",
"properties": {
"label": "Make it a large",
"enabled": true
},
"on": {
"tap": [
{
"action": "$update",
"key": "items",
"at": {
"$expr": "item_index"
},
"field": "cents",
"value": {
"$expr": "item.cents + 50"
}
}
]
}
},
{
"type": "Button",
"id": "remove",
"properties": {
"label": "Remove",
"enabled": true
},
"on": {
"tap": [
{
"action": "$remove",
"key": "items",
"at": {
"$expr": "item_index"
}
}
]
}
}
]
}
]
},
{
"type": "Button",
"id": "add",
"properties": {
"label": "Add an espresso",
"enabled": {
"$expr": "state.count < 5"
}
},
"on": {
"tap": [
{
"action": "$append",
"key": "items",
"value": {
"id": "espresso",
"name": "Espresso",
"cents": 250
}
}
]
}
}
]
},
"on": {
"appear": [
{
"action": "$set",
"key": "seen",
"value": true
}
]
},
"watch": {
"items": [
{
"action": "$set",
"key": "count",
"value": {
"$expr": "$length(state.items)"
}
}
]
}
}| What it uses | What it is |
|---|---|
$repeat with key | One template per element of an array. The key makes a row's identity follow the element, so removing the first row does not renumber the rest. Without it, rows are identified by position. |
$append, $remove, $update | Change one element of a list in state: add, drop, or set one field. Inside the template, item_index is the row's position at the moment of the tap, so a row edits itself. |
watch | Action lists that run when a state key changes, as part of the change. Here it keeps count in step with the list. A watch never triggers another watch, so there is no cascade to reason about. |
on | Lifecycle bindings: appear when the host says the view is on screen, disappear when it leaves. The host container delivers both; Milano infers nothing. |
formatMoney(...) | A function your app computes, declared in the vocabulary and called by its bare name. The contract's own functions carry a $ ($concat, $str, $length), so yours can be named anything, round included, without either shadowing the other. |
Declaring and using your own functions
Formatting is the usual reason: money, dates, plurals, units. The document should not carry locale rules, and Milano should not guess them, so the app computes them. Declare the function in the vocabulary, with its argument types and what it returns:
{
"functions": {
"formatMoney": { "arguments": ["int", "string"], "returns": "string" }
}
}Then give the engine one function handler. It answers every function the vocabulary declares, for every view that engine builds:
// The engine takes one function handler; it answers every function the
// vocabulary declares, for every view built from this engine.
let engine = try MilanoEngine(
vocabularyJson: vocabulary,
registry: PromoBridge.registry(),
functionHandler: MilanoClosureFunctionHandler { call in
switch call.name {
case "formatMoney":
let cents = call.arguments[0].intValue ?? 0
let currency = call.arguments[1].stringValue ?? "EUR"
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = currency
let amount = NSNumber(value: Double(cents) / 100)
return .string(formatter.string(from: amount) ?? "")
default:
return .null
}
}
)// The engine takes one function handler; it answers every function the
// vocabulary declares, for every view built from this engine.
val engine =
MilanoEngine(
vocabularyJson = vocabulary,
registry = promoRegistry(),
functionHandler =
MilanoFunctionHandler { call ->
when (call.name) {
"formatMoney" -> {
val cents = call.arguments[0].intOrNull ?: 0L
val currency = call.arguments[1].stringOrNull ?: "EUR"
val format = NumberFormat.getCurrencyInstance()
format.currency = Currency.getInstance(currency)
MilanoValue.StringValue(format.format(cents / 100.0))
}
else -> null
}
},
)// The engine takes one function handler; it answers every function the
// vocabulary declares, for every view built from this engine.
const engine = new MilanoEngine({
vocabularyJson: vocabulary,
registry: promoRegistry(),
functionHandler: (call) => {
if (call.name !== "formatMoney") return null;
const [cents, currency] = call.arguments;
return MilanoValue.string(
new Intl.NumberFormat(undefined, {
style: "currency",
currency: currency.stringValue ?? "EUR",
}).format(Number(cents.intValue ?? 0n) / 100),
);
},
});Documents then call it like any other function: formatMoney(item.cents, 'EUR'). The gate checks the call against the declaration, so a wrong argument count or type fails the build rather than the screen. A function must be pure over its arguments: the same arguments always give the same value. That is why the locale is passed in from context rather than read inside the handler, and it is what lets the engine call it whenever a dependency changes.
npm run check has no app to ask, so it answers every declared function with the zero value of its return type: an empty string here. The document is still fully type-checked; only the formatting is missing. The playground answers a small library of functions if you want to see values.
The rest, in one place
| Feature | What it is for | Guide |
|---|---|---|
| Typed results and failures | An action's handler answers with a value the document reads as result, or fails with a reason it reads as failure, so error copy lives in the document. | Writing documents |
| Dispatch identity | Every dispatched action carries a process-unique dispatchId: the idempotency key for the request your handler makes. | Creating a bridge |
| Document replacement | view.replace(document) swaps a live view's document, keeping the state whose declaration is unchanged: hot reload, or a refreshed document, without losing what the user typed. | Creating a bridge |
| Capability grants | A surface can narrow the actions a document may dispatch, or declare extra ones for itself, so a banner cannot reach what a settings screen can. | Creating a bridge |
| Context that changes | A context handle pushes new values into every live view: sign-in, feature flags, locale. | Guidelines |
| Analytics | Impressions, taps, dispatches, and outcomes arrive as structured records, with no document or renderer involvement. | Analytics |
| Guardrails | Every rejection rule, every runtime occurrence, the limits, and the unknown-type policies. | Guardrails |
Change the banner
Edit milano/documents/banner.json: new copy, a different image, a second line of text. Run npm run check, rebuild the app, and the change is on screen. No app code moved, because the app bundles the producer folder's files: from here on, changing what the banner says is a change in milano/, checked by the CLI, and the app is rebuilt with it.
When the vocabulary itself changes (a new component, a new property), bump its version and let the CLI say whether the bump is right before the app team depends on it:
$ npx milano diff vocabulary-1.0.0.json vocabulary.json
ADDITIVE Text property maxLines added
verdict: ok (0 breaking, 1 additive)Additive changes need a minor bump, breaking ones a major, and documents declare the minimum they need, so an older app and a newer document never meet by accident. Regenerate the bindings, add the renderer for anything new, and the compiler walks you through the rest.
Switch the platform above and read steps 4 to 7 again: the vocabulary, the banner, and the check never changed. That is the contract: one document, validated once, rendered by whatever the app is built with.
Where next
$repeat, forms.