---
slug: 41-frontend
title: Frontend
description: HTML del lado del servidor en Synsema — templates render() con bloques verbatim de CSS/JS, componentes con props, slots nombrados, datos seguros para <script>, content() para agentes y estáticos con política de cache.
example_ids: [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](41a-build-a-website).

```synsema
-- 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.html")
require file.read("_doctest_page.html")
require file.write("_doctest_base.html")
require file.read("_doctest_base.html")

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.
```

## `render()` — templates libres

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

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

- `{ name }` / `{ expr }` interpolan (HTML-escapeado; las expresiones llaman builtins y tus propias tasks — definí las tasks **antes** del bloque `serve`); `{ raw expr }` lo desactiva para HTML confiable.
- **`{ raw }` … `{ end }` es un bloque VERBATIM** — CSS/JS inline con llaves literales: `<style>{ raw } .card { color: red; } { end }</style>`.
- `{ each x in xs } … { otherwise } … { end }` — loop con **rama de lista vacía** opcional; índices con `enumerate(xs)` (`{ e.index }` / `{ e.item }`). Una no-lista es error fuerte.
- `{ when c } … { otherwise when c2 } … { otherwise } … { end }` — encadenado, como en el lenguaje.
- **Componé:** `{ include "partials/nav.html" }` (scope actual) o `{ include "partials/card.html" with {"title": t} }` (**un componente con props aislados**); `{ layout "layouts/base.html" }` con `{ slot }`, más **slots nombrados** — `{ slot "head_extra" }` en el layout, `{ fill "head_extra" }…{ end }` en la página.
- `{ -- comentario }` no emite nada. Los paths de template (incluidos `include`/`layout`) se resuelven contra el directorio de trabajo.
- **Datos en un `<script>` inline:** `{ raw json_for_script(x) }` — jamás `json_encode` ahí (un valor con `</script>` rompería el tag).

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 el `render(path)` de nivel superior necesita `require file.read("<path>")` (o
`require file("templates/*")` para un árbol), igual que `read_file` — esto cierra la lectura de
archivos arbitrarios a través de un path derivado del request. Los `{ include }`/`{ layout }` anidados
no necesitan su propia declaración (son estáticos y están confinados al directorio de trabajo), y un
template **horneado en un binario `synsema build`** no necesita capability alguna (es parte del
programa). Una sola línea cubre un árbol de templates: `require file.read("templates/*")`.

**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)](41b-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.

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

## 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:

```synsema
static "/assets" from "./static" cache "1h"          -- Cache-Control por mount ("immutable" para fingerprinteados)
static "/app" from "./dist" fallback "index.html"    -- history-fallback de SPA (try_files)
```

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:

```synsema
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-exacto
    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

- **`errors with <task>`** en el bloque serve da forma a 401/404/405/500 — páginas HTML para navegadores, el JSON default para agentes, `redirect("/login")` para un 401. El status del error siempre se conserva (nada de soft-404). Mirá [Servidor HTTP](40-serve).
- Las rutas de un sitio pueden vivir en **módulos**: `export routes shop` en `shop.syn`, y `mount shop.shop` (opcionalmente `at "/store"`) en el serve — los cuerpos llaman helpers privados del módulo directo. Mirá [Módulos](14-modules).

## 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](37-dataviz).
