Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Themes

A theme is a set of templates stored in the database and edited from the admin. Register the module and its tables migrate with the application’s:

use lateritecmf::prelude::*;
use lateritecmf::web::theme::ThemeModule;

Bootstrap::new("config")
    .module(ThemeModule)
    .module(migrations::AppModule)
    .serve()
    .await

Create a theme from its YAML config, add templates, and make it the site’s theme:

use lateritecmf::web::theme::{self, store, Kind};

let site = store::create_theme(&db, "acme", "name: Acme\nauthor: Acme\n").await?;
store::save_template(&db, site.id, Kind::Layout, "default.htm", None, LAYOUT, None).await?;
store::save_template(
    &db,
    site.id,
    Kind::Page,
    "home.htm",
    Some(serde_json::json!({ "url": "/", "layout": "default" })),
    HOME,
    Some(operator_id),
)
.await?;
theme::set_active_theme(&db, "acme").await?;

The screen

ThemeModule registers the Themes tab at /admin/themes under the permission web.manage_themes, granted to the Administrator role. The tab lists the themes with their state, creates, duplicates, locks, activates and deletes them, and opens a theme to its templates by kind, its config and a template’s editor with the earlier versions to restore.

SettingDefaultMeaning
backend.paths."laterite.theme/themes"/themesWhere the tab mounts under the admin, as a deployment override.

A module’s own screen audits through the route context:

ctx.audit(&user, "acme.thing.save", Some("thing"), Some(&id), Some(&label), None).await;

The config

name: Acme
author: Acme
homepage: https://acme.example
description: The Acme site.
parent: base
require:
  - acme.blog
form:
  fields:
    site_name:
      label: Site name
      default: Acme
KeyTypeDefaultMeaning
namestring""The theme’s display name.
authorstring""Who made it.
homepagestring""The author’s site.
descriptionstring""One line about the theme.
previewstringnoneA URL for the image on the theme list; the initials stand in without one.
parentstringnoneThe theme whose templates fill in for the ones this theme lacks.
requirelist of module ids[]The modules the theme’s templates need.
formform descriptornoneThe theme’s own settings fields (see Descriptor Files).

A key outside this table is refused. ThemeConfig::parse reads the YAML and ThemeConfig::to_yaml writes it.

The preview image

name: Acme
preview: https://acme.test/themes/acme/preview.png
PropertyStandard
Size1200 × 800 pixels. Minimum 600 × 400.
Aspect3:2, exactly.
FormatPNG or JPEG. WebP is accepted.
WeightUnder 300 KB.
SubjectThe home page as the theme renders it, cropped from the top of the viewport at 1200 pixels wide.
FramingNo browser chrome, device frame, drop shadow, border or rounded corners: the image edges are the page edges.
OverlaysNo added text, badges, logos or colour swatches.
ContentRepresentative content and real images; no placeholder blocks.
Colour schemeLight, unless the theme is dark only.
TransparencyNone: an opaque image.
Shown at184 pixels wide on the theme list, so headings and large shapes must still read at that size.

A theme without preview shows its initials on the theme list.

Templates

KindStored asHolds
Kind::LayoutlayoutThe frame a page renders inside.
Kind::PagepageA page served at its URL.
Kind::PartialpartialA fragment a layout or page includes.
Kind::ContentcontentA block of HTML, Markdown or text.
Kind::AssetassetA text file the site serves (a stylesheet, a script).

A path is /-separated segments of lowercase letters, digits, dots, hyphens and underscores, at most 255 characters. settings is a JSON object; None keeps the current one.

// A theme's own templates, or those of one kind.
let all = store::list_templates(&db, site.id, None).await?;
let pages = store::list_templates(&db, site.id, Some(Kind::Page)).await?;

// Through the parent chain: the theme's own row, else the nearest ancestor's.
if let Some((from, template)) = store::resolve_template(&db, "child", Kind::Layout, "default.htm").await? {
    println!("{} from {}", template.path, from.code);
}

store::delete_template(&db, template.id).await?;

Pages

A page’s settings name its URL pattern and its layout; the save refuses a pattern that does not parse or a layout the theme and its ancestors lack.

