HTML views
Keep page markup in HTML files with @portablesheep/brevity-views. It uses Eta for templates, layouts, and partials.
Set up the renderer
Install @portablesheep/brevity, @portablesheep/brevity-web, and @portablesheep/brevity-views using local packages while npm publication is pending. Configure the template directory once:
import { fileURLToPath } from "node:url";
import { createFormState } from "@portablesheep/brevity-web";
import { createViews } from "@portablesheep/brevity-views";
export const views = createViews({
root: fileURLToPath(new URL("./views", import.meta.url)),
layout: "layout",
});
export const emptyForm = () => createFormState({ labels: { text: "Note" } });Then call await views.render(res, name, data) from a route:
import { createApp, HttpError } from "@portablesheep/brevity";
import { createFormState, type FormState, type HtmlFragment } from "@portablesheep/brevity-web";
import { views, emptyForm } from "./page.js";
import type { NoteStore } from "./store.js";
export function createNotebook(store: NoteStore, enhance?: (form: FormState) => HtmlFragment) {
const app = createApp({ maxBodyBytes: 4096 });
app.use(async (req, res, next) => {
res.header("referrer-policy", "same-origin");
// Native same-origin forms send Origin. Reject cross-site writes before mutation.
if (req.method === "POST" && req.headers.origin !== `http://${req.headers.host}`) {
throw new HttpError(403, "Submit the form from this site");
}
return next(req, res);
});
app.router.get("/", async (_req, res) => {
const form = emptyForm();
await views.render(res, "notes", {
notes: await store.list(),
form,
enhancement: enhance?.(form),
});
});
app.router.head("/", (_req, res) => res.send(""));
app.router.post("/notes", async (req, res) => {
if (!req.form || req.form.getAll("text").length !== 1) {
throw new HttpError(400, "Send exactly one note using the HTML form");
}
const text = req.form.get("text") ?? "";
if (!text.trim() || text.length > 200) {
const form = createFormState({
values: { text },
labels: { text: "Note" },
errors: { text: ["Write between 1 and 200 characters."] },
});
await views.render(
res,
"notes",
{
notes: await store.list(),
form,
enhancement: enhance?.(form),
},
{ status: 422 },
);
return;
}
await store.add(text.trim());
res.redirect("/", 303);
});
return app;
}Use renderToString() when you need a fragment instead of a response.
Layouts and partials
A layout wraps the page using it.body:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><title>Notebook</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><main><h1>Notebook</h1><%= it.body %></main></body>
</html>Include a partial with await it.partial(name, locals?). It inherits the page's data and can receive its own values:
<%= await it.partial("form") %>
<ul><% for (const note of it.notes) { %><li><%= note.text %></li><% } %></ul>
<%= it.enhancement %>Pass { layout: false } to a render call to skip the layout. Use it.partial rather than Eta's filesystem include so all templates use the same path checks.
Values and helpers
<%= expression %> escapes text. <% ... %> runs template JavaScript. Brevity fragments, such as partials and form errors, retain their markup:
<% const field = it.form.field("text", { describedBy: "text-hint" }); %>
<%= it.form.errorSummary() %>
<form method="post" action="/notes">
<label for="<%= field.id %>">Note</label>
<p id="text-hint">Write between 1 and 200 characters.</p>
<textarea id="<%= field.id %>" name="<%= field.name %>"
aria-invalid="<%= field.invalid %>" aria-describedby="<%= field.describedBy %>"
maxlength="200"><%= field.value %></textarea>
<%= it.form.fieldErrors("text") %>
<button type="submit">Save note</button>
</form>Raw markup
Eta's <%~ ... %> outputs raw HTML. Don't use it for user input. Templates can execute JavaScript, so only trusted application code should create or edit them.
createFormState is available as it.createFormState. Add your own helpers through createViews({ helpers }); they're available on it too. Page data can override a helper with the same name.
Files and caching
Template names are relative to root: notes, notes.html, or shared/field. Don't take a template name straight from a request. Absolute paths, dot segments, and symlinks outside the root are rejected.
Production caches compiled templates; development reloads them on each render. Deploy views/ beside your server output: TypeScript doesn't copy HTML files. Restart or call clearCache() after changing cached templates.
Options and custom engines
createViews({ root, extension: ".html", layout, cache, engine, helpers }) returns render, renderToString, and clearCache. Render options accept status and layout. Cache defaults to NODE_ENV === "production"; failed compilations aren't cached. Cyclic or overly deep partials fail explicitly.
For another engine, implement ViewEngine.compile(source, filename), returning (data: ViewData) => string | Promise<string>. Keep escaping and trusted fragments safe in that implementation. Eta 3.5.0 is the only built-in engine and the package's one external runtime dependency.