Layout System

Overview

Both reepolee and reeweb use the same TemplateEngine (inspired by Eta.js and Svelte) that 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 Stacks

Reepolee renders application templates from its apps/main/ directory. ReeWeb renders its static site from src/public/. They use the same engine, but each project owns its own template root.

Server-side route templates - views: ./apps/main/

Configured in lib/template.ts:

new TemplateEngine({
    views: join(project_root, "apps/main"),      // → ./apps/main/
    shared_views: join(project_root, "platform"), // → ./platform/ (notfound + auth)
    project_root,
});
apps/main/
  layout.ree              ← App shell: nav sidebar, auth, toasts, language switcher
  home/index.ree          → render("home") → {#layout("layout")}
  users/index.ree         → {#layout("layout")}   (generated CRUD)
  users/form.ree          → {#layout("layout")}

platform/
  notfound.ree            ← Standalone 404 (FULL HTML, no {#layout})
  auth/login/form.ree     → {#layout("layout")}   (shared across every app)

All route templates reference {#layout("layout")}. A bare layout name resolves co-location-first - a layout next to the page wins, then the views root - so the default here is apps/main/layout.ree (see Layout resolution: co-location first).

reeweb - single layout stack

reeweb only has one viewsDir (./src/public/):

src/public/
  layout.ree
  plain.layout.ree
  academic.layout.ree     → {#layout('layout')} → src/public/layout.ree
  index.ree               → {#layout("layout")}
  ...

Same co-location rule: a layout can live in any subfolder and is found before the views-root fallback.


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.reelayout.ree

academic.layout.ree first wraps page content in its <article> element. Its own {#layout('layout')} then passes that article as props.body to layout.ree, which adds the document shell, header, main region, and footer.

At runtime:

  1. Page template renders → produces <h1>Content</h1>
  2. {#layout('layout')} triggers → layout.ree renders, receives page HTML as props.body
  3. layout.ree wraps it in <html>/<header>/<main>/<footer>
  4. Final output: full page

If another template nests academic.layout.reelayout.ree:

  1. Page renders → produces content
  2. academic.layout.ree wraps it in <article class="paper"> + captures as props.body
  3. academic.layout.ree's {#layout('layout')} triggers → layout.ree renders
  4. layout.ree wraps academic layout output in <html>/<header>/<footer>
  5. Final output

Path Resolution

The resolve_include() and resolve_layout() functions (in lib/template/include_resolver.ts) handle all path types:

SyntaxResolves toExample
{#layout("layout")}Co-located first, else {viewsDir}/layout.reepages/layout.reeapps/main/layout.ree
{#layout("pages/home")}{viewsDir}/pages/home.reeapps/main/pages/home.ree
{#layout("./partial")}{dirname(current)}/partial.reeRelative to current template
{#layout("../shared/header")}{parent(current)}/shared/header.reeParent traversal
{#layout("/components/card")}{viewsDir}/components/card.reeAbsolute from views root
{#include("./data.json")}Raw file loaded as-is (unescaped)Non-.ree extension
<card-header>slot</card-header>components/card-header.reeAuto-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 pages/home/index.ree)Tries in order
{#layout("layout")}pages/home/layout.reelayout.ree
{#layout("wallpaper.layout")}pages/home/wallpaper.layout.reewallpaper.layout.ree
{#layout("./wallpaper.layout")}pages/home/wallpaper.layout.ree (page directory only)
{#layout("../shared/header")}pages/shared/header.ree (page directory only)

Alias paths ($ prefix)

Resolve relative to the project root (one level up from viewsDir):

AliasMaps to
$components/card./components/card.ree
$routes/examples./apps/main/examples.ree
$lib/helpers./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 via engine.render)
  • Other extensions (.json, .html, .svg) → raw file, injected as text (unescaped)
  • No extension → treated as .ree template

Path Security

Path traversal is blocked during rendering: resolved paths must stay within the base directory (views root for views-relative, project root for alias paths), or an error is thrown.


Fallback Chains

Locale variant fallback

Every template load goes through this chain (filenames are lowercase):

{name}.{requested_locale}.ree  →  {name}.{default_locale}.ree  →  {name}.ree

Example: with locale=de-de and default_locale=sl-si:

  1. Try about/index.de-de.ree → not found
  2. Try about/index.sl-si.ree → found ✓ (or)
  3. Try about/index.ree → found ✓ (fallback)

This applies to every template load - pages, layouts, includes, and components.

render.ts route-directory fallback (server-side only)

In reepolee/lib/render.ts, when rendering a route template:

// First try: route_dir-prefixed path (from ctx.route_dir)
resolved_template = ctx.route_dir + "/" + clean_name;

// On "Template not found" error:
resolved_template = clean_name; // fall back to views root

Example: route handler calls render("layout", ...) with ctx.route_dir = "home":

  1. Tries apps/main/home/layout.ree → not found
  2. Falls back to apps/main/layout.ree → found ✓

Markdown layout fallback (static build)

For .md files rendered during static build:

const raw_layout = frontmatter.layout || "layout";
const base_layout = raw_layout.replace(".ree", "").replace(".layout", "");
const candidates = [`${base_layout}.layout`, base_layout];
  1. Frontmatter layout: academic → tries academic.layout.reeacademic.ree
  2. No frontmatter layout → default "layout" → tries layout.layout.reelayout.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 captureNo - rendered inlineYes - captures output as props.body
Use casePartials, components, raw filesPage structure wrapping
HTML escapingNever escaped (trusted HTML output)Body unescaped via {~ }
NestingAny depthAny depth
Raw filesYes - .html, .svg, .jsonNo - must be .ree template

Component Auto-Discovery

Templates can use kebab-case HTML elements (<card-header>, <toasts-area>). The pre-processor:

  1. Checks if components/{tag-name}.ree exists in the project root
  2. 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
  3. 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 as props.children. An attribute written as attr="{= expr }" is interpolated - the engine evaluates expr where the tag sits, so the component receives the real value rather than the literal string.


Layout File Convention

FilePurposeViews DirUsed by
apps/main/layout.reeApp shell (nav sidebar, auth, toasts, lang switcher)./apps/main/Server-side routes
components/*.reeReusable ReeTag componentsProject rootRoute templates

The root layout.ree is the shared default, not a restriction. A layout can sit in any folder next to the pages that use it and is found before the views-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 ReeWeb's markdown static-build pipeline (scripts/ssg/render_markdown.ts, driven by frontmatter layout:) - .ree templates that call {#layout('path')} directly go through the plain path resolution in Path Resolution above, with no .layout suffix magic.


reeweb vs reepolee

Aspectreewebreepolee
Layout stacks1 (./src/public/)1 (./apps/main/)
Layout filesrc/public/layout.reeapps/main/layout.ree (app shell)
Nesting
Locale fallback✅ 3-level chain✅ 3-level chain
Relative paths./, .././, ../, $ aliases
Co-located layouts✅ any folder✅ any folder
render.ts fallbackN/A✅ route_dir + bare name fallback
Raw file includes✅ with extension✅ with extension
Components✅ auto-discovery✅ auto-discovery