Layout System
Overview
ReeWeb's TemplateEngine (inspired by Eta.js and Svelte) supports:
- Layout wrapping via
{#layout('path')} - Partial includes via
{#include('path')} - Custom HTML element → component auto-discovery
- Locale-variant template fallback
- Relative, absolute, and alias path resolution
A layout is a .ree template that wraps a page's rendered content. The page declares its layout as the first line of the template, and the layout receives the page's HTML in {~ props.body }.
How Layouts Are Declared
{#layout("layout")}
<h1>{_ ui.title }</h1>
Everything after {#layout(...)} becomes the body content. The layout template renders first, calling {~ props.body } where the page content should appear.
Layouts can also receive extra data:
{#layout("layout", { title: "Custom" })}
The second argument merges into the layout's data: Object.assign({}, data, extraData, { body: body }).
Layout Stack
ReeWeb has a single viewsDir (./src/public/). All layouts and templates resolve within it. Layouts can live in any folder: a bare name resolves co-location-first (next to the page), then falls back to the views root.
src/public/
layout.ree ← Default shell: header, nav, footer (root fallback)
plain.layout.ree ← Minimal layout (no sidebar/nav)
academic.layout.ree → {#layout('layout')} (NESTED - wraps inside layout.ree)
index.ree → {#layout("layout")}
about/index.ree → {#layout("wallpaper.layout")}
about/wallpaper.layout.ree ← Co-located layout, found before the root fallback
blog/post.ree → {#layout("layout")}
Layout Nesting
Layouts nest arbitrarily via recursive __layout at compile time.
How it works at the code generation level
When the compiler encounters {#layout('path')}, it wraps the generated code:
// Normal template compilation produces __output
const __body = __output;
const __layoutData = Object.assign({}, props, extraData, { body: __body });
__output = await __layout("path", __layoutData);
This means the layout template is rendered with the captured body as props.body, and the layout's output becomes the final output (which itself could trigger another layout).
Real example: academic.layout.ree → layout.ree
academic.layout.ree src/public/layout.ree
┌─────────────────────────────┐ ┌───────────────────────────────────────────┐
│ {#layout('layout')} │ ──→ │ <html> │
│ │ │ <body> │
│ <article class="paper"> │ │ <header>...</header> │
│ {~ props.body } │ │ <main>{~ props.body }</main> │
│ </article> │ │ <footer>...</footer> │
└──────────────┬──────────────┘ └──────────────────┬────────────────────────┘
│ │
Rendered body passed as props.body Receives academic layout's HTML
to layout.ree via {#layout('layout')} as props.body, wraps in full page
At runtime:
- Page template renders → produces
<h1>Content</h1> {#layout('layout')}triggers →layout.reerenders, receives page HTML asprops.bodylayout.reewraps it in<html>/<header>/<main>/<footer>- Final output: full page
If another template nests academic.layout.ree → layout.ree:
- Page renders → produces content
academic.layout.reewraps it in<article class="paper">+ captures asprops.bodyacademic.layout.ree's{#layout('layout')}triggers →layout.reerenderslayout.reewraps academic layout output in<html>/<header>/<footer>- Final output
Path Resolution
The resolve_include() and resolve_layout() functions (in lib/template/include_resolver.ts) handle all path types:
| Syntax | Resolves to | Example |
|---|---|---|
{#layout("layout")} | Co-located first, else {viewsDir}/layout.ree | about/layout.ree → src/public/layout.ree |
{#layout("pages/home")} | {viewsDir}/pages/home.ree | src/public/pages/home.ree |
{#layout("./partial")} | {dirname(current)}/partial.ree | Relative to current template |
{#layout("../shared/header")} | {parent(current)}/shared/header.ree | Parent traversal |
{#layout("/components/card")} | {viewsDir}/components/card.ree | Absolute from views root |
{#include("./data.json")} | Raw file loaded as-is (unescaped) | Non-.ree extension |
<card-header>slot</card-header> | components/card-header.ree | Auto-component |
Layout resolution: co-location first
Layouts resolve differently from includes. A bare {#layout('name')} looks for the layout next to the page first, then falls back to the views root - which is what lets a layout live in any folder. Layout names are never extension-sniffed: the dotted convention name wallpaper.layout stays intact rather than being read as a file with a .layout extension.
Call (from about/index.ree) | Tries in order |
|---|---|
{#layout("layout")} | about/layout.ree → layout.ree |
{#layout("wallpaper.layout")} | about/wallpaper.layout.ree → wallpaper.layout.ree |
{#layout("./wallpaper.layout")} | about/wallpaper.layout.ree (page directory only) |
{#layout("../shared/header")} | shared/header.ree (page directory only) |
Alias paths ($ prefix)
Resolve relative to the parent of viewsDir - src/ in a standard project,
since viewsDir is src/public/:
| Alias | Maps to |
|---|---|
$components/card | src/components/card.ree |
$lib/helpers | src/lib/helpers.ree |
Aliases apply to {#include} and component auto-discovery, not to {#layout} - layouts resolve within the views directory only (co-location first, then root).
Extension rules
.ree→ compiled template (extension stripped, rendered viaengine.render)- Other extensions (
.json,.html,.svg) → raw file, injected as text (unescaped) - No extension → treated as
.reetemplate
Path Security
Raw-file includes (an explicit non-.ree extension) must stay within their base
directory, or Include path escapes base directory is thrown at compile time.
Template includes are not sandboxed - resolving outside the views root is
allowed, because the $ aliases rely on it. Include paths are trusted,
author-controlled input; never derive one from user input. See
Layouts & Includes.
Fallback Chains
Locale variant fallback
Every template load goes through this chain:
{name}.{requested_locale}.ree → {name}.{default_locale}.ree → {name}.ree
Example: with locale=de-de and default_locale=sl-si:
- Try
about/index.de-de.ree→ not found - Try
about/index.sl-si.ree→ found ✓ (or) - Try
about/index.ree→ found ✓ (fallback)
This applies to every template load - pages, layouts, includes, and components.
Markdown layout fallback (SSG script)
For .md files rendered by the SSG script:
const raw_layout = frontmatter.layout || "layout";
const base_layout = raw_layout.replace(".ree", "").replace(".layout", "");
const candidates = [`${base_layout}.layout`, base_layout];
- Frontmatter
layout: academic→ triesacademic.layout.ree→academic.ree - No frontmatter
layout→ default"layout"→ trieslayout.layout.ree→layout.ree
Layout data merge
Object.assign({}, props, extraData, { body: body });
The layout receives all page data, plus any extra data from {#layout('path', extraData)}, with body always taking precedence (last write wins).
{#include} vs {#layout}
{#include('path')} | {#layout('path')} | |
|---|---|---|
| Body capture | No - rendered inline | Yes - captures output as props.body |
| Use case | Partials, components, raw files | Page structure wrapping |
| HTML escaping | Never escaped (trusted HTML output) | Body unescaped via {~ } |
| Nesting | Any depth | Any depth |
| Raw files | Yes - .html, .svg, .json | No - must be .ree template |
Component Auto-Discovery
Templates can use kebab-case HTML elements (<card-header>, <site-nav>). The pre-processor:
- Checks if
src/components/{tag-name}.reeexists in the project root - If found → emits an internal marker that the compiler resolves into a component include -
<card-header attr="val">slot</card-header>becomes an__rtInclude("$components/card-header", { children: ..., attributes: { attr: "val" } })call - If not found → passes through as a native HTML element, with slot content compiled inline
Slot content is compiled and rendered at runtime. HTML attributes on the tag are passed as props.attributes inside the component, and the slot is passed as props.children. Attribute values written as attr="{= expr }" are interpolated - the engine evaluates expr where the tag sits, so the component receives the real value rather than the literal string.
Layout File Convention
| File | Purpose | Used by |
|---|---|---|
src/public/layout.ree | Default shell (header, nav, footer) | Most pages |
src/public/academic.layout.ree | Academic paper layout (nests inside layout.ree) | Markdown docs |
src/public/plain.layout.ree | Minimal layout | Simple pages |
These root-level files are the shared defaults, not a restriction. A layout can sit in any folder next to the pages that use it and is picked up before the root fallback (see Layout resolution: co-location first).
Naming convention: Layouts end in .layout.ree or can be plain layout.ree. The .layout suffix distinguishes named layouts (e.g., academic.layout.ree) from page templates. This {name}.layout.ree → {name}.ree candidate order is specific to the SSG script's markdown frontmatter resolution (layout: academic in a .md file's frontmatter) - .ree templates that call {#layout('path')} directly go through the plain path resolution in Path Resolution above, with no .layout suffix magic.