Skip to content
academia.sh

Lesson 14 / 16

Server-Side Templating

Building the view from data on the server: the template engine's parse and render steps, escaping as the default, raw output requested by an explicit marker, the difference the attribute context makes, and separating the layout template from the page template.

Contents

The previous lesson served files sitting on disk. The catalog page was one of those files, but it did not carry its own content: the document went out as an empty container, and the book list was built in the browser by a script. Since the server can already read the book records, it can write that list into the document itself.

The mechanism that performs this merge is called a template: text with the places where a value will go marked out. The program that merges a template with data is called a template engine. This lesson centers on a single question: what happens when a piece of text coming from data is placed inside markup?

The Template’s Two Steps

The engine does its work in two steps. First the template text is parsed: plain-text fragments are separated from placeholders, and block tags build a nested structure. Then this structure is walked against the data and rendered.

The two steps being separate is a requirement, not a stylistic choice. Parsing looks at the template and never at the data; rendering looks at the data and never reads the template text again. This lets the same template be parsed once and rendered a hundred thousand times, and more importantly, the content of the data can never alter the template’s structure under any circumstance. A piece of text coming from data can look exactly like a block tag, but it is never interpreted as one during rendering, because parsing has already finished.

The engine below recognizes four tags: {{ name }} writes the value with escaping, {{! name }} writes it raw, {{# name }} through {{/ name }} iterates over a list, and {{^ name }} through {{/ name }} runs when the value is empty.

// template.mjs — a small template engine that makes escaping the default
const ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
export const escape = (value) => String(value).replace(/[&<>"']/g, (c) => ESCAPES[c]);

// {{ name }} escapes, {{! name }} does not, {{# name }}..{{/ name }} iterates,
// {{^ name }}..{{/ name }} runs when the value is empty.
const TAG = /\{\{\s*([#^/!]?)\s*([\w.]+)\s*\}\}/g;

const resolve = (stack, name) => {
  if (name === ".") return stack[stack.length - 1];
  const parts = name.split(".");
  for (let i = stack.length - 1; i >= 0; i--) {
    let value = stack[i];
    if (value === null || typeof value !== "object") continue;
    if (!(parts[0] in value)) continue;
    for (const p of parts) value = value?.[p];
    return value;
  }
  return undefined;
};

// Converts the template to a tree in a single pass: string fragments and block nodes.
const parse = (text) => {
  const root = { children: [] };
  const stack = [root];
  let last = 0;
  for (const m of text.matchAll(TAG)) {
    const [whole, kind, name] = m;
    const parent = stack[stack.length - 1];
    if (m.index > last) parent.children.push(text.slice(last, m.index));
    last = m.index + whole.length;
    if (kind === "#" || kind === "^") {
      const node = { kind, name, children: [] };
      parent.children.push(node);
      stack.push(node);
    } else if (kind === "/") {
      if (stack.length === 1 || stack[stack.length - 1].name !== name)
        throw new Error(`unclosed block: ${name}`);
      stack.pop();
    } else {
      parent.children.push({ kind, name });
    }
  }
  if (stack.length !== 1) throw new Error(`unclosed block: ${stack[stack.length - 1].name}`);
  root.children.push(text.slice(last));
  return root;
};

const render = (node, stack) =>
  node.children.map((c) => {
    if (typeof c === "string") return c;
    const value = resolve(stack, c.name);
    if (c.kind === "#") {
      const list = Array.isArray(value) ? value : value ? [value] : [];
      return list.map((item) => render(c, [...stack, item])).join("");
    }
    if (c.kind === "^") {
      const empty = Array.isArray(value) ? value.length === 0 : !value;
      return empty ? render(c, stack) : "";
    }
    if (value === undefined || value === null) return "";
    return c.kind === "!" ? String(value) : escape(value);
  }).join("");

export const build = (text, data) => render(parse(text), [data]);

Name resolution runs over a scope stack: entering a block pushes that iteration’s item onto the stack, a name is looked up in the innermost scope first, and the search moves outward when it is not found there. This is what lets code inside an iteration block still reach fields from the outer scope.

Escaping Is the Default

Escaping converts characters that carry structural meaning in the markup language into their character entity form. When this conversion happens, text coming from data stays text in the output; when it does not, that text becomes part of the markup.

Escaping is the default in the engine, and raw output is obtained only by writing {{! name }} — an explicit decision made by whoever writes the template. The order could have been reversed; what matters is what happens when one of them is forgotten. If escaping is the default, forgetting the raw marker costs a visible oddity. If raw output is the default, forgetting to escape costs data-borne text bleeding into the document’s structure.

The block below feeds the same data to two placeholders at once and shows the difference. The marked-up text is harmless: a highlight element.

// compare.mjs — compares escaped and unescaped rendering with the same data
import { build } from "./template.mjs";

const TEMPLATE = `<ul class="catalog">
{{# books }}  <li>{{ title }} — <span class="author">{{ author }}</span></li>
{{/ books }}{{^ books }}  <li class="empty">No results</li>
{{/ books }}</ul>
<p class="note">{{ note }}</p>
<p class="note-raw">{{! note }}</p>`;

const DATA = {
  books: [
    { title: "In Search of Lost Time", author: "Marcel Proust" },
    { title: "Snow & Ice <archive>", author: "Branch 3" },
  ],
  note: "<mark>reserved</mark>",
};

const output = build(TEMPLATE, DATA);
console.log(output);
console.log("---");
console.log("output bytes:", Buffer.byteLength(output));
console.log("mark element count:", (output.match(/<mark>/g) ?? []).length);
console.log("--- empty list ---");
console.log(build(TEMPLATE, { books: [], note: "none" }));
<ul class="catalog">
  <li>In Search of Lost Time — <span class="author">Marcel Proust</span></li>
  <li>Snow &amp; Ice &lt;archive&gt; — <span class="author">Branch 3</span></li>
</ul>
<p class="note">&lt;mark&gt;reserved&lt;/mark&gt;</p>
<p class="note-raw"><mark>reserved</mark></p>
---
output bytes: 289
mark element count: 1
--- empty list ---
<ul class="catalog">
  <li class="empty">No results</li>
</ul>
<p class="note">none</p>
<p class="note-raw">none</p>

The same data produced two different results. In the escaped placeholder, the marked-up text is text meant to be read on screen; in the raw placeholder, it is an element inserted into the document. The count confirms this: the output has exactly one highlight element, and it came from the raw placeholder.

The & and the angle brackets in the book title were escaped as well. This is a correctness matter before it is a security one: without escaping, part of the title would not have appeared on screen at all, because the markup parser would have read it as the start of a tag.

Text Context and Attribute Context

A single escaping function does not know where in the document a value lands. In a value placed inside text, angle brackets matter; in a value placed inside an attribute, the quote character matters. The function above converts both, so it produces a correct result in either context, but why that is necessary still needs to be shown.

// attribute.mjs — escaping's effect on an attribute value
import { build } from "./template.mjs";

const ESCAPED = `<a class="branch" title="{{ name }}" href="/branch/{{ code }}">Branch</a>`;
const RAW = `<a class="branch" title="{{! name }}" href="/branch/{{ code }}">Branch</a>`;
const DATA = { name: 'Central "Old Books" Hall', code: "s3" };

// The parser ends the attribute value at the first closing quote; the pattern below
// applies the same rule and shows where the value gets cut.
const titleValue = (text) => text.match(/title="([^"]*)"/)[1];

for (const [name, template] of [["escaped", ESCAPED], ["raw", RAW]]) {
  const output = build(template, DATA);
  console.log(name);
  console.log("  output:", output);
  console.log("  title :", JSON.stringify(titleValue(output)));
}
escaped
  output: <a class="branch" title="Central &quot;Old Books&quot; Hall" href="/branch/s3">Branch</a>
  title : "Central &quot;Old Books&quot; Hall"
raw
  output: <a class="branch" title="Central "Old Books" Hall" href="/branch/s3">Branch</a>
  title : "Central "

In the raw output, the attribute value ends at “Central “: the quote in the branch name closed the attribute. The remaining words are no longer part of the value; they become something else in the tag’s body. In the escaped output, by contrast, the value stays whole, and the parser reads the character entities back into the full branch name.

The rule that follows is this: attribute values are always placed in quotes, and a value placed in a template is stripped of characters that could close that quote. Unquoted attribute syntax is never used in this scheme, because it turns even characters the escaping function does not convert — whitespace, for instance — into structural ones.

Layout and Page

Every page’s document skeleton is the same: the same language declaration, the same head element, the same body frame. Instead of copying this shared part into every template, a layout template is written once, and the page’s own output is placed inside it. That placement uses the single raw placeholder inside the layout — because what goes there is not data, it is output that has already been escaped once.

The data structure the template sees is called the view model. The view model is the contract between the data source and the template: the template does not know where the records came from, and the code that reads the records does not know the markup.

// view.mjs — builds and serves the catalog page on the server (same directory as template.mjs)
import { createServer } from "node:http";
import { build } from "./template.mjs";

const BOOKS = [
  { title: "In Search of Lost Time", author: "Marcel Proust", branch: "Central" },
  { title: "The Disconnected", author: "Oğuz Atay", branch: "Branch 3" },
  { title: "Snow & Ice <archive>", author: "Compilation", branch: "Branch 3" },
];

const LAYOUT = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>{{ title }}</title></head>
<body><h1>{{ title }}</h1>{{! content }}</body></html>
`;

const CATALOG = `<p class="summary">Records: {{ count }}</p>
<ul class="catalog">
{{# books }}  <li>{{ title }} — <span class="author">{{ author }}</span> ({{ branch }})</li>
{{/ books }}{{^ books }}  <li class="empty">No results</li>
{{/ books }}</ul>`;

// View model: the single data structure the template sees. The query and the
// records are merged here; the template itself never knows the data source.
const viewModel = (query) => {
  const q = (query ?? "").toLocaleLowerCase("en-US");
  const books = q ? BOOKS.filter((b) => b.title.toLocaleLowerCase("en-US").includes(q)) : BOOKS;
  return { books, count: books.length };
};

createServer((request, response) => {
  response.sendDate = false;
  const address = new URL(request.url, "http://local");
  if (address.pathname !== "/catalog") return response.writeHead(404).end();

  const content = build(CATALOG, viewModel(address.searchParams.get("q")));
  const document = build(LAYOUT, { title: "Library Catalog", content });

  response.setHeader("Content-Type", "text/html; charset=utf-8");
  response.setHeader("Content-Length", Buffer.byteLength(document));
  response.writeHead(200).end(document);
}).listen(8311, "127.0.0.1", () => console.log("listening: 127.0.0.1:8311"));
#!/usr/bin/env bash
# Starts view.mjs, shows the generated document and the filtered result, then stops it.
node view.mjs > /dev/null &
server=$!
sleep 1
A=http://127.0.0.1:8311

echo "--- full catalog ---"
curl -sS "$A/catalog"
echo "--- filter: q=snow ---"
curl -sS "$A/catalog?q=snow"
echo "--- non-matching query ---"
curl -sS "$A/catalog?q=zzz" | grep -c 'class="empty"'

kill "$server"
--- full catalog ---
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Library Catalog</title></head>
<body><h1>Library Catalog</h1><p class="summary">Records: 3</p>
<ul class="catalog">
  <li>In Search of Lost Time — <span class="author">Marcel Proust</span> (Central)</li>
  <li>The Disconnected — <span class="author">Oğuz Atay</span> (Branch 3)</li>
  <li>Snow &amp; Ice &lt;archive&gt; — <span class="author">Compilation</span> (Branch 3)</li>
</ul></body></html>
--- filter: q=snow ---
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Library Catalog</title></head>
<body><h1>Library Catalog</h1><p class="summary">Records: 1</p>
<ul class="catalog">
  <li>Snow &amp; Ice &lt;archive&gt; — <span class="author">Compilation</span> (Branch 3)</li>
</ul></body></html>
--- non-matching query ---
1

The empty-result branch was tested as well: for the non-matching query, the number of lines containing class="empty" is one. This confirms that the empty list was handled in the template; if it had not been, the output would have kept a bodyless list item.

Port 8311 is arbitrary and must be free.

The Load a Template Cannot Carry

A template is a view tool, and it breaks down as its load grows. Three limits are decisive in practice.

Computation does not happen in the template. The day count on an overdue loan, the fine amount, or a sort criterion is computed in the view model. If a template computes it instead, the same rule starts producing two different results on two pages and becomes untestable.

Data access does not happen in the template. If a query could be run from inside an iteration block, a page would spawn as many queries as it has rows. The view model brings in everything that is needed before rendering starts.

Raw output must stay countable. The raw placeholder is the one point in the system that needs auditing. As its count shrinks and it stays confined to places where only already-rendered output is inserted, the template layer’s correctness stays something the eye can verify.

Summary

  • The template engine first parses the template, then renders it against the data; because the data’s content arrives after parsing has finished, it can never alter the template’s structure.
  • Escaping converts characters with structural meaning in markup into character entities and should be the default; raw output is requested by the template author’s explicit decision.
  • The measurement separates two contexts: in the text context an unescaped value inserts an element into the document, while in the attribute context it cuts the value off at the first quote.
  • The layout template carries the shared document skeleton; the page output is placed into it through a single raw placeholder, because what is placed there is not data but output that has already been rendered.
  • The view model is the contract between the template and the data source; computation and data access happen in the code that builds the view model, not in the template.

Next Step

Up to this point, data has always come from the inside: files on disk, records in memory. In the library service’s real operation, data also comes from the outside — a member’s photo, the cover image of a donated book, a count file uploaded from a branch. As this content arrives at the server, its size is not known in advance, its declared type may not match its actual content, and where it gets written is a decision the server makes. The next lesson takes up uploading with these three questions: when and how does the server reject a payload that exceeds its limit, how does it decide the file’s type without looking at the extension, and why is the written file kept separate from the served directory?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close