Synsemadocsv0.6.xENES

Frontend

Frontend

Synsema sirve HTML desde el servidor (SSR) — sin framework ni CSS impuestos. Dos caminos: render() (control total del diseño) y content() (negociable por agentes). Para el recorrido completo (layout, estilos, formularios, páginas de error), mirá Construí un sitio web.

frontend.syn
-- 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 &lt;b&gt; 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, &quot;a&quot;]]")
    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> &lt;b&gt;")
    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() — templates libres§

render("page.html", data) devuelve una respuesta HTML; body of render(...) es el string. Los templates son HTML con huecos { ... }:

route "GET /"
    give render("pages/home.html", {"title": "My App", "items": items})

Los templates se cachean parseados y se recargan en caliente por request (editar → refrescar, sin reiniciar); los paths de render("literal.html") se validan al arranque y con synsema check. Para el .syn, synsema serve app.syn --watch reinicia al cambiar.

Capability (engine v0.6.14+): renderizar un template leído desde disco es una lectura de archivo, así que render(path) necesita require file.read("<path>"), igual que read_file — esto cierra la lectura de archivos arbitrarios a través de un path derivado del request. Desde el engine v0.6.45 vale lo mismo para cada { include } y { layout } leído desde disco: cada archivo que una página trae necesita file.read también (antes, un programa que podía escribir templates podía incluir .env en una página sin declarar que lo lee). Si falta uno, es un 500 Capability not granted: file_read("partials/nav.html") que nombra el archivo. Una sola línea cubre un árbol de templates, a cualquier profundidad: require file.read("./templates/") ( cruza carpetas; templates/ serían solo los archivos de adentro directo). Con pages/, layouts/ y partials/ una al lado de la otra, declará cada una (require file.read("./pages/"), y así). Un template horneado en un binario synsema build* no necesita capability alguna (es parte del programa).

Instalable en teléfonos y escritorio (engine v0.6.15+): el mismo sitio renderizado se vuelve una app — ícono en la pantalla de inicio, pantalla completa, shell offline, notificaciones push — con un manifest, un service worker y las líneas de <head> que synsema init --pwa escribe por vos. Mirá Tu app en el teléfono (PWA).

content() — páginas negociables por agentes§

Construís el árbol semántico una vez; el runtime sirve HTML a humanos y Markdown/JSON a agentes (por header Accept o sufijo .md/.json) — ideal para docs/blogs que un LLM deba leer.

route "GET /docs/:slug"
    give content(page([heading(1, "Title"), prose("…"), code("let x be 1", "synsema")], {
        "title": "Title", "description": "…", "stylesheet": "/assets/app.css"
    }))

Los nodos: heading(level, text), prose(text), list(items), ordered_list(items), link(text, href), image(src, alt), code(text, lang?), section(nodes) y raw(html) (HTML confiable, sin escapar — todo lo demás se escapa). page(nodes, meta?) acepta estas claves de meta:

ClaveHTMLMarkdown / JSON
title, description<title>, <meta name="description"> y un bloque JSON-LD WebPageel JSON las lleva en meta
stylesheetun <link rel="stylesheet"> en el <head>solo en el meta del JSON
classla clase del <main> que envuelve (default prose)solo en el meta del JSON
header, footerHTML crudo antes y después del <main> — tu nav y tu footer (body of render("partials/nav.html", {}) reusa el mismo partial)no van al Markdown; solo en el meta del JSON

Solo give content(...) se negocia: html()/render() siguen siendo HTML, un mapa sigue siendo JSON. La forma .json es el árbol de nodos con un type por nodo — el último test del ejemplo lo muestra.

Assets estáticos y JS de cliente§

El default es un mount estático con comportamiento de producción (ETag/304, Range, gzip) más tu política de cache y soporte SPA:

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)

Cada mount sirve una carpeta, y servirla es leerla — el archivo lo elige un cliente remoto — así que cada uno necesita require file.read("<carpeta>/") (engine v0.6.45+): require file.read("./static/") y require file.read("./dist/") para las dos líneas de arriba. Sin eso el servidor no arranca: Capability not granted: static "/assets" from "static" serves the files of that folder — … add require file.read("static/"). Una carpeta que viaja dentro de un binario synsema build es parte del programa y no necesita nada — y se sirve solo desde el bundle: un archivo que no está en el bundle nunca cae al disco.

El cliente no tiene restricciones — vanilla JS, htmx o el framework que quieras. Un <form method="post"> clásico llega como form of request (urlencoded y multipart, archivos como bytes exactos) — sin fetch ni JSON. Las respuestas dinámicas (render/html/content/JSON) se comprimen con gzip automáticamente.

Gotcha — las rutas declaradas le ganan a los mounts static. Si tu app tiene rutas wildcard (p.ej. GET /:lang/:slug), un mount static nunca se alcanza, así que servís los assets desde una ruta declarada. Serví texto (css/js/svg) con respond, y binario (imágenes, fuentes) con read_file_bytes + binary — un read_file plano decodifica UTF-8 y corrompería un 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")

Este mismo sitio de docs está construido exactamente así: sus rutas wildcard (/:lang/:version/:slug) fuerzan una ruta declarada /assets/*path, y la imagen de preview Open Graph se sirve con binary(read_file_bytes(...), "image/png").

Páginas de error y rutas en módulos§

Estructura de proyecto sugerida§

app.syn          bloque serve: mounts, static, errors with
shop.syn         un módulo con `export routes`
layouts/         base.html (chrome con { slot } / { slot "head_extra" })
partials/        nav.html, card.html (componentes; card recibe props vía `with`)
pages/           home.html, … (usan un layout + { fill })
static/          app.css, app.js, img/

Charts§

Los charts SVG server-side y los nodos chart() legibles por agentes vienen incluidos — mirá CSV, stats & charts.