{
  "url": "/blog/:slug",
  "layout": "default",
  "title": "A post",
  "description": "",
  "meta_title": "",
  "meta_description": "",
  "meta_image": "",
  "meta_type": "",
  "meta_robots": "",
  "hidden": false
}
KeyDefaultMeaning
urlrequiredThe URL pattern.
layoutnoneThe layout by name (default for layouts/default.htm); the page then fills its page block, and a block the page defines at its top level fills the layout’s block of that name without rendering in place. A page that writes {% extends %} itself is left as written.
title, description""The page’s own words, read as this.page.title.
meta_title, meta_description, meta_image, meta_type, meta_robots""The head’s meta fields.
hiddenfalseServed only to an operator previewing the theme.
PatternMatches
/blog/blog, in any case
/blog/:slugone segment, bound to slug
/blog/:slug?the segment or nothing
/blog/:page?1the segment, or 1 when left out
`/blog/:id^[0-9]+$`
/docs/:path*one or more segments, joined with /
/docs/:path?*none or more segments

An optional parameter before a required one is required. One wildcard at most, with no expression and nothing optional after it. The most specific page wins: more static segments, then fewer wildcards, then fewer parameters.

{% block title %}{{ this.page.title }}{% endblock %}
<a href="{{ page('blog/post', slug=post.slug) }}">{{ post.title }}</a>
NameGives
page(<name>, param=value, ...)The URL of the page named by its path without the extension; a parameter left out takes its default.
{% block page %} in a layoutWhere the page renders.
let engine = Engine::build(&db, "acme").await?;
if let Some((name, params)) = engine.resolve("/blog/hello") {
    let settings = engine.page(name);
    let url = engine.url_for(name, &params);
}
for (name, url) in engine.routes().fixed() {
    println!("{name} at {url}");
}

router::Pattern::parse, Pattern::matches, Pattern::url and router::RouteTable are the engine beneath, usable on their own.

The editor shows a page’s settings as fields (URL, a layout picker over the theme and its ancestors, title, description, the meta fields, hidden) and keeps the JSON box for every other kind; the theme page lists each page’s URL beside its path.

The site

ThemeModule serves the active theme’s pages at every URL the admin and the other public routes leave. A page renders with this and head:

<html>
<head>{{ head }}</head>
<body>
  <h1>{{ this.page.title }}</h1>
  <p>{{ this.params.slug }} on {{ this.url }} in {{ this.theme.name }}</p>
  {% if this.preview %}<p>Previewing.</p>{% endif %}
  {% block page %}{% endblock %}
</body>
</html>
NameGives
this.pageThe page’s settings and name.
this.paramsThe URL’s parameters.
this.themecode and name.
this.layoutThe layout the page named.
this.urlThe request path.
this.previewWhether an operator is previewing.
headThe title, description, canonical, Open Graph and robots tags from the page’s settings; a setting may carry an expression.
Page atServes
/404An unmatched URL, with status 404.
/errorA page that failed to render, with status 500.
URLServes
/themes/<code>/assets/<path>The theme’s text asset, rendered, with an ETag and public, no-cache.

An operator signed in to the admin with web.manage_themes sees their edit theme and its hidden pages, with private, no-store; a visitor sees the active theme. With a site registered, the admin’s session cookie is scoped to / so the site can tell the two apart; without one it stays scoped to the admin mount. The site is registered through PublicRouteReg::site, so a module of your own can take its place:

registry.add_public_route(PublicRouteReg::site(Arc::new(MySite)));
let operator = ctx.operator(&headers).await;     // the session cookie's operator, if any
ctx.record_unserved("GET", "/missing", 404).await; // into the request log

Revisions

Every save keeps what it replaced:

let revisions = store::revisions(&db, template.id).await?; // newest first
let restored = store::restore_revision(&db, revisions[0].id, Some(operator_id)).await?;

Themes

let all = store::list_themes(&db).await?;
let one = store::find_theme(&db, "acme").await?;
store::update_theme_config(&db, "acme", "name: Acme\nparent: base\n").await?;
store::set_locked(&db, "acme", true).await?;
let copy = store::duplicate_theme(&db, "acme", "acme-copy").await?;
let child = store::create_child_theme(&db, "acme", "acme-child", "Acme child").await?;
store::delete_theme(&db, "acme-copy").await?;
CallRefused when
create_themeThe code is taken, is not lowercase letters, digits and hyphens, or the parent is missing or leads back to the theme.
update_theme_configThe theme is locked, or the parent is missing or leads back.
save_template, delete_templateThe theme is locked, or the path is not a template path.
delete_themeThe theme is active, locked, or another theme’s parent.

Settings

A theme declares its own settings under form in the config; the theme page shows them as a form, and templates read the values beside the theme’s keys:

name: Acme
form:
  fields:
    site_name:
      label: Site name
      type: text
    tagline:
      label: Tagline
      type: textarea
<h1>{{ theme.site_name }}</h1>
<p>{{ theme.tagline }}</p>

