JSX to Bialet Migration#
You are moving a React/JSX codebase to Bialet. The good news: inline HTML strings look a lot like JSX. The bad news: the surface similarity hides hard limits that will bite you on the first nested list. This page is the cheatsheet — the five differences that turn JSX code into Bialet code.
The short version: inline HTML strings are plain strings, not a component
tree. No props, no children, no expressions-in-markup outside {{ }}, and no
dangerouslySetInnerHTML.
1. Tag Nesting Limitations#
JSX lets you nest any element inside any element. Bialet has one hard rule:
The outermost tag of an inline HTML string must not appear again as a direct child tag at any nesting level.
The parser checks the whole string, not just the first level, and reports
Cannot nest <div> inside <div> on the offending line.
// WRONG — <div> is the outermost tag AND a child
var bad = <div><div>Hello</div></div>
var alsoBad = <section><section>Nested</section></section>
// CORRECT — open with a different tag, then nest freely
var good = <div><section>Hello</section></div>
// CORRECT — once the tree starts with a different tag, the
// original tag can appear at any depth below it
var fine = <section>
<div>
<div>Deeply nested same tags work here</div>
</div>
</section>
Pitfall: this bites the common JSX pattern of a wrapper <div> inside a
<div> (row, then column). If you need the same tag twice, change the
outermost wrapper or extract the inner one into a component method (see
section 4).
Two more parser quirks to unlearn from JSX:
Mismatched closing tags are not validated. Only the outermost closing tag is matched.
<div><span>Hello</div>compiles and is served verbatim — the browser auto-closes the<span>at render time. Write well-formed HTML yourself; there is no compiler checking your balance.Tag names are restricted. Lowercase, start with a letter, only letters, numbers, and hyphens.
<MyComponent>and<my_component>are compiler errors. PascalCase component names are a JSX thing — see section 4.
2. Map Callback Restrictions#
In JSX you write {items.map(item => <li>{item}</li>)} and the arrow function
can hold any logic. In Bialet the map callback is a single Wren
expression — the inline HTML string itself:
var items = ["Apple", "Banana", "Cherry"]
var html = <ul>{{ items.map { |item| <li>{{ item }}</li> } }}</ul>
// Output: <ul><li>Apple</li><li>Banana</li><li>Cherry</li></ul>
The two restrictions that will trip you up:
No statements inside the callback#
You cannot declare a variable or write multiple lines of Wren inside map:
// WRONG — multiple statements inside the map callback
{{ tasks.map { |task|
var icon = task.finished ? "✓" : "○"
<li>{{ icon }} {{ task.description }}</li>
} }}
If you need pre-computed values, compute them before map, or add a
computed property to your model class:
var tasks = Tasks.list().map { |t| t.withIcon } // pre-process the list
return <ul>{{ tasks.map { |t| <li>{{ t.icon }} {{ t.description }}</li> } }}</ul>
The body must start on the line after {#
A multi-line block whose body is not a single expression on the { line
returns null silently — the output is empty and nothing tells you why:
// WRONG — the callback body breaks onto its own line, returns null, renders empty
return <div>{{ items.map { |item|
<li>{{ item }}</li>
} }}</div>
// CORRECT — the expression starts on the {{ line; the HTML string
// itself may span many lines
return <div>{{ items.map { |item| <li>
{{ item.description }}
</li> } }}</div>
Empty lists render empty#
map over an empty list produces nothing. In JSX you’d conditionally render a
fallback; in Bialet you pair map with &&:
return <main>
<ul>
{{ tasks.map { |task| <li>{{ task.description }}</li> } }}
</ul>
{{ tasks.count == 0 && <section class="empty-state">
<p>No tasks yet.</p>
</section> }}
</main>
3. Interpolation Rules#
JSX interpolates with {expr}. Bialet uses {{ expr }} — the extra brace is
what makes it valid Wren inside an HTML string. The rules differ more than the
syntax:
{{ }} is the only interpolation point#
Interpolation happens only inside {{ }}. A bare {expr} is not a template
expression — inside an HTML string it is literal text, and outside one it is
Wren map syntax.
Values are escaped by default#
This is the biggest safety win over JSX (where you must remember
dangerouslySetInnerHTML). Plain values — user input, database rows, URL
parameters — are HTML-escaped automatically:
var userInput = "<script>alert('xss')</script>"
var safe = <p>{{ userInput }}</p>
// Renders: <p><script>alert('xss')</script></p>
Line breaks follow Wren, not JSX#
A {{ }} expression can span lines, but an infix operator must end its line.
A leading operator is a parse error:
// CORRECT — operators end each line
return <div>{{
showClear &&
<form method="post" action="/clear">
<button>Clear</button>
</form>
}}</div>
// WRONG — the operator starts a line
return <div>{{
showClear
&& <form method="post" action="/clear">
<button>Clear</button>
</form>
}}</div>
Interpolation depth is limited#
Nesting is capped at 9 levels of {{ }}. Deeply nested JSX interpolations
must be flattened.
No expressions in string values#
JSX lets you write className={btn ${active ? ‘on’ : ‘’}} — a template
literal inside an attribute. Bialet does not support string interpolation
into attribute values. Compute the string first, then interpolate the result:
// WRONG — no template literals
<a className="btn {{ active ? 'on' : '' }}">...</a>
// CORRECT — compute the class, then interpolate it
var cls = "btn " + (active ? "on" : "")
<a class="{{ cls }}">...</a>
For simple two-way choices, the ternary inside {{ }} is fine:
<button class="{{ task.finished ? "checkbox-btn checked" : "checkbox-btn" }}">
4. Component-as-Method Pattern#
JSX has components: functions that take props and return JSX, composed via
<Card title="x" />. Bialet has methods that return inline HTML strings.
There is no prop syntax, no children slot, no component invocation in markup.
Define a component as a method on a class:
// _template.wren
class Template {
card(title, body) { <article class="card">
<h2>{{ title }}</h2>
<div class="card-body">{{ body }}</div>
</article> }
layout(content) { <!doctype html>
<html lang="en">
<body>
<main>{{ content }}</main>
</body>
</html> }
}
Compose by passing markup into methods, not by nesting tags:
// index.wren
import "_template" for Template
return Template.new().layout(
Template.new().card("Hello", <p>World</p>)
)
Because the method returns a plain string, the same-tag nesting rule applies
across the boundary: if a component’s outermost tag would repeat under a
caller’s outermost tag, the parser rejects it. card() returning an
<article> inside a page whose outermost tag is also <article> fails —
widen one of them.
Pitfall: instance methods need new(). Template.layout(...) looks for
a static method and fails. Always instantiate first.
Pitfall: a method body is an expression body only when the expression
starts on the same line as {. A statement body on the following line
returns null silently — the component renders as empty with no error.
Keep the opening HTML tag on the method’s first line.
5. Raw HTML Security Footguns#
Bialet escapes by default — the safety flip of JSX’s default-raw model. The
footguns are the ways you can accidentally defeat that default and re-open
the XSS holes dangerouslySetInnerHTML created for you.
Footgun 1: .safe is the opposite of what you expect#
.safe marks a string as trusted markup. Inside {{ }}, interpolation
already escapes, so {{ userInput.safe }} escapes the text twice
(&lt; instead of <) — the raw HTML you meant is shown as text.
Drop .safe from templates entirely.
Footgun 2: .raw / HtmlNode skip all sanitization#
To render a runtime string as markup you must mark it safe, and that is an assertion, not a sanitizer:
var html = "<b>bold</b>".raw
<p>{{ html }}</p> // rendered as written
<p>{{ "<b>bold</b>" }}</p> // escaped: <b>bold</b>
.raw, HtmlNode.new(...), and HtmlNode values are inserted verbatim —
no escaping, no scrubbing of <script>, no filtering of javascript: URLs.
Never use them on untrusted input. The JSX instinct “I need raw HTML here”
should map to “this value is markup I fully control”, and nothing else.
Footgun 3: Markdown.html() passes raw HTML through#
The built-in Markdown parser escapes HTML only inside code blocks. Raw HTML in paragraphs, headings, and lists passes through unescaped, and link URLs are not validated. Rendering untrusted Markdown is XSS:
// WRONG — raw HTML passes through to the output
var content = Markdown.html("<script>alert('xss')</script>")
// WRONG — javascript: links are not filtered
var link = Markdown.html("[x](javascript:alert('xss'))")
Render only Markdown you trust.
Footgun 4: interpolation inside <script> and href is not “safe”#
Escaping makes {{ value }} safe for text and attribute values, but it does
not neutralize JavaScript or URLs. Never interpolate untrusted input into a
<script> block or a javascript: href — restructure the page so it cannot
happen.
Footgun 5: Request.post() returns null#
Reading a missing form field gives you null, not "". String operations on
it crash the request. Guard every read:
var msg = Request.post("msg") || ""
Migration checklist#
[ ] Replace
{expr}with{{ expr }}— and expect values to be escaped.[ ] Open repeated same-tag nesting with a different outer tag, or extract a component method.
[ ] Move statements out of
mapcallbacks; keep the callback body a single expression starting on the{line.[ ] Compute strings before interpolating into attributes; no template literals.
[ ] Turn components into class methods; compose by passing markup, not by nesting tags.
[ ] Replace
dangerouslySetInnerHTMLwith.raw/HtmlNodeonly for markup you fully control; never for user input.[ ] Guard
Request.post()with|| "".
See Template for the full inline-HTML reference and Security for the escape rules and pitfalls.