Frontend
Frontend
Synsema serves HTML from the server (SSR) — no imposed framework or CSS. Two paths: render() templates (full design control) and content() (agent-negotiable). For the end-to-end walkthrough (layout, styling, forms, error pages), see Build a website.
-- Doc example: render() templates. HTML with { holes }, loops with an empty branch,
-- chained conditionals, VERBATIM blocks for inline CSS/JS, components with props,
-- named slots, and safe JSON-for-<script>. Auto-escaped by default (XSS-safe).
-- Self-contained: writes tiny templates, then renders them (runs anywhere, sandboxed).
intent: "doc example: frontend render()"
require file.write("_doctest_card.html")
require file.read("_doctest_card.html")
require file.write("_doctest_page*")
require file.read("_doctest_page*")
require file.write("_doctest_base.html")
require file.read("_doctest_base.html")
require file.write("_doctest_unread.html") -- written, never granted for reading
write_file("_doctest_card.html", "<div class=\"card\"><h2>{ title }</h2><ul>{ each tag in tags }<li>{ tag }</li>{ end }</ul></div>")
print(body of render("_doctest_card.html", {"title": "Synsema", "tags": ["fast", "secure"]}))
test "render fills holes, loops, and HTML-escapes by default (XSS-safe)"
let html be body of render("_doctest_card.html", {"title": "A <b> tag", "tags": ["x", "y"]})
assert(contains(html, "<h2>A <b> tag</h2>")) -- auto-escaped
assert(contains(html, "<li>x</li><li>y</li>")) -- { each } loop
test "verbatim { raw } block: inline CSS/JS with literal braces"
write_file("_doctest_page.html", "<style>{ raw }.card { color: red; }{ end }</style>{ when n == 1 }one{ otherwise when n == 2 }two{ otherwise }many{ end }")
let html be body of render("_doctest_page.html", {"n": 2})
assert(contains(html, ".card { color: red; }")) -- braces intact (no holes inside)
assert(contains(html, "two")) -- { otherwise when } chains
test "each empty branch + enumerate for indexes"
write_file("_doctest_page.html", "{ each e in enumerate(xs) }[{ e.index }:{ e.item }]{ otherwise }empty{ end }")
assert_eq(body of render("_doctest_page.html", {"xs": ["a", "b"]}), "[0:a][1:b]")
assert_eq(body of render("_doctest_page.html", {"xs": []}), "empty")
test "include with props = a component (isolated scope)"
write_file("_doctest_card.html", "<b>{ label }</b>")
write_file("_doctest_page.html", "{ include \"_doctest_card.html\" with {\"label\": item} }")
assert_eq(body of render("_doctest_page.html", {"item": "Props!"}), "<b>Props!</b>")
test "layout + named slot: { slot \"x\" } filled by { fill \"x\" }"
write_file("_doctest_base.html", "<head>{ slot \"extra\" }</head><main>{ slot }</main>")
write_file("_doctest_page.html", "{ layout \"_doctest_base.html\" }{ fill \"extra\" }<meta n=\"{ t }\">{ end }<h1>{ t }</h1>")
let html be body of render("_doctest_page.html", {"t": "Hi"})
assert(contains(html, "<head><meta n=\"Hi\"></head>"))
assert(contains(html, "<main>") and contains(html, "<h1>Hi</h1>"))
test "json_for_script: data into an inline <script> without XSS"
let js be json_for_script([{"name": "a</script>"}])
assert(contains(js, "a\\u003c/script\\u003e")) -- cannot close the tag
assert(not contains(js, "</script>"))
-- json_encode is for APIs/storage; json_for_script is for <script> embedding.
task shout(s)
give upper(s) + "!"
task render_or_error(tpl, data)
-- One file per template text: render() keeps a parsed template per path and notices an
-- edit by the file's time and size, so two same-size templates written within the same
-- clock tick would read as one.
let path be "_doctest_page_" + slice(hex(sha256(tpl)), 2, 18) + ".html"
write_file(path, tpl)
try
give body of render(path, data)
recover err
give "error: " + err
test "a hole takes any value, like a backtick string — but a missing field is an error"
assert_eq(render_or_error("[{ x }]", {"x": nothing}), "[nothing]") -- guard it with { when x != nothing }
assert_eq(render_or_error("[{ xs }]", {"xs": [1, "a"]}), "[[1, "a"]]")
assert(contains(render_or_error("[{ missing }]", {}), "field 'missing' is not in the template data"))
-- text + nothing is an error in a hole too (as in the language since v0.6.29)
assert(contains(render_or_error("[{ \"a\" + x }]", {"x": nothing}), "Cannot add text and nothing"))
assert_eq(render_or_error("[{ `a{x}` }]", {"x": nothing}), "[anothing]")
test "each walks a list or a map's keys; text and numbers are an error"
assert_eq(render_or_error("{ each k in m }{ k }={ m[k] };{ end }", {"m": {"a": 1, "b": 2}}), "a=1;b=2;")
assert_eq(render_or_error("{ each k in m }{ k }{ otherwise }none{ end }", {"m": {}}), "none")
assert(contains(render_or_error("{ each c in s }{ c }{ end }", {"s": "ab"}), "Cannot iterate over text"))
assert(contains(render_or_error("{ each c in n }{ c }{ end }", {"n": 3}), "Cannot iterate over number"))
test "raw expr, your own tasks, comments and a literal brace"
assert_eq(render_or_error("{ raw b } { b }", {"b": "<b>"}), "<b> <b>")
assert_eq(render_or_error("{ upper(t) }", {"t": "hi"}), "HI") -- builtins…
assert_eq(render_or_error("{ shout(t) }", {"t": "hi"}), "HI!") -- …and your own tasks
assert_eq(render_or_error("{ -- a note }ok", {}), "ok")
assert_eq(render_or_error("{ \"{\" }x{ \"}\" }", {}), "{x}")
assert(contains(render_or_error("a { b", {}), "unclosed '{'"))
test "include shares the current scope (loop variables too); include with sees only its props"
write_file("_doctest_card.html", "<{ x }{ y }>")
assert_eq(render_or_error("{ each x in xs }{ include \"_doctest_card.html\" }{ end }", {"xs": [1, 2], "y": "y"}), "<1y><2y>")
assert(contains(render_or_error("{ include \"_doctest_card.html\" with {\"x\": 1} }", {"y": "y"}), "field 'y' is not in the template data"))
test "layouts nest; an unfilled named slot is empty; fill lives at the top level of a page with a layout"
write_file("_doctest_card.html", "<o>{ slot }</o>") -- the outer layout
write_file("_doctest_base.html", "{ layout \"_doctest_card.html\" }<m>{ slot }{ slot \"side\" }</m>")
assert_eq(render_or_error("{ layout \"_doctest_base.html\" }P", {}), "<o><m>P</m></o>")
assert(contains(render_or_error("{ layout \"_doctest_base.html\" }{ when true }{ fill \"side\" }S{ end }{ end }", {}), "must be at the top level"))
assert(contains(render_or_error("{ fill \"side\" }S{ end }P", {}), "requires a '{ layout"))
test "content(): one semantic tree, negotiated per request (HTML, Markdown, JSON)"
let pg be page([heading(1, "Title"), prose("text"), list(["a", "b"]), ordered_list(["first"]),
link("Docs", "/docs"), image("/cover.png", "cover"), code("let x be 1", "synsema"),
section([prose("grouped")]), raw("<hr>")],
{"title": "T", "description": "D", "stylesheet": "/assets/app.css",
"class": "doc", "header": "<nav>…</nav>", "footer": "<footer>…</footer>"})
-- `give content(pg)` in a route; what `/…json` (or Accept: application/json) serves is this:
let tree be json_decode(json_encode(content(pg)))
assert_eq(tree["type"], "page")
assert_eq(apply((n) => n["type"], tree["nodes"]),
["heading", "prose", "list", "ordered_list", "link", "image", "code", "section", "raw"])
assert_eq(keys(tree["meta"]), ["title", "description", "stylesheet", "class", "header", "footer"])
assert_eq(tree["nodes"][6], {"type": "code", "text": "let x be 1", "lang": "synsema"})
test "an include from disk is a file read: it needs file.read like render() (v0.6.45+)"
write_file("_doctest_unread.html", "<p>not declared</p>")
let out be render_or_error("{ include \"_doctest_unread.html\" }", {})
assert(contains(out, "Capability not granted: file_read(\"_doctest_unread.html\")"))render() — free-form templates§
render("page.html", data) returns an HTML response; body of render(...) is the string. Templates are HTML with { ... } holes:
route "GET /"
give render("pages/home.html", {"title": "My App", "items": items})
{ name }/{ expr }interpolate (HTML-escaped; expressions can call builtins and your own tasks — define tasks before theserveblock);{ raw expr }opts out for trusted HTML.- A hole takes any value, like a backtick string:
nothingrenders as the wordnothingand a list as[1, "a"](texts quoted inside, since v0.6.29) — guard optional values with{ when x != nothing }. A name that is not in the data is an error (field 'missing' is not in the template data), never an empty string. Expressions follow the language:{ "Hi " + x }withx=nothingis the v0.6.29text + nothingerror — write `{Hi {x}}` or guard it. { raw }…{ end }is a VERBATIM block — inline CSS/JS with literal braces:<style>{ raw } .card { color: red; } { end }</style>. A single literal brace outside it is{ "{" }; a{that never closes is an error (unclosed '{' in template).{ each x in xs } … { otherwise } … { end }— loop with an optional empty branch; indexes viaenumerate(xs)({ e.index }/{ e.item }). Over a map it walks the keys ({ each k in m }{ k }={ m[k] }{ end }; an empty map takes{ otherwise }). Text or a number is a hard error (Cannot iterate over text in '{ each }') — unlike the language'seach, a template does not walk characters.{ when c } … { otherwise when c2 } … { otherwise } … { end }— chained, like the language.- Compose:
{ include "partials/nav.html" }shares the current scope (the page's data and loop variables);{ include "partials/card.html" with {"title": t} }is a component with isolated props — it sees only that map plus your tasks, so a page field it forgot is the "not in the template data" error.{ layout "layouts/base.html" }with{ slot }, plus named slots —{ slot "head_extra" }in the layout,{ fill "head_extra" }…{ end }in the page. A named slot nobody fills renders empty; afillmust sit at the top level of a page that declares alayout(both are errors otherwise); layouts nest (a layout may declare its own{ layout }). { -- comment }emits nothing. Template paths (includinginclude/layout) resolve against the working directory, not against the including file.- Data into an inline
<script>:{ raw json_for_script(x) }— neverjson_encodethere (a value containing</script>would break out of the tag).
Templates are parse-cached and hot-reload per request (edit → refresh, no restart); render("literal.html") paths are validated at startup and by synsema check. For the .syn itself, synsema serve app.syn --watch restarts on change.
Capability (engine v0.6.14+): rendering a template read from disk is a file read, so render(path) needs require file.read("<path>"), just like read_file — this closes reading arbitrary files through a request-derived path. Since engine v0.6.45 the same holds for every { include } and { layout } read from disk: each file a page pulls in needs file.read too (before, a program that could write templates could include .env in a page without declaring that it reads it). A missing one is a 500 Capability not granted: file_read("partials/nav.html") naming the file. One line covers a template tree, at any depth: require file.read("./templates/") ( crosses folders; templates/ would be only the files directly inside). With pages/, layouts/ and partials/ side by side, declare each one (require file.read("./pages/"), and so on). A template baked into a synsema build binary* needs no capability at all (it's part of the program).
Installable on phones and desktop (engine v0.6.15+): the same rendered site becomes an app — home-screen icon, full screen, offline shell, push notifications — with a manifest, a service worker and the <head> lines that synsema init --pwa writes for you. See Your app on the phone (PWA).
content() — agent-negotiable pages§
Build a semantic tree once; the runtime serves HTML to humans and Markdown/JSON to agents (by Accept header or a .md/.json suffix) — ideal for docs/blogs an LLM should read.
route "GET /docs/:slug"
give content(page([heading(1, "Title"), prose("…"), code("let x be 1", "synsema")], {
"title": "Title", "description": "…", "stylesheet": "/assets/app.css"
}))
The nodes: heading(level, text), prose(text), list(items), ordered_list(items), link(text, href), image(src, alt), code(text, lang?), section(nodes) and raw(html) (trusted HTML, not escaped — everything else is). page(nodes, meta?) takes these meta keys:
| Key | HTML | Markdown / JSON |
|---|---|---|
title, description | <title>, <meta name="description"> and a JSON-LD WebPage block | JSON carries them in meta |
stylesheet | a <link rel="stylesheet"> in <head> | JSON meta only |
class | the class of the <main> wrapper (default prose) | JSON meta only |
header, footer | raw HTML before and after <main> — your nav and footer (body of render("partials/nav.html", {}) reuses the same partial) | not in Markdown; JSON meta only |
Only give content(...) is negotiated: html()/render() stay HTML, a map stays JSON. The .json form is the node tree with a type per node — the example's last test shows it.
Static assets & client JS§
The default is a static mount with production behavior (ETag/304, Range, gzip) plus your cache policy and SPA support:
static "/assets" from "./static" cache "1h" -- Cache-Control per mount ("immutable" for fingerprinted)
static "/app" from "./dist" fallback "index.html" -- SPA history-fallback (try_files)
Each mount serves a folder, and serving it is reading it — the file is chosen by a remote client — so each one needs require file.read("<folder>/") (engine v0.6.45+): require file.read("./static/") and require file.read("./dist/") for the two lines above. Without it the server does not start: Capability not granted: static "/assets" from "static" serves the files of that folder — … add require file.read("static/"). A folder that travels inside a synsema build binary is part of the program and needs nothing — and it is served only from the bundle: a file that is not in the bundle never falls back to the disk.
The client is unrestricted — vanilla JS, htmx, or any framework. Classic <form method="post"> posts arrive as form of request (urlencoded and multipart, file uploads as exact bytes) — no fetch/JSON required. Dynamic responses (render/html/content/JSON) gzip automatically.
Gotcha — declared routes beat static mounts. If your app has wildcard routes (e.g. GET /:lang/:slug), a static mount is never reached, so you serve the assets from a declared route instead. Serve text (css/js/svg) with respond, and binary (images, fonts) with read_file_bytes + binary — plain read_file decodes UTF-8 and would corrupt a PNG:
route "GET /assets/*path"
let p be "static/" + params.path
when not file_exists(p)
give not_found("asset not found")
when ends_with(p, ".png")
give binary(read_file_bytes(p), "image/png") -- byte-exact
give respond(read_file(p), "text/css; charset=utf-8")
This very docs site is built exactly this way: its wildcard routes (/:lang/:version/:slug) force a declared /assets/*path route, and the Open Graph preview image is served with binary(read_file_bytes(...), "image/png").
Error pages and routes-in-modules§
errors with <task>on the serve block shapes 401/404/405/500 — HTML pages for browsers, the JSON default for agents,redirect("/login")for a 401. The error status is always preserved (no soft-404s). See HTTP server.- A site's routes can live in modules:
export routes shopinshop.syn, thenmount shop.shop(optionallyat "/store") in the serve block — bodies call the module's private helpers directly. See Modules.
Suggested project structure§
app.syn serve block: mounts, static, errors with
shop.syn a module with `export routes`
layouts/ base.html (chrome with { slot } / { slot "head_extra" })
partials/ nav.html, card.html (components; card takes props via `with`)
pages/ home.html, … (use a layout + { fill })
static/ app.css, app.js, img/
Charts§
Server-side SVG charts and agent-readable chart() content nodes are built in — see CSV, stats & charts.