A child theme without a form uses its parent’s; the values are each theme’s own. A field named code, name, author, homepage or description is refused.

let chain = store::chain(&db, "acme").await?;
let fields = store::form_fields(&chain);
store::save_settings(&db, "acme", &serde_json::json!({ "site_name": "Acme" })).await?;
let values = store::find_theme(&db, "acme").await?.unwrap().settings;

A module’s own screen renders and collects such a form through the route context:

let form = ctx.value_form(&fields, "acme")?;
let markup = form.render(&values, &shell);
let saved = form.collect(&data)?;

Rendering

use lateritecmf::web::theme::{Engine, EngineCache, Kind};

let engine = Engine::build(&db, "acme").await?;
let html = engine.render(Kind::Page, "home.htm", serde_json::json!({ "title": "Home" }))?;
let css = engine.render(Kind::Asset, "css/site.css", ())?;

// One engine per theme, rebuilt when the theme or an ancestor changes.
let cache = EngineCache::new();
let engine = cache.get(&db, "acme").await?;
cache.invalidate("acme");

// The application's own functions, filters and globals.
let engine = Engine::build_with(&db, "acme", |env| {
    env.add_function("year", || 2026);
})
.await?;

Templates are named by the kind’s folder and the path:

KindName
Kind::Layoutlayouts/default.htm
Kind::Pagepages/home.htm
Kind::Partialpartials/nav.htm
Kind::Assetassets/css/site.css

A theme’s own template is used, else the nearest ancestor’s. In a template:

{% extends "layouts/default.htm" %}
{% block body %}
  {% include "partials/nav.htm" %}
  {{ partial("card.htm", title="Acme", body=page.body) }}
  {{ content("intro.md") }}
  <h1>{{ theme.name }}</h1>
{% endblock %}
NameGives
{% extends "layouts/<path>" %}, {% block %}A page inside a layout.
{% include "partials/<path>" %}A partial with the current variables.
partial(<path>, name=value, ...)A partial with the named variables, the request’s this and head, and the globals.
content(<path>, name=value, ...)A content block: .md rendered to HTML, .htm and .html as they are, anything else escaped as text; {name} in the block is the named variable, escaped.
asset(<path>)The URL of the theme’s text asset: /themes/<code>/assets/<path>.
url(<path>)The absolute URL of a path on the site, from the deployment’s origin.
media(<file>), media(<file>, preset="card")The URL of a media value (a URL, or an object with url as theme::media_value builds), under a preset when one is named.
<url>|variant("card")A media URL rewritten to the named preset.
themecode, name, author, homepage, description of the theme rendered.

HTML is escaped in every template but an asset. A printed undefined value is an error; {% if page.sub %} on an undefined value is not. A render spends at most five million instructions.

use lateritecmf::web::theme::{check, RenderError};

match check(source) {
    Ok(()) => {}
    Err(RenderError { line, message, .. }) => println!("line {line:?}: {message}"),
}

RenderError carries template, line, kind and message.

Components

A module ships a component; a page or layout attaches it in its settings under components, keyed by the alias the template reads it as:

use lateritecmf::web::theme::component::{
    Component, ComponentContext, ComponentOutput, ComponentReg, PropertyDef, PropertyKind,
};
use lateritecmf::web::theme::ThemeError;
use std::sync::Arc;

struct Posts;

#[lateritecmf::core::strata::async_trait]
impl Component for Posts {
    fn properties(&self) -> Vec<PropertyDef> {
        vec![
            PropertyDef::new("limit", "How many", PropertyKind::Number).default(5),
            PropertyDef::new("slug", "Slug", PropertyKind::Text),
        ]
    }
    async fn run(&self, cx: &ComponentContext<'_>) -> Result<ComponentOutput, ThemeError> {
        let limit = cx.properties["limit"].as_u64().unwrap_or(5);
        let items = load_posts(cx.db, limit).await?;
        Ok(ComponentOutput::data(serde_json::json!({ "items": items }))
            .inject("site_name", "Acme"))
    }
}

fn register(&self, registry: &mut Registry) {
    registry.add(ComponentReg::new("acme.posts", "Posts", Arc::new(Posts)));
}
{
  "url": "/blog/:slug",
  "layout": "default",
  "components": {
    "post": { "component": "acme.posts", "limit": 1, "slug": "{{ :slug }}" },
    "latest": { "component": "acme.posts" }
  }
}
<h1>{{ post.items[0].title }}</h1>
{% for item in latest.items %}<li>{{ item.title }}</li>{% endfor %}
<footer>{{ site_name }}</footer>
NameGives
properties()The properties an attachment may set, with kind, required and default; the save refuses an unknown component, an undeclared property, a wrong kind or a missing required one.
run()ComponentOutput::data, read under the alias, and .inject(name, value) for the page scope.
cx.propertiesThe attached values, {{ :name }} resolved from the URL and defaults filled.
cx.db, cx.params, cx.path, cx.theme, cx.previewThe request.

A layout’s components run first, then the page’s; a page alias replaces a layout’s. build::build_with takes the registered components for a static build.

A component may ship markup; a theme overrides it per alias:

fn markup(&self) -> Option<&'static str> {
    Some("<ul>{% for item in component.items %}<li>{{ item.title }}</li>{% endfor %}</ul>")
}
{{ component('latest') }}
NameGives
component(<alias>)The theme’s partials/<alias>/default.htm, else the component’s own markup, with the data as component and under the alias, beside this and head.

The page editor’s Components box takes the components object as JSON and lists the registered components with their properties.

Markup a module adds

use lateritecmf::web::theme::markup::{Markup, MarkupReg};
use minijinja::Environment;
use std::sync::Arc;

struct Money;

impl Markup for Money {
    fn install(&self, env: &mut Environment<'static>) {
        env.add_filter("money", |minor: i64| format!("{}.{:02}", minor / 100, minor % 100));
        env.add_function("year", || 2026);
    }
}

fn register(&self, registry: &mut Registry) {
    registry.add(MarkupReg::new("acme.money", Arc::new(Money)));
}
{{ price|money }} in {{ year() }}

Every registration is installed into each engine the site builds, in order. lat theme build renders without an application’s modules, so a template that needs their markup is built from the application with build::build_with.

Maintenance mode

Settings › Website › Maintenance mode, under web.manage_site:

FieldMeaning
Show visitors the maintenance pageOn, a visitor gets status 503 with Retry-After: 600.
MessageThe plain notice’s text, when the theme has no page at /maintenance.
Page atServes
/maintenanceEvery visitor’s request while the mode is on.

An operator with web.manage_themes sees the site as usual.

use lateritecmf::web::theme::maintenance::MaintenanceSettings;

let settings: MaintenanceSettings = lateritecmf::admin::settings::load(&db).await?;

Building a static site

lat theme build acme
lat theme build acme --out public --base-url https://acme.example

Every page whose URL has no parameter is written at its URL (/about to about/index.html), the /404 page also as 404.html, and the text assets under themes/<code>/assets/, through the same render path the site serves. A page with parameters or a hidden page is skipped and named.

FlagDefaultMeaning
--outdistThe output directory.
--base-urlthe application’s app.url, else one derived from server.listenThe origin url() and the canonical link use.
use lateritecmf::web::theme::build;

let report = build::build(&db, "acme", Path::new("dist"), "https://acme.example").await?;
println!("{} pages, {} assets, {} skipped", report.pages.len(), report.assets.len(), report.skipped.len());

Export and import

lat theme list
lat theme export acme
lat theme export acme --out themes/acme.zip
lat theme import acme themes/acme.zip
lat theme import acme themes/acme.zip --replace

The theme page has Export as zip and Import a zip; the list has Import a theme from a zip, which creates the theme from the archive’s theme.yaml.

acme.zip
├── theme.yaml
├── settings.json         # the theme's settings values, when it has any
├── layouts/default.htm
├── pages/home.htm
├── partials/nav.htm
├── content/intro.md
└── assets/css/site.css

A template with settings carries them as YAML above a == line:

url: /
layout: default
==
{% extends "layouts/default.htm" %}
Flag or fieldMeaning
--replace, Replace templatesReplace the templates and the config the theme already has; each replaced template keeps a revision. Without it, an existing template is skipped.
codeThe theme to import into; created from theme.yaml when it does not exist.
use lateritecmf::web::theme::archive;

let bytes = archive::export(&db, "acme").await?;
let report = archive::import(&db, "acme-copy", &bytes, false, Some(operator_id)).await?;
println!("{} written, {} skipped", report.imported, report.skipped);

ImportReport carries created, imported, skipped and ignored (files that are not templates).

The active theme and the edit theme

theme::set_active_theme(&db, "acme").await?;
let live = theme::active_theme(&db).await?;

theme::set_edit_theme(&db, operator_id, Some("acme-next")).await?;
let editing = theme::edit_theme(&db, operator_id).await?; // the operator's choice, else the active theme
theme::set_edit_theme(&db, operator_id, None).await?;

ThemeSettings (code laterite.theme) holds the active theme; EditTheme (code laterite.theme.edit) is each operator’s preference.