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

Laterite

Laterite is a content management framework for Rust: a descriptor-driven admin panel, authentication and permissions, namespaced migrations and typed settings, on Axum and sqlx, for Postgres, MySQL or SQLite.

cargo install laterite-cli
lat new
CrateProvides
laterite-coreConfig, errors, the database layer, migrations, modules.
laterite-authBackend users, sessions, roles and permissions.
laterite-adminThe admin router, list and form screens, settings.
laterite-mediaContent-addressed file storage. Optional.
laterite-webStatic-site generation and page metadata. Optional.
laterite-cliThe lat command.
laterite-macrosThe t!, tn! and tp! translation macros.
SectionCovers
Getting StartedInstalling, configuring and running an application.
Extending LateriteEach capability: what to write and what can be set.
ReferenceThe icon set and the generated API documentation.

Installation

lat new scaffolds a project, sets up the database and creates the first administrator.

Install the CLI

cargo install laterite-cli

Requires stable Rust and one of PostgreSQL, MySQL/MariaDB or SQLite.

Create an application

lat new
cd acme
cargo run          # or: lat serve

Open http://127.0.0.1:8080/admin and sign in.

lat new asks for:

PromptValue
Application nameAny text, such as Acme Blog. Its slug, acme-blog, names the crate, directory and database.
Display timezoneAn IANA name; type to search.
Listen addresshost:port. Default 127.0.0.1:8080.
DatabasePostgreSQL, MySQL/MariaDB or SQLite, and its connection details. Offers to create the database.
First administratorUsername, email, password.

Serve

lat serve
lat serve --port 3000
lat serve --host 0.0.0.0
lat serve --listen 0.0.0.0:3000

Runs from anywhere inside the application. An override sets <PREFIX>__SERVER__LISTEN; see Configuration.

What it generates

acme/
├── Cargo.toml
├── README.md
├── .gitignore
├── config/
│   ├── default.toml     # committed defaults
│   └── local.toml       # git-ignored; the database URL
├── src/
│   ├── main.rs
│   └── migrations/      # the application's module and its migrations
└── storage/             # runtime data

src/main.rs:

[dependencies]
lateritecmf = { version = "0.9", features = ["postgres"] }
laterite-core = "0.9"
use lateritecmf::prelude::*;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    Bootstrap::new("config")
        .module(migrations::AppModule)
        // .extend(|router, ctx| router.merge(my_api(ctx.db())))
        .serve()
        .await
}

src/migrations/mod.rs defines the application’s module:

use lateritecmf::prelude::*;

impl Module for AppModule {
    fn id(&self) -> ModuleId {
        ModuleId::new(MODULE_ID)
    }
    fn migrations(&self) -> MigrationSet {
        migrations()
    }
    fn register(&self, registry: &mut Registry) {
        // registry.add_resource(..);
        // registry.add_permission(..);
        // registry.add_settings(..);
    }
    async fn boot(&self, cx: &BootCx<'_>) -> CoreResult<()> {
        // A sweep, a seed or a check, with cx.db and cx.registry.
        Ok(())
    }
}
MethodRunsDoes
migrationsBefore register, in dependency order.Applies the module’s own migrations.
registerOnce every module has migrated.Contributes resources, settings models, permissions, menu entries, hooks.
bootOnce every module has registered, before anything is served.Anything that needs the database and the whole registry. An error aborts the boot; a plugin’s error quarantines the plugin.

Bootstrap::extend merges the application’s own routes onto the admin router.

Set the brand

[app]
name = "Acme Blog"

Shown across the admin. Settings → Branding overrides it; with both blank the brand is Laterite.

Check the setup

lat doctor

Verifies the configuration, the timezone, write access to storage/, the database connection and the framework tables. Exits non-zero on any failure.

Manage administrators

lat admin create editor --email editor@acme.test --first-name Editor
lat admin reset-password editor

Both prompt for a password; --generate prints a strong one that must be changed at first sign-in. Outside the application, pass --database-url or set DATABASE_URL. A fresh install with no accounts serves a first-run setup screen at /admin.

Configuration

Configuration is layered: one build runs across environments without code changes. Per-install branding and per-operator preferences are edited in the admin, not here.

Layers

Later layers override earlier ones.

LayerSource
1default.toml. Required.
2<APP_ENV>.toml. Optional. APP_ENV defaults to development.
3local.toml. Optional; keep it out of version control.
4<PREFIX>__SECTION__KEY environment variables, e.g. LAT__DATABASE__URL. The prefix is LAT unless app.env_prefix names another.

Sections

[app]
name = "Acme Blog"               # baseline admin brand
env_prefix = "LAT"               # prefix of the overriding environment variables
# url = "https://acme.example"   # public base URL; derived from listen when unset

[server]
listen = "127.0.0.1:8080"

[database]
url = "postgres://localhost/acme_dev"
max_connections = 10             # optional
acquire_timeout_secs = 5         # optional

[backend]
secure_cookie = false            # true behind HTTPS
timezone = "UTC"                 # default display timezone, IANA name
locale = "en"                    # default admin language
path = "/admin"                  # where the admin mounts
trusted_proxies = []             # CIDR ranges whose X-Forwarded-For is believed
storage = "storage"              # where files the admin keeps are stored
secret = "a-long-random-string"  # signs timed URLs; generated and stored if unset

[backend.paths]
"rainmill.location" = "/places"          # a whole module's screens
"rainmill.location/nodes" = "/places"    # one screen; the more specific key wins

[auth]
session_idle_timeout_secs = 7200      # 2h
session_absolute_timeout_secs = 43200 # 12h
remember_duration_secs = 1209600      # 14d
max_failures = 5
max_failures_per_address = 20
failure_window_secs = 900

[auth.password_policy]
min_length = 8

Every [auth] and [backend] key is optional.

[backend]

KeyDescription
secure_cookieSets the Secure flag on cookies. true behind HTTPS.
timezoneDefault display timezone until an operator picks their own. See Dates and Timezones.
localeDefault admin UI language. Falls back to en.
pathURL path the admin mounts under.
trusted_proxiesCIDR ranges. X-Forwarded-For is read only from a peer inside one; the client is the rightmost address not itself a trusted proxy. Empty records the peer address.
pathsRemounts a module’s screens, or one screen, at another path. Two claims on one path abort the boot.
storageThe directory files the admin keeps are stored under, relative to the working directory unless absolute. Uploaded import files go to private beneath it.
secretThe key timed URLs are signed with (a private file’s). Unset, one is generated at the first boot and kept in the settings table. Set it when several instances share one database.

[auth]

KeyDescription
session_idle_timeout_secsQuiet time before a session ends, from the last request. Default 7200.
session_absolute_timeout_secsCeiling from login, never extended. Default 43200. The earlier name session_ttl_secs is still read.
remember_duration_secsHow long an unused “stay signed in” credential lasts, from its last use. Default 1209600.
max_failuresFailed sign-ins from one address for one username before that address is locked out of it. Default 5.
max_failures_per_addressFailed sign-ins from one address, any username, before the address is locked out. Default 20.
failure_window_secsWindow the failures are counted over. Default 900.
password_policy.min_lengthThe shortest password accepted, wherever one is set. Default 8.

Where a module’s screens mount

A module’s screens namespace under its identity: rainmill.location contributing /nodes serves /admin/rainmill/location/nodes. A module may declare a shorter base; backend.paths has the last word.

How lat finds the application

lat walks up from the current directory to the nearest config/default.toml and reads the application’s prefix from it. lat admin takes the database URL from --database-url, then DATABASE_URL, then database.url.

What is not configured here

SettingWhere
Branding: name, colour-mode default, logoSettings → Branding
Preferences: an operator’s own timezonePreferences. See Dates and Timezones.

Friendly Local Domains

lat domain routes every host under a reserved .test TLD to loopback. macOS, built on dnsmasq.

Set up

brew install dnsmasq
lat domain setup

setup writes a dnsmasq wildcard rule and /etc/resolver/test, prompting for sudo. It is idempotent.

FlagDescription
--dry-runPrint the plan and change nothing.
--tld <name>Use a different reserved TLD.

Open an application by name

lat serve --port 8080
# http://acme.test:8080/admin

Set app.url to match:

[app]
url = "http://acme.test:8080"

The port stays in the URL. HTTPS on the bare name is a reverse proxy’s job.

Status and teardown

lat domain status
lat domain teardown

teardown removes only what lat domain created. A .test managed by another tool, such as Laravel Valet, is detected: setup adds nothing and teardown leaves it alone.

Live Reload in Development

systemfd holds the port and watchexec rebuilds on change, so the browser reconnects after every edit.

Install

cargo install systemfd
brew install watchexec   # or cargo install watchexec-cli

Reuse the passed socket

Add listenfd and take the socket in main:

use listenfd::ListenFd;
use tokio::net::TcpListener;

let listener = match ListenFd::from_env().take_tcp_listener(0)? {
    Some(std_listener) => {
        std_listener.set_nonblocking(true)?;
        TcpListener::from_std(std_listener)?
    }
    None => TcpListener::bind("127.0.0.1:8080").await?,
};
axum::serve(listener, app).await?;

None is the normal path: cargo run and production bind directly.

Run the loop

systemfd --no-pid -s http::8080 -- \
    watchexec -r -e rs,html,css,toml -- \
    cargo run -p acme-api

Templates and the stylesheet are compiled in, so .html and .css are watched too. As a justfile recipe:

dev:
    systemfd --no-pid -s http::8080 -- \
        watchexec -r -e rs,html,css,toml -- \
        cargo run -p acme-api

See errors as you type

cargo install bacon   # or brew install bacon
bacon clippy

Dates and Timezones

Timestamps are stored in UTC and converted to a display timezone when rendered.

Display a timestamp

use laterite_admin::list::ListColumn;

ListColumn::new("created_at", "Created").datetime();
ListColumn::new("published_on", "Published").date();
ListColumn::new("is_active", "Active").yes_no();
KindRendered as
textThe stored value. Default.
datetime14 Aug 2026, 15:53, in the viewer’s timezone.
dateThe date, in the viewer’s timezone.
timeThe time, in the viewer’s timezone.
yes_noYes or No.

A value that does not parse is shown as stored.

Choose the timezone

Resolved per request:

  1. The operator’s own preference, set under Preferences.
  2. Otherwise backend.timezone, an IANA name such as Asia/Kolkata. Defaults to UTC.

“Use the deployment default” in Preferences clears the operator’s choice.

Installing Plugins

A plugin is a crate compiled into the application. Installing one records it and rebuilds.

StateWhereChanging it costs
Which plugins are compiled inplugins/plugins.tomla rebuild
Which of them are enabledthe databasea restart

Add one

$ lat plugin add ../rainmill-discovery
$ lat plugin add https://github.com/rainmill/discovery-plugin
$ cargo build

A checkout outside the project is linked into plugins/, not copied. --as <folder> picks the folder name.

The list

# plugins/plugins.toml
[[plugin]]
path = "rainmill-discovery"

[[plugin]]
path = "acme-blog"
source = "https://github.com/acme/blog-plugin"
CommandDescription
lat plugin addRecords a plugin and syncs.
lat plugin removeDrops the entry. --delete removes the folder; a link only loses its link.
lat plugin syncRegenerates plugins-manifest/ from the list. Run after editing by hand.
lat doctorChecks the list and the manifest agree.

Two entries resolving to one crate are refused.

Enable and disable

Settings → Plugins turns a compiled-in plugin off without a rebuild. The change applies at the next boot: a disabled plugin’s migrations do not run, it contributes nothing, and anything depending on it is skipped. Its tables stay.

Write one

Mark the crate, name its module, and declare the framework by version:

[package.metadata.laterite]
plugin = "acme.blog"

[dependencies]
laterite-core = "0.9"
laterite-admin = "0.9"

Expose the entry point at the crate root:

pub fn module() -> Box<dyn Module> {
    Box::new(MyModule)
}

The dependency requirement is the compatibility statement. lat plugin add refuses a plugin built for another Laterite:

$ lat plugin add ../acme-legacy
Error: acme-legacy needs laterite-core ^0.2, and this application is on 0.5.0.

Never declare the framework by path. An application on a local checkout maps it with [patch.crates-io], which lat new writes in development mode.

Third-Party Notices

A built binary carries the licence notices of everything compiled into it.

Distribute source or a crate

Nothing to do: licence files travel inside each package.

Distribute a binary

Generate a notice with cargo about and ship it beside the binary:

cargo install cargo-about
cargo about init          # writes about.toml and about.hbs
cargo about generate about.hbs > NOTICE.html

It reads every dependency’s NOTICE, LICENSE and COPYING, Laterite’s bundled fonts and icons included.

What Laterite bundles

WorkLicence
IBM Plex Sans, IBM Plex MonoSIL Open Font License 1.1
Space GroteskSIL Open Font License 1.1
Lucide, a subsetISC, with an MIT annex for icons derived from Feather
htmxZero-Clause BSD

Full texts: crates/admin/NOTICE.

Settings Models

A settings model is a struct an operator edits from the admin panel.

Define one

use laterite_admin::settings::SettingsModel;
use serde::{Deserialize, Serialize};

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct SiteSettings {
    #[serde(default)]
    pub title: String,
    #[serde(default)]
    pub tagline: String,
    #[serde(default)]
    pub maintenance_mode: bool,
}

impl SettingsModel for SiteSettings {
    const CODE: &'static str = "acme.site";
}

Derive Serialize, Deserialize and Default, and give every field #[serde(default)]. CODE is the storage key: namespace it to your application and never change it.

Read and write

let settings: SiteSettings = laterite_admin::settings::load(&db).await?;

let settings = SiteSettings { title: "Acme".into(), ..Default::default() };
laterite_admin::settings::save(&db, &settings).await?;

load returns Default when nothing is saved. save upserts the whole struct. By code, untyped:

let value = laterite_admin::settings::get(&db, SiteSettings::CODE).await?;
laterite_admin::settings::set(&db, "acme.site", &value.unwrap_or_default()).await?;

Edit it in the admin

Register a SettingsItem from your module’s register:

use laterite_admin::form::FormField;
use laterite_admin::settings::SettingsItem;

registry.add_settings(
    SettingsItem::new("acme.site", "Site", vec![
        FormField::text("title", "Site title"),
        FormField::text("tagline", "Tagline"),
        FormField::switch("maintenance_mode", "Maintenance mode"),
    ])
    .description("Public site title, tagline, and a maintenance switch.")
    .category("General")
    .order(10)
    .icon("sliders-horizontal")
    .permission("acme.manage_site"),
);

One screen lists every item by category; one form edits each.

BuilderDescription
new(code, label, fields)The model’s CODE, the menu label, and its fields.
description(text)Under the label in the settings index. At most 72 characters.
hint(text)Above the form.
category(text)Groups items in the index.
order(n)Sort within the category.
icon(name)An icon. An unknown name stops the boot.
permission(code)Hides the item from operators without the grant.
link(path)Places an existing screen in the settings menu, with no form.
confirm_leave(false)Leaving the screen with changes unsaved does not ask.

Fields are ordinary FormFields: text, textarea, switch, select, date, repeater, and any registered type through FormField::of(name, label, "vendor.type"). A type that refuses its input refuses the save.

A type’s keys go through options; a dependency through its builder:

FormField::textarea("notice", "Notice").options(serde_json::json!({ "rows": 4, "grow": false }))
FormField::text("slug", "Slug").preset("title", PresetShape::Slug)
FormField::date("send_at", "Send date").trigger(Trigger::new("show", "is_delayed", "checked"))

Keep a value per operator

use laterite_admin::settings::{load_for, save_for, PreferencesModel, SettingsItem};

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Desk {
    #[serde(default)]
    pub greeting: String,
    #[serde(default)]
    pub compact: bool,
}

impl PreferencesModel for Desk {
    const CODE: &'static str = "acme.desk";
}

registry.add_settings(
    SettingsItem::new("acme.desk", "My desk", vec![
        FormField::text("greeting", "Greeting"),
        FormField::switch("compact", "Compact rows"),
    ])
    .description("How the desk greets you.")
    .per_operator(),
);

let desk: Desk = load_for(&db, user.user.id).await?;
save_for(&db, user.user.id, &Desk { compact: true, ..desk }).await?;
ItemDoes
PreferencesModelThe same shape as SettingsModel; CODE is the storage key, kept with each operator’s preferences.
.per_operator()Stores the item once per operator and lists it on their Preferences page, under More, at /preferences/<code>. The permission still applies; the category is not used.
load_for, save_forOne operator’s model, or the default when they have none; a write for one operator.
get_for, set_forThe same, as raw JSON.
AuditA deployment setting’s save is audited; an operator’s own preference is not.
registry.add_settings(
    SettingsItem::new("acme.pages", "Pages", Vec::new())
        .description("Manage site pages.")
        .category("Content")
        .icon("folder")
        .link("/pages"),
);

The path is under the admin mount. Any request at or under it renders the settings sidebar with the item active. The built-in Users and Roles register this way.

Change a model later

ChangeMigration
Add a fieldNone. A missing key deserializes to the field’s default.
Remove a fieldNone. A stale key is ignored.
Rename a fieldRead the old key, write the new one. Never reuse a CODE for an incompatible model.

Permissions

A permission is a dotted string such as posts.approve. A descriptor declares the one a screen requires; the framework checks it against the operator’s roles.

Register permissions

use laterite_admin::{Permission, ROLE_EDITOR};
use laterite_core::t;

registry.add_permission(
    Permission::new("acme.publish_pages", t!("Publish pages"), t!("Content"))
        .roles([ROLE_EDITOR]),
);

Registered permissions appear in the role editor under their group. Only a registered permission can be granted.

Rename a permission

Permission::new("acme.posts", t!("Manage posts"), t!("Content"))
    .renamed_from(["acme.manage.posts"])

At the next boot every role and per-user override holding the old code is rewritten to the new one. Two permissions claiming one old code, or an old code still registered, abort the boot.

Built-in roles

Every deployment starts with two roles the framework owns and rewrites at each boot from the registry.

RoleHolds
ROLE_ADMIN, AdministratorEvery permission that names no role, plus any naming it. The first operator holds it.
ROLE_EDITOR, EditorOnly the permissions that name it. Empty until your application registers some.

Permission::roles names the roles that hold a permission by default. Naming none means the administrator alone, so a permission you forget to place never reaches editors. Neither role is editable: duplicate one to make it yours.

Gate a screen

use laterite_admin::Resource;

registry.add_resource(
    Resource::new("/pages", "Pages", pages_list_config())
        .form(pages_form_config())
        .permission("acme.manage_pages"),
);
WhereEffect
Resource::permissionEvery route the resource mounts: 403 without the grant, the login screen when signed out. Unset leaves it open to any operator.
SettingsItem::permissionHides the item from the settings menu and refuses its form.
ScreenReg::new(path, permission, ..)Required. Enforced before the handler runs.
ToolbarButton::requireThe button is not rendered without the grant.

How a grant matches

GrantCovers
posts.approveExactly posts.approve.
posts.*posts.approve, posts.tags.create; not the bare posts.
*Everything.

A superuser passes every check.

Resolve for one operator

  1. A superuser: allowed.
  2. The operator’s own override for that permission: Deny refuses, Allow grants.
  3. Otherwise the roles decide.

Roles and overrides are both set per user on the Administrators screen; Inherit is the default. An operator can change only permissions they hold themselves, only roles granting no more than they hold, and never their own roles.

Built-in permissions

PermissionGrants
backend.manage_usersThe backend users list and per-user overrides.
backend.manage_rolesThe roles list and the role editor.
backend.manage_pluginsThe plugins screen.
backend.view_audit_logThe audit log.
backend.view_access_logThe access log.

Operators

An operator is a person who signs in to the admin. Accounts are managed under Settings → Users → Administrators, gated by backend.manage_users.

From the panel

ActionWhereEffect
CreateNew on the listUsername, email, name, roles. A temporary password is generated and shown once; the account must replace it at first sign-in.
Assign rolesThe edit screenOnly roles granting no more than you hold; never your own.
Override a permissionThe edit screenAllow or Deny one permission regardless of roles.
DeactivateThe edit screenSigns the account out everywhere and refuses sign-in; reversible.
Reset passwordThe edit screenA new temporary password shown once; every device signed out. Never your own account, never one holding more than you.
UnlockThe edit screenClears a sign-in lockout. Shown only while locked.

Every action is on the audit log, named after the account it touched.

From the command line

lat admin create editor --email editor@acme.test --first-name Editor --generate
lat admin reset-password editor --generate
lat admin list

--generate prints a strong password and marks it temporary; a typed or prompted password is the operator’s own.

From code

use laterite_auth::NewOperator;
use laterite_core::Actor;

let id = auth
    .create_operator(
        NewOperator {
            username: "editor",
            email: "editor@acme.test",
            first_name: "Editor",
            last_name: None,
            password: &laterite_auth::password::generate(),
            timezone: None,
        },
        &Actor::user(admin.id, &admin.username),
    )
    .await?;
auth.require_password_change(id).await?;

let temporary = auth.reset_operator_password(id, &actor).await?;
auth.unlock("editor").await?;

create_superuser makes a superuser the same way; first-run setup and lat admin create use it.

Validation

A form field carries rules. A failed submission re-renders the form with a message under each field and writes nothing.

Declare rules

use laterite_admin::form::FormField;

let fields = vec![
    FormField::text("email", "Email").required().email().unique(),
    FormField::text("name", "Name").required().max_length(120),
];
BuilderRule
.required()Non-empty in every mode.
.required_on(Mode::Create)Non-empty on create, or on update, only.
.min_length(n) / .max_length(n)Length bounds. min is skipped when empty.
.email()A valid email address.
.unique()Not already used in the column. Ignores the edited row on update.

The generic create and edit handlers validate before writing.

Call the engine

use laterite_core::validation::{validate, FieldRules, Mode, Rule};

let rules = vec![FieldRules::new("email", "Email", vec![Rule::Required, Rule::Email])];
let bag = validate(&db, "users", "id", &rules, &data, Mode::Create, None).await?;
if bag.is_empty() {
    // persist
}

validate_fields runs the rules without the database; validate adds the unique probe. The result is an ErrorBag; empty means valid.

What a submission answers

OutcomeResponse
Refused422, the form re-rendered with its values. An htmx submit swaps the form; a plain one gets the whole page.
SavedA flash, then the list: HX-Redirect for htmx, 303 otherwise.
API422 with { "field": ["message", ...] }.

Errors

Admin handlers return Result<_, laterite_admin::AdminError>.

VariantStatusPage
NotFound404Not found
Forbidden403Forbidden
Internal(_)500Something went wrong

Map a domain error

async fn edit(state: &AdminState, id: &str) -> Result<Response, AdminError> {
    let row = fetch(state, id)
        .await?                        // CoreError::NotFound -> 404, DB error -> 500
        .ok_or(AdminError::NotFound)?; // a missing row -> 404
    Ok(render(view(row)))
}
FromTo
CoreError::NotFound404
CoreError::Forbidden, CoreError::Unauthorized403
AuthErrorThrough CoreError, the same mapping
A database error, a render failure, a panic500

An unmatched admin URL renders the 404 page.

Show the cause in development

[app]
debug = true   # off by default

A 500 logs its cause through tracing::error! in every mode. With debug on, the page shows it too.

Validation

A refused submission is not an AdminError: it answers 422 with the form re-rendered. See Validation.

Localization

English strings in code and templates are the catalog keys. A .po file per language translates them.

Write translatable text

use laterite_core::{t, tn};

let msg = t!("Your changes were saved.");
let greeting = t!("Welcome back, {name}.").arg("name", user.name.clone());
let label = tn!("{n} item", "{n} items", count).arg("n", count);
WhereCall
Rustt!("..."); tn!("one", "many", n) for plurals. Both build a Text, localized at render.
Templateshell.t("..."); shell.tf(...) with numeric placeholders.
Descriptor labelsAlready Text, localized where they render.

A placeholder such as {name} appears unchanged in every translation.

Manage catalogs

crates/admin/lang/
  messages.pot     # generated template, every source string
  de.po            # a German translation
lat i18n extract          # regenerate every messages.pot
lat i18n update de        # create or refresh de.po from the template
lat i18n status           # per-locale coverage
lat i18n check            # fail if any messages.pot is stale; run in CI

A translator fills in msgstr:

#: templates/login.html
msgid "Admin panel"
msgstr "Verwaltung"

Ship a language from a module

impl Module for AcmeModule {
    fn catalogs(&self) -> &'static [(&'static str, &'static str)] {
        &[
            ("de", include_str!("../lang/de.po")),
            ("fr", include_str!("../lang/fr.po")),
        ]
    }
}

A malformed catalog, or one whose placeholders differ from the source, fails the boot.

How a language is chosen

  1. The operator’s preference, from Preferences.
  2. The browser’s Accept-Language.
  3. backend.locale.
  4. English.

Only a language with a loaded catalog is offered or chosen. Plural rules cover one and other. Month and day names follow a locale that carries a territory, de_DE.

Preview coverage

Set the locale to xx, or send Accept-Language: xx: every translated string renders bracketed and accented, [Ṡäṽé]. Plain text is a string that bypassed translation.

Screens and Routes

A module mounts its own routes: a screen inside the admin, or a public endpoint outside it. A screen is for what a list and a form cannot express: an import wizard, a report, a diff view.

Take axum from the framework

use laterite_admin::axum::{routing::get, Router};
use laterite_core::strata::async_trait;

Never add axum or async-trait to a plugin’s Cargo.toml.

Mount an admin screen

use laterite_admin::routes::{RouteCtx, Screen, ScreenReg};

struct Importer;

impl Screen for Importer {
    fn mount(&self, ctx: &RouteCtx) -> Router {
        let db = ctx.db().clone();
        Router::new()
            .route("/", get(upload_form).post(run_import))
            .route("/report", get(move || show_report(db.clone())))
    }
}

fn register(&self, registry: &mut Registry) {
    registry.add_screen(
        ScreenReg::new("/import", "acme.import", Arc::new(Importer))
            .in_menu(t!("Import places")),
    );
}
ScreenRegDescription
new(path, permission, screen)path sits under the module’s identity: rainmill.location at /import serves /admin/rainmill/location/import. permission is required and enforced before the handler runs.
.in_menu(label)Adds it to the main menu. Without it the screen still mounts, reached by link.

The screen inherits the session, the operator, CSRF protection and the error pages.

Render a page in the admin

use askama::Template;
use laterite_admin::axum::{Extension, Router};
use laterite_admin::html::Markup;
use laterite_admin::{AdminError, Shell};
use laterite_core::t;

#[derive(Template)]
#[template(path = "places.html")]
struct PlacesTemplate<'a> {
    shell: &'a Shell,
    rows: Vec<String>,
}

async fn places(Extension(shell): Extension<Shell>) -> Response {
    let body = PlacesTemplate { shell: &shell, rows: vec![] };
    match Markup::from_template(&body) {
        Ok(body) => shell.page(&t!("Places"), body),
        Err(e) => AdminError::Internal(e.into()).into_response(),
    }
}
<div class="lat-card">
  <form method="post" action="/admin/places">
    <input type="hidden" name="_csrf" value="{{ shell.csrf_token() }}">
    <button class="lat-btn lat-btn--primary" type="submit">{{ shell.t("Save") }}</button>
  </form>
</div>
MethodDoes
shell.page(&title, body)The body inside the chrome: nav, flash messages, assets, the title as the heading.
shell.t("Save"), shell.tt(&text)A string, or a Text, in the operator’s locale.
shell.csrf_token()The request token for a form the template builds. First in an upload form.
shell.datetime(&utc)A stored RFC 3339 timestamp in the operator’s timezone.
shell.base()The admin mount, for a link into the panel.

The admin’s stylesheet is already on the page: lat-card, lat-table, lat-fields, lat-field, lat-input, lat-select, lat-check, lat-facts, lat-actions, lat-btn, lat-alert, lat-help, lat-status. A SessionHandle extension carries push_flash; an AdminError renders the styled 404, 403 or 500.

Head a page

use laterite_admin::PageHead;

let actions = Markup::from_template(&ActionsTemplate { shell: &shell })?;
shell.page_with(
    PageHead::new(t!("Places")).back(t!("Regions"), ctx.url("")).actions(actions),
    body,
)
<a class="lat-btn lat-btn--secondary lat-btn--sm" href="{{ export_href }}">{{ shell.t("Export") }}</a>
<details class="lat-menu">
  <summary class="lat-btn lat-btn--secondary lat-btn--sm">{{ shell.t("Manage") }} &#9662;</summary>
  <div class="lat-menu__panel">
    <button type="button" data-lat-dialog="new-place">{{ shell.t("New place") }}</button>
    <div class="lat-menu__divider"></div>
    <form method="post" action="{{ purge_href }}"><input type="hidden" name="_csrf" value="{{ shell.csrf_token() }}"><button class="is-danger" type="submit" data-lat-confirm='{{ shell.t("Purge every place?") }}'>{{ shell.t("Purge") }}</button></form>
  </div>
</details>
MethodDoes
PageHead::new(title)The heading.
.back(label, href)A link above the heading to the page this one sits under.
.actions(markup)Controls in the band under the heading: buttons, a details.lat-menu, dialog openers.
shell.page_with(head, body)The body under that head, inside the chrome.

Open a form in a dialog

<button type="button" class="lat-btn lat-btn--primary lat-btn--sm" data-lat-dialog="new-place">{{ shell.t("New place") }}</button>
<button type="button" class="lat-btn lat-btn--ghost lat-btn--sm" data-lat-dialog="new-place" data-lat-dialog-set="kind=city">{{ shell.t("New city") }}</button>

<dialog class="lat-dialog" id="new-place">
  <form method="post" action="{{ base }}">
    <div class="lat-dialog__head"><h2>{{ shell.t("New place") }}</h2><button type="button" class="lat-dialog__close" data-lat-dialog-close aria-label='{{ shell.t("Close") }}'>&times;</button></div>
    <div class="lat-dialog__body">
      <input type="hidden" name="_csrf" value="{{ shell.csrf_token() }}">
      <div class="lat-field">
        <label class="lat-field__label" for="kind">{{ shell.t("Kind") }}</label>
        <select class="lat-input lat-select" id="kind" name="kind"><option value="city">{{ shell.t("City") }}</option></select>
      </div>
      <div class="lat-field">
        <label class="lat-field__label" for="name">{{ shell.t("Name") }}</label>
        <input class="lat-input" id="name" name="name" required autofocus>
      </div>
    </div>
    <div class="lat-dialog__actions"><button class="lat-btn lat-btn--ghost" type="button" data-lat-dialog-close>{{ shell.t("Cancel") }}</button><button class="lat-btn lat-btn--primary" type="submit">{{ shell.t("Create") }}</button></div>
  </form>
</dialog>
AttributeOnDoes
data-lat-dialog="id"Any controlOpens the dialog.lat-dialog with that id and focuses its first field.
data-lat-dialog-set="field=value;other=value"The openerFills the named fields before opening.
data-lat-dialog-closeA control insideCloses the dialog. Escape and a click on the backdrop close it too.
.lat-dialog--wideThe dialog760px wide.

Split a screen

<div class="lat-split">
  <nav class="lat-pane">
    <div class="lat-pane__head">{{ shell.t("Regions") }}<button type="button" class="lat-btn lat-btn--ghost lat-btn--sm lat-btn--icon" data-lat-dialog="new-region">{{ shell.icon("plus")|safe }}</button></div>
    <ul class="lat-nav">
      <li><a class="lat-nav__item is-active" href="{{ href }}"><span class="lat-nav__label">{{ shell.t("North") }}</span><span class="lat-nav__meta">12</span></a></li>
    </ul>
  </nav>
  <div class="lat-pane">
    <div class="lat-pane__bar"><nav class="lat-breadcrumb"><a href="{{ base }}">{{ shell.t("All") }}</a></nav><form method="get" action="{{ base }}"><input class="lat-input lat-listbar__search" type="search" name="q"></form></div>
    <div class="lat-pane__body">{{ content|safe }}</div>
    <div class="lat-pane__foot"><button class="lat-btn lat-btn--primary" type="submit">{{ shell.t("Save") }}</button></div>
  </div>
</div>
ClassRenders
lat-splitA 260px pane beside the content; lat-split--aside puts a 340px pane after it. One column under 900px.
lat-paneA framed block; lat-pane__head, lat-pane__bar, lat-pane__body, lat-pane__foot stack inside it.
lat-nav, lat-nav__item, .is-activeA list of links in a pane, with lat-nav__label and lat-nav__meta.
shell.icon(name)An icon from the framework’s set, inline.

Compose chrome

use laterite_admin::ui::{Badge, Button, Card, Toolbar};

let body = Toolbar::new("Reports")
    .button(Button::link("New report", format!("{}/reports/new", ctx.admin_path())).primary())
    .button(Button::post("Rebuild", format!("{}/reports/rebuild", ctx.admin_path())).confirm("Rebuild every report?"))
    .render(&shell);
let card = Card::new(Markup::from_override(table_html)).render();
let state = Badge::new("Stale").render(&shell);
ElementBuildersRenders
Button::link(label, href).primary(), .danger(), .ghost(), .small(), .icon(name)A link styled as a button.
Button::post(label, action)The same, and .confirm(question)A form that posts with the request token, asking first when confirm is set.
Toolbar::new(title), Toolbar::section(title).button(..)A page or section heading, its buttons in the band under it.
Badge::new(label)A small label.
Card::new(markup)The panel’s framed block around what it is given.

Choose where it mounts

fn admin_base(&self) -> Option<&'static str> { Some("/places") }
[backend.paths]
"rainmill.location" = "/geography"
"rainmill.location/import" = "/geography/bulk-upload"

The deployment has the last word. Two modules on one path, or one on a framework screen, abort the boot naming both.

ctx.url("/report")      // "/admin/places/import/report", wherever it mounted
ctx.base_url()          // the site origin, from app.url or the bind address

base_url is for a URL a route emits: a sitemap <loc>, a canonical, a feed entry. Never build one from the Host header.

Mount a public route

use laterite_admin::routes::{PublicRoute, PublicRouteReg, RouteCtx};

struct Robots;

impl PublicRoute for Robots {
    fn mount(&self, _ctx: &RouteCtx) -> Router {
        Router::new().route("/", get(robots_txt))
    }
}

registry.add_public_route(PublicRouteReg::new("/robots.txt", Arc::new(Robots)));

No session, no permission, no admin chrome. The path is literal, never namespaced; a route inside the admin mount is refused. A POST decides its own CSRF stance.

Add middleware

fn mount(&self, _ctx: &RouteCtx) -> Router {
    Router::new()
        .route("/", get(robots_txt))
        .layer(CompressionLayer::new())
}

The framework’s session, permission, CSRF and panic layers wrap yours from the outside.

Test a route

let ctx = RouteCtx::builder(db)
    .admin_path("/backoffice")
    .base_url("https://acme.example")
    .build();

let response = Robots.mount(&ctx)
    .oneshot(Request::get("/").body(Body::empty())?)
    .await?;

Every builder field has a default; name only what you assert on.

Put a checklist on a screen

use laterite_admin::checklist::{Checklist, Choice, Expand, Offer};
use laterite_core::Translator;

let list = Checklist::new("Channels")
    .choice(Choice::new("channel", "email", "Email").checked(true))
    .group("Chat", vec![
        Choice::new("channel", "slack", "Slack").note("One workspace").into(),
        Choice::new("channel", "teams", "Teams").locked(true).into(),
    ])
    .select_all(Offer::Auto)
    .search(Offer::Never)
    .expand(Expand::All);

// `Markup`, for a template's `|safe` slot
let html = list.render(&Translator::new("en"));
BuilderDoes
Choice::new(name, value, label)One box, submitted as name=value.
.note(..), .code(..)A second line under the label; an identifier beside it.
.checked(..), .locked(..)Ticked; shown and not changeable.
.group(label, entries)A heading over entries of its own, nested to any depth.
.select_all(..), .search(..), .expand(..)The defaults listed under Behaviours.
.read_only(true)Every box locked.
.view(&translator)The resolved list as a serialisable value, for a field type’s view-model.

Contribute a column type

use laterite_admin::html::Markup;
use laterite_admin::list::{CellCx, CellVm, ColumnType, ColumnTypeReg};

struct Rating;

impl ColumnType for Rating {
    fn view_key(&self) -> &'static str { "acme.rating" }
    fn view_model(&self, cx: &CellCx<'_>) -> CellVm { /* ... */ }
    fn render_default(&self, vm: &CellVm) -> Markup { /* ... */ }
}

registry.add(ColumnTypeReg::new(Arc::new(Rating)));

Use a dotted vendor.name key. A taken key aborts the boot; so does a column naming a key nobody registered.

Read other modules’ contributions

pub struct FeedReg {
    pub name: String,
}

impl PublicRoute for Feeds {
    fn mount(&self, ctx: &RouteCtx) -> Router {
        let feeds: Vec<&FeedReg> = ctx.contributions::<FeedReg>();
        // ...
    }
}

Any Send + Sync type another module registers. Contributions the framework consumes itself (resources, screens, settings, permissions, persisters, listeners, column types, public routes) are not visible here.

Write through a resource’s form

use std::collections::HashMap;
use laterite_admin::persist::SaveError;
use laterite_core::Actor;

impl Screen for Importer {
    fn mount(&self, ctx: &RouteCtx) -> Router {
        let products = ctx.form("/products").expect("the products resource");
        Router::new().route("/run", post(move || {
            let products = products.clone();
            async move {
                let actor = Actor::system("acme.import");
                let mut row = HashMap::new();
                row.insert("sku".to_string(), "A-100".to_string());
                row.insert("name".to_string(), "Anvil".to_string());
                match products.create(row, &actor).await {
                    Ok(id) => format!("created {id}"),
                    Err(SaveError::Invalid(bag)) => format!("refused: {bag:?}"),
                    Err(SaveError::Failed(e)) => format!("failed: {e}"),
                }
            }
        }))
    }
}

ctx.form(path) takes the resource’s path as its descriptor declares it, relative to the admin root, and is None when nothing with a form mounts there. The handle is Clone and owns what it needs.

MethodDoes
create(data, &actor)Validates data as a submission, saves a new record through the form’s field types, listeners and persister, and returns its id.
update(id, data, &actor)The same as an edit of the record id. Every field is written; one left out of data is stored blank.
entity(), id_field()The table and its primary key column.
fields()The declared fields, in order; data is keyed by their names.

SaveError::Invalid carries the per-field messages a refused submit shows; SaveError::Failed is the write failing. The audit log records the actor.

Navigation

A resource and a screen each add their own menu entry. A module adds sections and extra entries through its registry.

# descriptors/posts.yaml
menu: { under: acme.content, icon: file-text, order: 2 }
registry.add_nav(NavItem::section("acme.content", "Content").icon("folder").order(10));
registry.add_nav(NavItem::new("acme.reports", "Reports", "/reports").permission("acme.reports"));

Resource::new("/posts", "Posts", list)
    .permission("acme.posts")
    .menu(Placement::new().under("acme.content").icon("file-text").order(2))

ScreenReg::new("/reports", "acme.reports", Arc::new(ReportsScreen))
    .in_menu("Reports")
    .menu(Placement::new().under("acme.content"))
menu keyDefaultDoes
underthe top barThe key of the section the entry sits under.
iconnoneAn icon name from the icon set.
order0Sorts among siblings, lowest first; equal orders sort by label.
after, beforenoneSits right after or before the sibling with that key, whatever the orders say. A resource’s key is resource: and its path (resource:/posts), a screen’s screen: and its path, a contributed item’s its own.
menu: falseMounts the routes with no menu entry.
NavItemDoes
NavItem::new(key, label, path)An entry leading to path under the admin mount.
NavItem::section(key, label)A tab holding entries; it leads to the first one the operator may open.
.icon(name), .order(n), .after(key), .before(key), .under(key), .permission(code)As the menu keys above; permission hides the entry from an operator without it.
BehaviourDoes
PermissionA resource’s entry carries the resource’s permission, a screen’s its own. An operator sees only the entries they may open; a section with none left is gone.
SidebarOn a section’s pages, its entries show in the context sidebar with the current one marked.
BreadcrumbsOn a section’s pages and on settings pages: the tab, then the entry, above the page. A tab’s own page shows none.
Dashboard and SettingsAlways first and last.
The operator’s own orderDragging the tabs in the top bar keeps that order with the operator’s preferences, on every device; “Reset the menu order” on Preferences restores the default. Dashboard and Settings stay put.
KeysA duplicate key, under naming a key nobody declared, or after and before naming no sibling, aborts boot.

Command palette

# descriptors/posts.yaml
palette: acme.posttitles
Resource::new("/posts", "Posts", list).palette("acme.posttitles")
KeyDoes
paletteA registered picker source. The palette finds this resource’s records through it and opens each at its edit page. Absent keeps the records out.
BehaviourDoes
OpeningThe search button in the top bar, or Ctrl+K (Cmd+K). Without scripting the button is /palette, the same finder as a page.
FindingMenu entries and settings the operator may open, by a case-insensitive substring; then records from every resource with a palette source, up to eight each. A blank query lists the pages alone.
KeysUp and Down move, Enter goes, Escape closes.

Request Log

Every URL the admin could not serve is counted, once per method, path and status, with the first and last time it was seen.

# descriptors/request-log.yaml, built in
entity: backend_request_log
path: /request-log
permission: backend.view_request_log
ColumnHolds
Last seen, First seenWhen the URL was last and first requested.
Method, PathThe request; the path is kept to 512 characters, without its query string.
Status404.
HitsHow many times.
BehaviourDoes
WritingThe admin’s 404 fallback counts the request before answering.
ViewingSettings › System › Request Log, for an operator holding backend.view_request_log.
ClearingRows are selected and deleted like any list’s; the log keeps the two thousand most recently seen on its own.

Descriptor Files

One YAML file describes one resource: the table, where it mounts, who may reach it, and its list and form.

# descriptors/posts.yaml
entity: posts
path: /posts
title: Posts
permission: acme.blog.manage_posts
list:
  creatable: true
  deletable: true
  order_by: created_at
  order_dir: desc
  no_records_message: No posts yet.
  search: { prompt: Search posts }
  columns:
    title:      { searchable: true }
    status:     { type: status_pill, width: 10% }
    created_at: { type: datetime, align: right }
  filters:
    status: { type: select, options: [{ value: draft, label: Draft }] }
form:
  title: Post
  fields:
    title:  { span: 2/3, rules: [required, { max_length: 120 }] }
    status: { span: 1/3, type: select, options: [{ value: draft, label: Draft }] }
    body:   { type: textarea, rows: 8 }

Register it from your module:

registry.add_resource(laterite_admin::resource!("descriptors/posts.yaml"));

The path is relative to your Cargo.toml. The file is read at compile time: a missing or malformed one fails the build.

Resource keys

KeyDescription
entityThe table both screens read and write. Required.
pathWhere the resource mounts, under your module’s namespace. Required.
titleThe navigation label, and the default title of both screens. Required.
permissionThe permission gating it. Required once a form is present.
id_fieldThe primary key column. Default id.
listThe list screen. Required.
formThe create and edit screen. Optional.
importtrue, or { permission: ... }, to let records be imported into the resource. Needs form. Optional.
relationsRelated records, keyed by name, each a section on the edit page. See Related Records. Needs form. Optional.

entity, path and id_field reach both screens.

Naming

Columns, fields and filters are maps. The key is the name, and they render in the order written:

columns:
  title: {}
  created_at: { type: datetime }

A missing label is made from the key: created_at becomes “Created at”. It is still translatable, and lat i18n extract finds it, as it finds the label of every entry under a column’s labels.

Type keys

An entry carries its own keys and its type’s, side by side:

fields:
  body:   { type: textarea, rows: 8, placeholder: Write here }
  price:  { input: number, min: 0, step: 0.01 }
  status: { type: select, options: [{ value: draft, label: Draft }] }
filters:
  status: { type: select, options: [{ value: draft, label: Draft }] }
TypeKeys
textinput (text, email, tel, number, url), placeholder, counter. A number input adds min, max, step; a url input adds copy.
textarearows, placeholder, grow, counter
selectoptions: a list of { value, label }; search
radiooptions
checklistoptions, select_all, search, expand. See Checklist.
referencesource. The chosen id is stored as a number.
idNone. A record id held as a number, as a hidden input; a relation’s foreign key is one.
repeaterfields, min_items, max_items, display, summary_field, reorder, duplicate
passwordreveal
switch, dateNone
A select filteroptions

A key the type does not read is refused, naming the entry and the keys it accepts.

Checklist

Boxes in groups. The column holds the ticked values as a JSON array of text.

fields:
  topics:
    type: checklist
    rules: [required]        # a box must be ticked
    options:
      - { value: news, label: News }
      - group: Sport
        options:
          - { value: football, label: Football, note: Every league }
          - { value: cricket, label: Cricket }
    select_all: auto
    search: auto
    expand: auto
KeyValuesDefault
optionsA list of { value, label, note } and { group, options }, nested to any depth.None
select_allauto, true, falseauto: past 10 choices
searchauto, true, falseauto: past 20 choices
expandauto, all, noneauto: past 10 choices in more than one group, a group with every box ticked or none starts closed and a group partly ticked starts open

A value listed twice stops the boot, naming it.

Form keys

form:
  enter: off
  confirm_leave: false
  focus: off
  fields:
    title: { rules: [{ max_length: 80 }], counter: true }
    body:  { type: textarea, rows: 8, grow: false }
    token: { type: password, reveal: false }
KeyOnValuesDefault
enterThe formsubmit, offsubmit
confirm_leaveThe formtrue, falsetrue: leaving with changes unsaved asks first
focusThe formauto, offauto: a new record opens with the cursor in its first field, a refused save in the first field it refused
enterA fieldsubmit, off, nextThe form’s
countertext, textareaauto, true, falseauto: with a max_length rule, shown once four fifths of it are used
growtextareatrue, falsetrue: as tall as its text, from rows up to most of the window
revealpasswordtrue, falsetrue: a button shows what was typed
searchselectauto, true, falseauto: past 10 choices, the dropdown is searched. An option with an empty value is what clearing chooses.
displayrepeaterauto, inline, listauto: past 3 fields a row, rows collapse to the line naming them
reorderrepeatertrue, falsetrue: each row moves up and down
duplicaterepeatertrue, falsetrue: each row copies into a new one beneath it

A max_length rule also sets the control’s maxlength.

Dependencies

fields:
  title: {}
  slug:  { preset: title }
  path:  { preset: { field: title, type: url } }
  is_delayed: { type: switch, label: Send later }
  send_at:
    type: date
    trigger: { action: show, field: is_delayed, condition: checked }
  format: { type: select, options: [{ value: csv }, { value: xml }] }
  delimiter:
    trigger: { action: show|empty, field: format, condition: "value[csv]" }
  topics: { type: checklist, options: [{ value: news }, { value: sport }] }
  region:
    trigger: { action: enable, field: "topics[]", condition: "value[sport]" }
KeyValue
presetThe field followed, or { field, type }. Follows it as it is typed, until this field is edited; emptying this field hands it back.
preset.typeslug (default): hello-world. url: /hello-world. file: spaces to hyphens. exact: as typed.
trigger.actionshow, hide, enable, disable, empty, fill[value], several joined by |. A hidden field is left out of the submission.
trigger.fieldThe field watched. name[] watches every ticked value of a checklist.
trigger.conditionchecked, unchecked, value[x], value[x][y] (either), value[] (empty), value[*] (anything), value[csv*] (a prefix).

A field that follows or watches one the form does not have stops the boot. An action or condition the page does not know is refused when the file is read.

Layout

Fields lay out on a twelve-column row and wrap as it fills.

KeyDescription
spanThe share of the row: full (default), 1/2, 1/3, 2/3, 1/4, 3/4, or 1 to 12 columns. left, right, auto and half are halves; third and quarter as named.
breaktrue starts a new row.

Under 1100px a quarter becomes a half; under 768px every field takes the row.

Errors

A key the framework does not know is refused with its line:

error: line 11 column 19: unknown field `searchabel`, expected one of
label, type, sortable, invisible, width, align, permission, searchable

Write only the keys you set; everything else takes its default.

lat doctor parses every descriptors/**/*.yaml in the application and its plugins, so a typo is named without a build.

Read a descriptor at run time

resource! embeds a file. For YAML you loaded yourself, call the function behind it:

let resource = laterite_admin::descriptor::from_yaml(&yaml, "posts.yaml")?;

Related Records

A resource lists another entity’s records that belong to each of its own, as a section on the edit page. A has-many section has New, Edit and Delete; a many-to-many section has Attach, Edit and Remove.

# descriptors/products.yaml
relations:
  variants:
    label: Variant
    entity: product_variants
    foreign_key: product_id
    list:
      columns:
        sku: {}
        price: { type: money }
    form:
      fields:
        sku: { rules: [required] }
        price: { type: money }
Resource::new("/products", "Products", list)
    .form(form)
    .permission("acme.manage_products")
    .relation(RelationConfig::has_many("variants", "Variant", "product_variants", "product_id", variants_list, variants_form))
# descriptors/products.yaml
relations:
  tags:
    label: Tag
    entity: tags
    pivot:
      table: product_tags
      parent_key: product_id
      related_key: tag_id
      source: acme.tags
    list:
      columns:
        name: {}
    form:
      fields:
        note: {}
Resource::new("/products", "Products", list)
    .form(form)
    .permission("acme.manage_products")
    .relation(
        RelationConfig::belongs_to_many(
            "tags",
            "Tag",
            "tags",
            PivotConfig::new("product_tags", "product_id", "tag_id", "acme.tags"),
            tags_list,
        )
        .form(note_form),
    )
// The picker source Attach searches, registered with the admin.
Contributions {
    picker_sources: vec![PickerSourceReg::new(
        "acme.tags",
        Arc::new(TableSource::new("tags", "id", "name")),
    )],
    ..Default::default()
}
KeyDefaultDoes
labelrequiredOne related record, singular: the New or Attach button, the child form’s title.
titlethe name, humanizedThe section heading.
entityrequiredThe related table.
foreign_keyone of the twoHas-many: the column on the related table holding the parent’s id. Written by the framework from the URL; a field of that name in form is kept hidden.
pivotone of the twoMany-to-many: the table joining the two, keys below.
listrequiredThe section’s list: the same keys as a resource’s list. Its columns are the related table’s.
formrequired for has-manyHas-many: the related record’s form. Many-to-many: the fields kept on the pivot row, asked for on Attach; empty gives the section no Edit.
permissionthe parent’sA permission the operator must also hold.
Pivot keyDefaultDoes
tablerequiredThe pivot table.
parent_keyrequiredThe column holding the parent’s id. Written by the framework from the URL.
related_keyrequiredThe column holding the related record’s id. Chosen in the picker on Attach, fixed afterwards.
sourcerequiredThe registered picker source Attach searches.
id_fieldidThe pivot row’s id column.
PathDoes
/products/7/editThe parent’s form, then a section per relation.
/products/7/variantsThe section’s list on its own, sorted and paged.
/products/7/variants/newHas-many: a new related record. Many-to-many: Attach, a record from the picker with the pivot fields. Saves return to the parent.
/products/7/variants/3/editHas-many: the related record 3. Many-to-many: pivot row 3’s fields. Only under its own parent.
/products/7/variants/deleteHas-many: deletes the selected records through the related entity’s listeners and persister. Many-to-many: deletes the selected pivot rows the same way; the records stay.
/products/7/variants/reorderHas-many with reorder on its list: moves one record within the parent’s section.

A relation needs a form on the parent. A relation’s name is a path segment and cannot be new, edit, delete, import, export or columns. A create page names the sections it will have; “Save and continue editing” lands on them. The section is never offered for a parent that does not exist. Attaching a record already attached, or one the related table does not hold, is refused with a message.

Admin Interactivity

The admin is server-rendered HTML with htmx. Behaviour htmx cannot express is an island, registered by name.

Request feedback

Every htmx request shows a progress bar, dims the submitting form, and raises a toast on failure. A 422 re-renders the form with its errors. See Validation.

Raise a toast from script:

window.lat.flash('Import finished.', 'success');   // or 'error', which stays until dismissed

Keyboard

KeyWhereDoes
EnterA single-line fieldSubmits the form.
EnterA textareaA newline.
Cmd/Ctrl+EnterAnywhere in a formSubmits it.
EnterA picker’s search box, a repeater rowActs there: the picker chooses what is highlighted; a repeater row moves to the next field, and from its last field adds a row. Never submits the form around it.
Down, Up, Home, EndA picker’s listMove the highlight. Down on a closed list opens it.
EscapeA menu, a dialogCloses it.

Opt out in a descriptor:

form:
  enter: off               # Enter never submits this form; Cmd/Ctrl+Enter still does
  fields:
    sku: { enter: next }   # Enter moves to the next field
    notes: { type: textarea }

A widget of your own that puts an input inside a form marks its root data-lat-enter-scope, with data-lat-enter-target="<selector>" naming the button Enter presses there; without a target, Enter does nothing.

Lists

ControlBehaviour
Column headerSorts; a second click flips it. Only a declared, sortable column sorts.
ColumnListColumn builders: sortable(false), invisible(), width("10%"), align(Align::Right), require(permission), labels(..). A column the operator may not see is not queried, sorted, searched or exported.
A stored codelabels: [{ value: login_success, label: Signed in }] shows the label for the value, whatever the column’s type. A value without one shows as stored.
RowOpens its record on a click or Enter when the list links rows to a form. A modified or middle click opens a new tab; a click on the row’s own controls is theirs. row_click: none leaves it to the Edit link.
Row boxesShift and a click ticks, or clears, every box between it and the last one clicked.
SearchAsks as you type, in the columns marked searchable; text columns by default. ListColumn::searchable overrides. ListConfig::search(SearchConfig) sets a prompt, asks on_enter only, or turns it off().
Empty state“No records yet.”, or ListConfig::no_records_message(text).
Filtersboolean, select, text, number, date, or of(field, label, type). Only a value the type accepts reaches the query. default_value narrows the list until the operator decides; require(permission) gates it.
List setupEach operator chooses which columns show, and the page size. Picking every column clears the choice.
ExportOn a list that calls .exportable(): CSV or JSON of the current query without paging. Over 20,000 rows is refused.
PagerKeeps the sort, search and filters.
Page sizeper_page. per_page_options offers a choice in the list setup, remembered per operator.
The queryThe sort, search and filters an operator used come back when they return to the list from a plain link, within the session; clearing them forgets them. remember: false turns it off.

Every control is a link or a GET form, so it works with scripting off.

Add a toolbar button:

use laterite_admin::list::ToolbarButton;

ToolbarButton::new("Reports", "/reports")
    .icon("history")
    .require("acme.view_reports");

A path starting with / resolves under the admin mount. A button the operator lacks the permission for is not rendered.

Confirm a destructive action

<button type="submit" data-lat-confirm="Delete the selected records? This cannot be undone.">
  Delete
</button>

Ask from script

lat.confirm('Archive 12 articles?', { go: 'Archive', cancel: 'Keep', focus: 'cancel' }, function (yes) {
  if (yes) archive();
});
LabelDoes
goNames the confirming button. Default OK.
cancelNames the other. Default Cancel, localized.
focus'cancel' opens with the cursor on it. Default: the confirming button.

Forms

A form of your own template joins in with one attribute:

<form method="post" action="/admin/acme/import" data-lat-widget="form" data-lat-focus="first">
AttributeDoes
data-lat-widget="form"Asks before leaving with changes unsaved.
data-lat-refusedThe form holds a submission that was not saved: it opens changed, with the cursor in the first field carrying an error.
data-lat-focusfirst opens with the cursor in the first field; off leaves it alone.
data-lat-confirm-leave="off"Never asks.
data-lat-counterOn a control with maxlength: auto or on.
data-lat-grow="off"On a textarea: keeps the height it was given.
data-lat-preset, data-lat-preset-typeOn a field’s wrapper: the field followed and the shape (slug, url, file, exact).
data-lat-trigger-action, data-lat-trigger-field, data-lat-trigger-conditionOn a field’s wrapper: the dependency, as Descriptor Files spells it.

Events

Every island announces what it does as a DOM event named lat:<component>:<event>, bubbling from its root. Server-side facts are on the event bus.

var stop = lat.on('repeater:added', function (detail, event) {
  console.log(detail.count, 'rows; the new one is', detail.row);
});

// A `before-` event is cancelable: this caps a repeater at five rows.
lat.on('repeater:before-add', function (detail, event) {
  if (detail.count >= 5) event.preventDefault();
});

// Drive an island through its controller.
lat.get(document.querySelector('[data-lat-widget="repeater"]')).add();
EventDetailCancelable
repeater:before-addcountYes
repeater:addedrow, index, countNo
repeater:before-removerow, index, countYes
repeater:removedindex, countNo
repeater:movedrow, from, to, countNo
repeater:duplicatedrow, index, countNo
ref-picker:changedid, label, previousNo
ref-picker:clearedpreviousNo
selection:changedids, countNo
checklist:changedvalues, count, totalNo
flash:showntext, levelNo
flash:dismissedtextNo
confirm:openedtextNo
confirm:confirmed, confirm:cancelledNoneNo
copy:copiedvalueNo
reveal:shown, reveal:hiddenNoneNo
preset:filledfrom, valueNo
trigger:changedfield, metNo
ControllerMethods
Repeateradd(), remove(index), move(from, to), duplicate(index), count()
Record picker, searched selectvalue(), label(), choose({ id, label }), clear()
Checklistvalues(), count(), set(values), all(), none(), search(text)
Formchanged(), settle(): takes the form as it stands for saved
Password revealshow(), hide()
HelperDoes
lat.on(name, handler)Listens page-wide, swapped content included. Returns the function that stops listening.
lat.emit(el, name, detail, cancelable)Announces lat:<name> from el. Returns false when a listener cancelled it.
lat.get(el)The controller of the island at or around el.
lat.scan(root)Starts the islands in markup a script added. Swapped content starts without it.
lat.confirm(text, labels, done)Asks in the admin’s dialog. done hears true or false.

Write an island

window.lat.widget('char-count', function (el) {
  var input = el.querySelector('input');
  function count() { return input.value.length; }
  input.addEventListener('input', function () {
    el.querySelector('.count').textContent = count();
    lat.emit(el, 'acme.char-count:changed', { count: count() });
  });
  // Returned, so `lat.get(el).count()` reads it from outside.
  return { count: count };
});

An island that builds markup around its element returns that markup as root, so lat.get finds the controller from inside it too.

<div data-lat-widget="char-count">
  <input type="text" name="title">
  <span class="count">0</span>
</div>

Every matching element is initialised once, on load and after each htmx swap. Bind by structure, not by id. window.lat.assets.ensure(url) loads a stylesheet or script once.

Not built yet

Inline editing in a row; reordering columns.

Search and Matching

A text lookup attaches a SearchProfile: how text folds, how a query widens, and how it becomes SQL.

use laterite_core::search::{Numerals, SearchProfile};

let profile = SearchProfile::folding().with_expander(Numerals);
assert_eq!(profile.fold("  Ávila's  Café "), "avilas cafe");

Fold text

Folding runs in Rust, never in a database collation.

NormalizerEffect
LowercaseCase.
DiacriticsNFKD, then combining marks dropped.
PunctuationRemoved.
WhitespaceCollapsed.

NormalizerChain::standard() applies all four in order. SearchProfile::folding() folds case only. A profile folds the query, not stored values, so widen it only where the column holds folded values:

use laterite_core::search::{NormalizerChain, SearchProfile};

let profile = SearchProfile::new().with_chain(NormalizerChain::standard());

NormalizerChain::fingerprint() names the chain, case+diacritics+punctuation+whitespace. Store it beside folded data and compare after a chain change.

Widen a query

An expander turns the folded query into alternatives. Query-side only; stored data is untouched.

ExpanderEffect
NumeralsEnglish number words and digits, both ways, on whole tokens: 7 rivers finds seven rivers.
AliasesPairs that reach each other.
use laterite_core::search::{Aliases, SearchProfile};

let profile = SearchProfile::folding()
    .with_expander(Aliases::new().pair("bengaluru", "bangalore"));

Fold alias values through the same chain before adding them. candidates(q) returns the folded query and its expansions, escaped for LIKE, capped at MAX_CANDIDATES.

Match

condition(caps, column, q) builds the sea_query condition; None for a blank query, and the caller decides. LikeMatcher is the default: case-folded substring matching with an explicit escape character. A matcher needing a database extension implements applies against the capability set and stands down to LIKE when it is absent.

Where it applies

SurfaceProfile
Picker sourceTableSource::with_search(profile).
List search boxThe columns marked searchable, text columns by default. Case-folding.

Extending Fields, Writes and Lifecycles

Three seams change how the admin behaves. Each is contributed from a module’s register and named by a descriptor.

ScopeSeam
One field on a formA field type
One entity’s storage shapeA persister
Any record, cross-cuttingA listener
Another module’s screenA resource hook

Write a field type

impl FieldType for SwitchField {
    fn view_key(&self) -> &'static str {
        "switch"
    }

    fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
        scalar_vm("switch", cx)
    }

    fn render_default(&self, vm: &FieldVm) -> Markup {
        Markup::from_template(&SwitchTmpl { vm }).unwrap_or_default()
    }

    fn to_attr(
        &self,
        field: &SubmittedField<'_>,
        _opts: &ResolvedOptions,
        _mode: Mode,
    ) -> Result<Option<AttrValue>, String> {
        Ok(Some(AttrValue::Bool(stored_is_on(field.value()))))
    }
}
MethodRole
view_keyThe name a descriptor uses. A dotted vendor.name; a taken key aborts the boot.
view_modelThe submitted or stored value as a view-model.
render_defaultThe markup.
to_attrThe submitted string as the typed value that reaches the database. Err refuses the save.
option_keysThe keys the type reads from a field’s entry. Any other key is refused by name.
submittedFor a type of several controls: what it submitted, gathered into one value. The rules read it, and a refused save is rebuilt from it.
resolve_optionsThose keys, typed once at boot.

Built in: text, textarea, select, radio, checklist, switch, date, password, reference, repeater.

Register it and name it from a descriptor:

registry.add_field_type(FieldTypeReg::new(Arc::new(MoneyField)));

FormField::of("price", "Price", "acme.money")

One registration covers forms and settings. A descriptor naming an unregistered type aborts the boot.

A type’s keys are written on the field, beside the field’s own:

price: { type: acme.money, currency: EUR, precision: 2, span: 1/3 }
fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
    Some(vec!["currency".into(), "precision".into()])
}

What a field type owes the people using it

RuleHow
Works with no configurationEvery key has a default chosen for the common case: type: acme.money alone renders a usable control.
Each default can be turned offOne key per behaviour, named for what it does, read from the field’s entry.
Its keys are declaredoption_keys lists them, so a typo is refused by name.
Enter stays inside itA root marked data-lat-enter-scope when the control holds an input of its own. See Admin Interactivity.
It says what it didlat.emit(root, 'acme.money:changed', detail); a before- event is cancelable.
It can be drivenThe island’s initialiser returns its controller; lat.get(el) finds it.
It is listedOne row per behaviour in your plugin’s docs: the default, and the key that changes it. See Behaviours.
Works before scripts loadThe markup submits as a plain form; a script island improves it.

Write a persister

A persister replaces the generic insert and update for one entity.

#[async_trait]
impl Persister for NodePersister {
    async fn create(&self, cx: &mut SaveCx<'_>, rec: &Record) -> Result<i64, SaveError>;
    async fn update(&self, cx: &mut SaveCx<'_>, id: &str, rec: &Record) -> Result<(), SaveError>;
    async fn load(&self, cx: &mut SaveCx<'_>, id: &str) -> Result<Option<Record>, SaveError>;
    async fn delete(&self, cx: &mut SaveCx<'_>, id: &str, rec: &Record) -> Result<(), DeleteError>;
}
MethodDefault
loadNone. The pre-write row, for listeners.
deleteRefuses.

cx carries the open transaction. Run every statement on it; never open your own.

registry.add_persister(PersisterReg::new("rainmill.location", Arc::new(NodePersister)));

FormConfig::new("location_node", "Location", "/locations", "id", fields)
    .persist("rainmill.location")

Write a listener

A listener runs around the write of any record it targets. See Model Listeners.

registry.add(ModelListenerReg::for_entity("article", Arc::new(NotifyOnPublish)));
registry.add(ModelListenerReg::all(Arc::new(AuditWriter)));

An all-entities listener must not assume a column exists. FormConfig::timestamps opts one entity into the built-in Timestamps.

Mount a feature under a resource

use laterite_admin::axum::routing::get;
use laterite_admin::axum::Router;
use laterite_admin::routes::{ResourceFeature, ResourceFeatureReg, ResourceInfo, RouteCtx};

struct Archive;

impl ResourceFeature for Archive {
    fn mount(&self, ctx: &RouteCtx, resource: &ResourceInfo) -> Router {
        let form = resource.form.clone();
        Router::new().route("/", get(move || async move { form.entity().to_string() }))
    }
}

registry.add(ResourceFeatureReg::new("archive", Arc::new(Archive)));
# descriptors/products.yaml
archive: true
FieldMeaning
resource.pathThe resource’s path as declared, relative to the admin root (/products).
resource.base_pathWhere its list mounted (/admin/products).
resource.labelIts menu label.
resource.permissionThe permission the feature’s routes are gated by.
resource.formIts form, to write records through.

The routes mount at {base_path}/{key} behind the permission. A descriptor declaring a key nobody registered aborts boot naming the resource. The admin knows the import key; a new key needs the admin to carry it.

Change another module’s resource

registry.add_resource_hook(ResourceHook::new("/acme/blog/posts", |mut resource| {
    resource.list.columns.push(ListColumn::new("reviewed", "Reviewed").boolean());
    resource
        .relation(RelationConfig::has_many("notes", "Note", "post_notes", "post_id", notes_list, notes_form))
        .menu(Placement::new().under("acme.content"))
}));
KeyDoes
targetThe resource’s path as it mounts: /posts for an application’s, /acme/blog/posts for a plugin’s under its module base. One nobody declared aborts boot.
The closureTakes the resource as declared, returns it changed. Anything the builders set: columns, fields, relations, import, the palette source, the menu placement.
OrderHooks run in module order, before the resource mounts, so every hook sees the previous one’s result.

Model Listeners

A listener runs when a record is written: fill a column, refuse a save, react to a change. It reaches every entity written through the save pipeline.

The record

let mut rec = Record::new("articles");
rec.set("title", "Hello")
   .set("views", 0i64)
   .set("published", true)
   .set("published_at", Utc::now());

rec.text("title");               // Some("Hello")
rec.int("views");                // Some(0)
rec.deserialize::<Article>()?;   // a struct view; extra attributes ignored

An absent attribute is not part of the write; a null one writes NULL.

Write a listener

use laterite_core::{ErrorBag, ModelListener, Op, Record, SaveCx, t};

struct Slugify;

#[laterite_core::strata::async_trait]
impl ModelListener for Slugify {
    async fn before_save(
        &self,
        cx: &mut SaveCx<'_>,
        rec: &mut Record,
        op: Op,
    ) -> Result<(), ErrorBag> {
        let Some(title) = rec.text("title") else {
            return Ok(());
        };
        if title.is_empty() {
            let mut bag = ErrorBag::default();
            bag.add("title", t!("A title is required."));
            return Err(bag);
        }
        rec.set("slug", slugify(title));
        Ok(())
    }
}

fn register(&self, registry: &mut Registry) {
    registry.add(ModelListenerReg::for_entity("articles", Arc::new(Slugify)));
    // ModelListenerReg::all(..) targets every entity
}
HookRunsReturn
before_saveInside the transactionErr(ErrorBag) refuses and rolls back; the operator sees the messages on their fields.
after_saveAfter commitSide effects. Cannot roll back.
before_deleteInside the transactionErr(Text) refuses and rolls back.
after_deleteAfter commitSide effects.

All optional. Keep before_save fast: the transaction stays open across it.

Read what changed

if rec.has_original() && rec.changed("title") {
    let was = rec.original("title").and_then(AttrValue::as_str);
}

changed is false without a snapshot; check has_original first. The comparison is text-level.

Read the actor

match cx.actor() {
    Actor::User { id, username } => rec.set("updated_by", *id),
    Actor::System { process } => { /* a seeder, a job, the CLI */ }
    _ => {}
}

Stamp timestamps

FormConfig { timestamps: true, .. }

Sets created_at on create and updated_at on every write; a created_at already on the record is kept. Opt in per form: a column added to every entity reaches tables without it.

Guard a delete

#[laterite_core::strata::async_trait]
impl ModelListener for Guard {
    async fn before_delete(&self, cx: &mut SaveCx<'_>, rec: &Record) -> Result<(), Text> {
        Err(laterite_core::t!("Three records still reference this one."))
    }
}

Deletion is not an Op: a save listener never receives deletes. A Persister refuses deletion unless it overrides delete.

Events

An event is a fact a module announces. A listener acts on it.

Listen

use laterite_auth::events::SignedIn;
use laterite_core::{EventCx, EventError, Listener, Registry};

struct WelcomeBack;

#[laterite_core::strata::async_trait]
impl Listener<SignedIn> for WelcomeBack {
    async fn handle(&self, cx: &EventCx<'_>, event: &SignedIn) -> Result<(), EventError> {
        acme::greetings::record(cx.db(), event.user_id).await?;
        Ok(())
    }
}

fn register(&self, registry: &mut Registry) {
    registry.listen::<SignedIn>(WelcomeBack);
}
In a listenerIs
eventThe payload, typed.
cx.db()The application’s pool.
cx.events()The bus, for announcing a fact of the listener’s own.
Err(..)Logged with the event and the listening module. The listeners after it still run, and the announcer is not told.

Listeners run one after another in module dependency order, then the order a module registered them, inside the call that announced.

Announce

use laterite_core::Event;
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
#[non_exhaustive]
pub struct Published {
    pub article_id: i64,
}

impl Published {
    pub fn new(article_id: i64) -> Self {
        Self { article_id }
    }
}

impl Event for Published {
    const NAME: &'static str = "acme.blog.published";
}
impl Screen for Articles {
    fn mount(&self, ctx: &RouteCtx) -> Router {
        let events = ctx.events().clone();
        let list = ctx.url("/");
        Router::new().route("/{id}/publish", post(move |Path(id): Path<i64>| {
            let (events, list) = (events.clone(), list.clone());
            async move {
                // ... write and commit, then:
                events.emit(&Published::new(id)).await;
                Redirect::to(&list)
            }
        }))
    }
}
PartRule
NAMEThe module’s id, then the fact in the past tense: acme.blog.published. Two types under one name stop the boot, naming both.
PayloadSerde in both directions. #[non_exhaustive] with a constructor, so a field added later breaks no listener.
MomentAfter the write has committed.
WhereThe bus
A screen or a public routectx.events() on RouteCtx
Routes added with Bootstrap::extendctx.events() on BootstrapCtx
A listenercx.events()
Beside an AuthServiceauth.events()

Framework events

Types are in laterite_auth::events.

NameTypeFields
auth.signed_inSignedInuser_id, username, ip_address, user_agent
auth.sign_in_failedSignInFaileduser_id (none for an unknown username), username, ip_address, user_agent
auth.locked_outLockedOutuser_id, username, ip_address, user_agent
auth.signed_outSignedOutuser_id
auth.password_changedPasswordChangeduser_id, changed_by (none for a process); by_owner()

Record writes are heard through Model Listeners, which can also refuse one.

Test

let events = Events::builder(db.clone())
    .listen::<SignedIn>(WelcomeBack)
    .build()?;

// A listener, alone.
events.emit(&SignedIn::new(7, "ada", &RequestContext::default())).await;

// The service announcing to it.
let auth = AuthService::new(db.clone(), AuthConfig::default()).with_events(events.clone());

// A route announcing to it.
let ctx = RouteCtx::builder(db).events(events).build();
let response = Articles.mount(&ctx).oneshot(request).await?;

Not built yet

  • Refusing an action from a listener.
  • Listening by a wildcard name.
  • Queued delivery.

Audit Log

Every administrative change is recorded in an append-only log. Handlers do not opt in.

What an entry holds

FieldValue
WhenThe time of the change.
OperatorThe username, kept on the entry after the account is removed. A system change records the process name.
ActionA dot-keyed name: backend.role.update, backend.plugin.disable.
TargetA type, id and name, when the action has one.
ChangeRecorded
A role created or editedYes
A user’s permissions changedYes
A password changedYes, as backend.user.password_change, never the password
An operator created from setup or lat admin createYes, as backend.user.create, credited to the process
A plugin enabled or disabledYes
A settings model savedYes, without the contents
A record created or edited through a resource formYes
An operator’s own preferencesNo

View it

Settings → System → Audit Log, newest first, read-only. Gated by backend.view_audit_log; superusers hold it.

The access log

Settings → System → Access Log, newest first, read-only, gated by backend.view_access_log. Sign-in events, with the address and client they came from:

EventRecorded when
login_successA sign-in succeeded.
login_failureA password was wrong, or the username unknown.
locked_outAn attempt was refused by the lockout.
logoutAn operator signed out.
password_changedAn operator changed their own password.

Your own resources

A Resource with a form is audited as backend.<entity>.create and backend.<entity>.update, attributed to the signed-in operator, through a model listener.

A screen of your own records an entry with the builder:

use laterite_auth::AuditEntry;
use laterite_core::Actor;

let actor = Actor::user(operator.id, &operator.username);
auth.record_audit(
    AuditEntry::new(&actor, "acme.posts.publish").target("post", &post_id, Some(&post.title)),
)
.await?;

The audit write runs after the change commits. A failed audit write is logged; the operator’s action stands.

Sessions and CSRF

A session cookie signs an operator in. Alongside the identity it carries a CSRF token and flash messages.

CSRF

Every POST, PUT, PATCH and DELETE on the admin mount passes two gates. GET, HEAD, OPTIONS and QUERY are exempt.

GatePasses when
OriginSec-Fetch-Site: same-origin, or Origin matches app.url. In development the request Host stands in.
TokenThe session’s token arrives in the _csrf field or the X-CSRF-Token header.

Descriptor forms and htmx requests carry the token already. A hand-written form includes it:

<form method="post" action="{{ action }}">
  {% include "_csrf.html" %}
  ...
</form>

A failed gate renders a 403 “Request blocked” page. Login and first-run setup have no session and pass on origin alone.

Flash a message

use laterite_admin::{FlashLevel, SessionHandle};

async fn save(Extension(session): Extension<SessionHandle>, /* ... */) -> Response {
    // ... persist ...
    session.push_flash(FlashLevel::Success, "Settings saved.");
    Redirect::to("/admin/settings").into_response()
}

The next full page renders and clears it, across a redirect. Levels: Success, Error, Info. push_flash_sticky keeps the message until dismissed.

Change a password

use laterite_core::Actor;

auth.change_password(
    user.id,
    &new_password,
    Some(&session_token),
    &Actor::user(user.id, &user.username),
)
.await?;
ArgumentValue
user_idThe account whose password changes.
new_passwordRefused under password_policy.min_length (default 8).
keep_tokenThe session to keep signed in, or None to end all of them.
actorWho made the change, recorded on the audit log. Actor::system("lat admin reset-password") for a process.

AuthService::password_changed_at(user_id) returns when it last changed, or None if never recorded.

An operator changes their own under Preferences → Password, which calls:

auth.change_own_password(user.id, &current, &new_password, &session_token, &ctx)
    .await?;
ErrorReturned when
AuthError::InvalidCredentialscurrent does not match. Counts as a failed sign-in.
AuthError::TooManyAttemptsThe account is locked out.
AuthError::RefusedThe new password is under password_policy.min_length.

A change through this call is also on the access log as password_changed, with the address it came from.

A password made for an operator is temporary:

auth.require_password_change(user.id).await?;

Every admin request then redirects to Preferences → Password until the operator sets their own. lat admin create --generate and lat admin reset-password --generate mark the account the same way.

Purge

Expired sessions and stay-signed-in credentials are removed at boot, every hour, and on demand:

lat admin purge
let purged = auth.purge_expired().await?;
println!("{} sessions, {} credentials", purged.sessions, purged.remember_tokens);

Why a session ended

EventThe signed-out device sees
AuthService::change_password“Your password was changed”; every other session and stay-signed-in device ends.
Deactivation“This account was deactivated.”
Sign out one device, or every other device“You were signed out from another device.”
Inactivity or the absolute ceiling“Your session ended after a period of inactivity.”

The login URL carries ?ended=<code>, from a fixed set. An htmx request gets HX-Redirect with no body.

A fresh login mints a new session and token. A corrupt session blob degrades to an empty session, never a sign-out.

Media and File Storage

laterite-media stores files: hashed on arrival, one copy per identical bytes, each recorded as a row your tables point at. Its table migrates with the admin; the crate is a dependency only for code that calls it.

[dependencies]
laterite-media = "0.9"
TermMeaning
BlobBytes, named by their BLAKE3 hash.
RecordA file with a stable id, pointing at a blob.
LinkAttaches a record to its owner.

Replacing a file makes a new blob; the record’s id and every reference to it stay.

Take a file in

use laterite_media::{ingest, LocalDisk};

let disk = LocalDisk::new("storage/private");
let stored = ingest(&disk, stream, 10 * 1024 * 1024).await?;

let id = laterite_media::record::create(&db, NewMedia {
    disk: "private".into(),
    hash: stored.hash,
    size: stored.size as i64,
    content_type: stored.content_type,
    original_name: filename,
    in_library: false,
    created_by: Some(user.id),
}).await?;

ingest(disk, stream, limit) takes any byte stream: a browser upload, a server-side fetch, a URL import. It hashes as the bytes pass, aborts and discards past the limit, and sniffs the content type from the bytes.

Receive an upload

async fn receive(mut upload: VerifiedUpload) -> impl IntoResponse {
    while let Some(field) = upload.next_field().await? {
        // the token field is already consumed and checked
    }
}
<form method="post" action="{{ action }}" enctype="multipart/form-data">
  <input type="hidden" name="_csrf" value="{{ shell.csrf_token() }}">
  {% let upload_id = "file" %}
  {% let upload_accept = ".csv" %}
  {% let upload_multiple = false %}
  {% let upload_url = String::new() %}
  {% let upload_fields = String::new() %}
  {% let upload_hint = shell.t("A CSV file.") %}
  {% include "_upload.html" %}
  <button class="lat-btn lat-btn--primary" type="submit" data-upload-submit>{{ shell.t("Upload") }}</button>
</form>

_csrf comes first in the form, or arrives as the X-CSRF-Token header. A route that parses the upload without VerifiedUpload has its response refused.

VariableValue
upload_idThe input’s id, for its label.
upload_acceptAn accept list, or empty.
upload_multipletrue for several files.
upload_urlEmpty: the form submits the file. A URL: the zone posts each file there itself, with _csrf first and X-Requested-With: fetch, shows its progress, and reloads once every file is stored.
upload_fieldsFields sent before each file (folder=3;kind=image).
upload_hintA line under the prompt.
data-upload-submitOn the form’s button: hidden while the zone sends.

A route the zone posts to answers JSON:

{"stored": [{"id": 7, "name": "cover.png", "href": "/admin/media/7", "url": "/storage/media/<hash>/cover.png"}],
 "refused": [{"name": "huge.mov", "reason": "Larger than the upload limit."}]}
EventDetail
lat:upload:picked{count}, in a form.
lat:upload:before-send{count}, cancelable.
lat:upload:stored, lat:upload:failed{name, reason} per file.
lat:upload:done{failed} once every file answered.

Declare disks

let public = LocalDisk::new("storage/media").public_at("/storage/media");
let private = LocalDisk::new("storage/private");

fn register(&self, registry: &mut Registry) {
    registry.add(DiskReg::new("private", Arc::new(private)).label("Uploads kept out of the library"));
}
DiskServing
PublicFiles served directly at the path.
PrivateNo public URL; streamed through the application.

Each record stores its disk. Deduplication is per disk. A module that stores files reads the disks through ctx.contributions::<DiskReg>() and picks one by name. The admin’s import keeps uploads on private: the registered disk of that name, else private under backend.storage.

An S3 disk

[dependencies]
laterite-media = { version = "0.9", features = ["s3"] }
use laterite_media::{DiskReg, S3Config, S3Disk};

let config: S3Config = settings.get("storage.disks.s3")?;
let disk = Arc::new(S3Disk::from_config(&config).await);
registry.add(DiskReg::new("s3", disk));
[storage.disks.s3]
bucket = "acme-media"
region = "auto"
endpoint = "https://<account>.r2.cloudflarestorage.com"
access_key = "..."
secret_key = "..."
prefix = "media"
public_url = "https://media.acme.test"
KeyDefaultMeaning
bucketThe bucket.
regionautoThe region name.
endpointAWSThe store’s URL (R2, MinIO, B2, Spaces, Wasabi).
access_key, secret_keythe SDK’s default chainStatic credentials.
prefix""A key prefix every blob goes under.
public_urlnoneThe origin blobs are reachable at unsigned; unset, the disk is private and serves through presigned URLs.
path_styletrue with an endpointendpoint/bucket/key addressing.

A write under 16 MiB is one request; past it, a multipart upload in 16 MiB parts, aborted if a part fails. signed_url presigns a GET, presigned_put a PUT for a direct upload, and copy is server-side.

Direct uploads

POST /admin/media/direct            name=clip.mp4&content_type=video/mp4&size=52428800
→ {"direct": true, "url": "<presigned PUT>", "path": "quarantine/<id>/clip.mp4", "headers": {"Content-Type": "video/mp4"}}
PUT <presigned PUT>                 the bytes, from the browser
POST /admin/media/direct/complete   path=quarantine/<id>/clip.mp4&name=clip.mp4&folder=3
→ {"stored": [{"id": 7, "name": "clip.mp4", "href": "/admin/media/7", "url": "..."}], "refused": []}
StepOn a disk that presignsOn any other disk
directA PUT URL under quarantine/<32 hex>/<name>, good for an hour.{"direct": false}: the uploader posts the file instead.
completeThe object is read, sniffed, hashed, an image’s EXIF stripped, stored under its hash, recorded, and the quarantine copy removed.The same, for an object already under quarantine/.

The uploader does this on its own when the field carries upload_direct. A path outside quarantine/, or an object larger than the upload limit, is refused. Abandoned quarantine objects are the bucket’s to expire: a lifecycle rule on the quarantine/ prefix of one day.

MethodDefaultOn S3Disk
StorageDriver::presigned_put(path, content_type, expires_in)NoneA presigned PUT.
StorageDriver::copy(from, to)get then putA server-side copy.

The library

The Media tab lists the records with in_library set, in virtual folders, with upload, search, a kind filter, sorting, folders to make, rename, move and remove, and a file page to retitle, rename, move and delete. Library uploads go to the disk named public (the registered one, else media under backend.storage).

PermissionGrants
media.browseThe tab.
media.uploadUpload into it.
media.manageRename, retitle, move, and make or remove folders.
media.deleteDelete a file no record links to.
media.delete_linkedDelete a file records still use; the file page lists them.
use laterite_media::library::{self, Listing, Sort};
use laterite_media::{folder, link, Kind, Owner};

let photos = folder::create(&db, None, "Photos").await?;
let year = folder::create(&db, Some(photos.id), "2026").await?;   // path /Photos/2026
folder::rename(&db, year.id, "2027").await?;
folder::move_to(&db, year.id, None).await?;
folder::delete(&db, year.id).await?;                                // empty folders only
let crumbs = folder::ancestors(&db, year.id).await?;

let page = library::list(&db, &Listing {
    folder: Some(photos.id),
    search: None,
    kind: Some(Kind::Image),
    sort: Sort::Newest,
    page: 1,
    per_page: 60,
}).await?;

let owner = Owner { owner_type: "acme_posts", owner_id: "7", field: "gallery" };
link::set(&db, owner, &[first, second]).await?;                     // in that order
let held = link::of_owner(&db, owner).await?;
let users = link::users_of(&db, first).await?;
link::clear_owner(&db, "acme_posts", "7").await?;

record::update_details(&db, id, Some("Our cat"), Some("A tabby")).await?;
record::rename(&db, id, "cat.png").await?;
record::move_to_folder(&db, id, Some(photos.id)).await?;
record::set_in_library(&db, id, true, None).await?;
CallRefused when
folder::create, folder::renameThe name has a slash or a control character, or a sibling has it.
folder::move_toThe target is the folder or below it.
folder::deleteThe folder still holds a file or a folder.

Listing.search looks across every folder; Kind::of(content_type) gives Image, Video, Audio, Document or Other.

Serve

Every record serves at /storage/media/<hash>/<name>:

HeaderValue
ETagThe hash; If-None-Match answers 304.
Cache-Controlpublic, max-age=31536000, immutable
X-Content-Type-Optionsnosniff
Content-Dispositioninline, or attachment for SVG, HTML, script and any Other kind.

A record on a disk registered .private() serves only at a signed URL:

registry.add(DiskReg::new("vault", Arc::new(private)).private());
let url = laterite_admin::medialib::file_url(&record);
let timed = laterite_admin::medialib::signed_file_url(&ctx, &record, Duration::from_secs(3600));
// /storage/media/<hash>/<name>?e=<expiry>&t=<signature>
PartValue
eExpiry, seconds since the epoch.
tHMAC-SHA256 over the hash and the expiry, with backend.secret.
Cache-Controlprivate, no-store
Bare, expired, altered or retargeted404

The admin’s own links to a private file carry a one-hour signature.

The media field

fields:
  cover_id: { type: media, label: Cover, mode: image }
  gallery: { type: media, label: Gallery, max: 6 }
OptionDefaultMeaning
modefileimage offers images alone and shows thumbnails.
max1How many files the field holds: 1 stores one id in a bigint column; any other number stores a JSON list in a text column, 0 meaning no limit.

A save writes the record’s links under the field’s name (media_links: owner_type is the form’s entity, owner_id the record’s id, field the field’s name), so the file page shows who uses a file and a delete of a linked file needs media.delete_linked. The field’s picker is the library at /admin/media/pick and its tiles come from /admin/media/resolve?ids=.

EventDetail
lat:media:changed{ids}, after a pick, a removal or a clear.

Crop

POST /admin/media/<id>/crop   x=100&y=50&w=200&h=100&mode=new
FieldMeaning
x, y, w, hThe area kept, in the image’s own pixels, clamped to it.
modenew (the default): a file named <stem>-crop.<ext> in the same folder. replace: this file’s bytes, hash and size change and its variants are dropped.

The format is kept (PNG, JPEG, WebP; anything else becomes PNG). The file page offers the dialog to an operator with media.manage on an image.

let out = laterite_media::image::crop(&bytes, Rect { x, y, width, height }, orientation)?;
laterite_media::record::replace_blob(&db, id, &hash, size, &content_type, width, height).await?;

Variants

use laterite_media::{Format, Preset};

registry.add(Preset::new("card").width(600).height(400).crop());
registry.add(Preset::new("hero").width(1600).format(Format::WebP).quality(85));
<img src="/storage/media/{{ hash }}/card/{{ name }}" width="600" height="400">
BuilderDefaultMeaning
Preset::new(name)The name in the URL.
.width(px), .height(px)unboundedThe box.
.fit()yesInside the box, keeping the aspect ratio; a smaller source stays its size.
.crop()Filling the box, cropped from the centre.
.exact()The box, stretched.
`.format(Format::AutoJpegPng
.quality(1..=100)82JPEG quality.
Built inBoxUsed by
thumb320 × 320, fitThe library grid.
preview1200 × 1200, fitThe file page.

A registered preset with a built-in name replaces it. An undeclared name is a 404. A file that is not an image serves as it is under any preset. The first request renders the variant behind a lock per file and preset and stores it at variants/<hash prefix>/<hash>/<preset>.<ext> beside the original; the ETag is "<hash>/<preset>".

LimitValue
Side12 000 pixels, read from the header before decoding.
Pixels50 million.
Decode memory512 MiB.

On upload, a JPEG, PNG or WebP loses its EXIF segment before it is hashed, its orientation is read and kept in meta.orientation, and width and height are recorded. A variant bakes the orientation in. From code:

let prepared = laterite_media::image::prepare(bytes);
let rendered = laterite_media::image::render(&bytes, &preset, prepared.orientation)?;
let made = variants.ensure(&db, disk.as_ref(), &record, &preset).await?;

Collect and verify

lat media gc                 # files outside the library that nothing links to
lat media gc --grace 1       # younger than one hour is left alone (default 24)
lat media gc --dry-run       # list, remove nothing
lat media verify             # re-hash every stored file against its record
FlagDefaultMeaning
--grace <hours>24A record younger than this is never garbage.
--dry-runoffPrints id, disk, hash and name; removes nothing.
--storage <dir>backend.storageWhere the media and private disks live.

A blob goes with the last record naming it. verify exits non-zero on a fault and prints one line per faulty record: missing, hash mismatch, or a size the row disagrees with. A record on a disk the command cannot reach (one the application registered itself) is skipped and counted. From code:

let report = laterite_media::gc::collect(&db, &disks, chrono::Duration::hours(24)).await?;
let report = laterite_media::verify::run(&db, &disks).await?;

Delete

if laterite_media::record::delete(&db, id).await? {
    disk.delete(&blob_path(&hash)).await?;
}

delete returns whether the blob is now unreferenced. Deleting a blob already gone succeeds.

Write a driver

#[async_trait]
impl StorageDriver for MyDisk {
    async fn put(&self, path: &str, data: ByteStream) -> Result<(), StorageError>;
    async fn get(&self, path: &str) -> Result<ByteStream, StorageError>;
    async fn delete(&self, path: &str) -> Result<(), StorageError>;
    async fn metadata(&self, path: &str) -> Result<Option<BlobMeta>, StorageError>;
    fn public_url(&self, path: &str) -> Option<String>;
    async fn signed_url(&self, path: &str, expires_in: Duration, disposition: Disposition)
        -> Result<Option<String>, StorageError>;
}

No list(): folders are database rows. Write to a temporary path and move into place. signed_url defaults to None: the framework then streams the file itself behind its own token. A store that presigns returns the URL.

Bulk Import

A resource’s records imported from a CSV, each row written through the resource’s form, one commit a row, with a report. Built into the admin.

Let a resource be imported into

# descriptors/products.yaml
import: true
import:
  permission: acme.import_products
Resource::new("/products", "Products", list)
    .form(form)
    .permission("acme.manage_products")
    .import(ImportConfig::new())
[backend]
storage = "storage"
fn register(&self, registry: &mut Registry) {
    registry.add(DiskReg::new("private", Arc::new(LocalDisk::new("/srv/acme/private"))));
}
KeyDefaultDoes
import: trueoffMounts the pages at /admin/products/import and shows an Import button beside the list’s export menu.
import.permissionthe resource’sThe permission an operator needs to import.
backend.storagestorageThe directory the uploaded file is kept under, in private, as a media record outside the library.
DiskReg named privatenoneKeeps the file somewhere else instead.

A resource declaring import needs a form and a permission; either missing aborts boot naming the resource.

What the pages do

PageDoes
/importThe upload form, a template with the form’s fields as titles, and this resource’s recent runs.
/import/{file}Each file column with a sample and the field proposed for it: the field whose name or label reads like the title, or the matching used the last time a file with these titles came in. The duplicates key and policy, and whether a blank cell keeps a record’s value.
Check the fileEvery row through the rules and conversions with nothing written; the report says what an import would do.
Import nowThe run, one commit a row, polled every two seconds.
/import/runs/{id}The counts, every refused or skipped row by its line in the file, and refused.csv: those rows as they were, with the problem beside each, to fix and import on their own.

The delimiter is read off the file’s first line (comma, semicolon or tab) and can be set by hand. Files are read as UTF-8; a byte-order mark is accepted.

Start a run from code

use laterite_admin::import::{start, Duplicates, Match, RunSpec};

let products = ctx.form("/products").expect("the products resource");
let spec = RunSpec::new("/products", vec![Match::new(0, "sku"), Match::new(1, "name")])
    .duplicates(Duplicates::update("sku"))
    .media(payload_id);

let run = start(ctx.db(), products, actor, spec, disk.get(&path).await?).await?;

start records the run, detaches the work and returns the run’s id. The request that started it may end; the rows keep being written.

OptionDefaultDoes
RunSpec::new(resource, matches)The resource’s path as its descriptor declares it, and the column matches.
Match::new(column, field)File column column (from 0) fills field. A column may fill several fields.
.duplicates(Duplicates::skip(key))noneA row whose key is already there is passed over and noted.
.duplicates(Duplicates::update(key))noneA row whose key is already there updates that record; fields the file does not carry keep their values.
.keep_blank(bool)trueOn an update, a blank matched cell leaves the record’s value as it is. false clears the field.
.dry_run(bool)falseNothing is written; the counts say what an import would do.
.first_row_titles(bool)trueThe first row names the columns and is passed over.
.delimiter(u8)b','The byte between values.
.titles(vec)noneThe file’s titles, so a later file with the same titles is proposed this matching.
.media(id)noneThe stored payload’s record, kept on the run.

start returns RunError::Spec for a match naming a field the form lacks, no matches, or a duplicates key no column feeds. A blank row is passed over.

Poll a run

use laterite_admin::import::{find, Status};

let run = find(ctx.db(), id).await?.expect("a run");
match run.status {
    Status::Running => format!("{} rows so far", run.processed),
    Status::Done => format!("{} created, {} updated", run.created, run.updated),
    Status::Failed => run.failure.unwrap_or_default(),
}
FieldMeaning
statusRunning, Done, or Failed (the run itself stopped; failure says why). A run left running by a stopped process reads as failed.
processed, created, updated, skipped, errorsRow counts. On a dry run, what an import would do.
notesOne per message: row (the line in the file), kind (Error or Skipped), field, message (a Text, localized where it renders).
specWhat ran, dry_run included.
resource, media_id, actor_label, started_at, finished_atWhat ran, as recorded.

Each row goes through the form’s rules, field types, listeners, persister and audit log. A refused row is a note; the next row still runs.

Database Portability

Laterite runs on Postgres, MySQL or SQLite, chosen by the connection URL. Migrations and queries are written once, through sea-query over sqlx::Any.

Write a migration

use laterite_core::strata::*;

One import brings the Migration trait, async_trait, the schema and query builders, Db, and the polyfills. lat make:migration <description> writes a new file with it.

One migration per file under src/migrations/, named m<NNNN>_<description>.rs, declaring pub struct Migration with a stable name, an up and an optional down. mod.rs lists them in apply order:

laterite_core::migration_set! {
    module_id: "acme.blog",
    m0001_create_posts,
    m0002_create_comments,
}

Append at the end. Never reorder or rename a shipped entry: applied migrations are tracked by (module_id, name).

Use portable types

You writeStored asHelper
boolinteger 0/1bool_col in schema; binds as bool; read with AnyRowExt::get_bool
DateTime<Utc>text, RFC 3339 at fixed precisionConvert at the query boundary
JSON object or arraytextserde_json at the boundary; no in-database JSON operators
Key, id or code columnvarchar(255)key_col; .text() only for unindexed prose
Any string readStringAnyRowExt::get_text, get_text_opt; never try_get::<String>
Unsigned integerThe next wider signedAutomatic
New row idi64query::insert_returning_id
Cast to stringchar or textExpr::col(c).cast_as(Alias::new(text_cast(db.backend)))
Insert, ignoring a duplicatequery::on_conflict_ignore(keys)
use laterite_core::{bool_col, AnyRowExt};

Table::create()
    .table(Article::Table)
    .col(bool_col(Article::Published).not_null().default(0));

let published: bool = row.get_bool("published")?;
let title: String = row.get_text("title")?;

Ids are bigint auto-increment; an insert never sets one. Canonicalise a user-facing unique key, lower-cased and trimmed, on write and on lookup: MySQL compares case-insensitively.

Write a query

use laterite_core::strata::*;

let stmt = Query::select()
    .column(Article::Title)
    .from(Article::Table)
    .and_where(Expr::col(Article::Published).eq(true))
    .to_owned();
let (sql, values) = build(db.backend, &stmt);
let rows = bind_values(sqlx::query(&sql), values).fetch_all(&db.pool).await?;

Never hand-write SQL with ? placeholders. db is a laterite_core::Db: the pool paired with its backend.

Differences the helpers do not hide

BackendBehaviour
SQLiteForeign keys are enforced only with PRAGMA foreign_keys = ON; sqlx sets it. Do not disable it.
MySQLvarchar needs a length: use key_col or .text(). DDL is not transactional: keep a migration to one change.

Static Site Generation

laterite-web renders an application’s public pages to HTML at build time, into a directory any static host serves.

[dependencies]
laterite-web = "0.9"
TypeRole
MetaThe shared <head> tags: title, description, canonical URL, Open Graph and Twitter card.
StaticSiteCollects rendered pages, copies assets, and reports what it wrote.

Templating is yours: any renderer that produces an HTML String.

Write a generator

use laterite_web::{Meta, StaticSite};

fn render_home(head: &str) -> String {
    format!("<!doctype html><html><head>{head}</head><body><h1>Acme</h1></body></html>")
}

fn main() -> std::io::Result<()> {
    let base_url = "https://acme.example";

    let meta = Meta::new("Acme", "The Acme website.")
        .canonical(format!("{base_url}/"))
        .image(format!("{base_url}/static/card.png"));
    let home = render_home(&meta.head_tags());

    let mut site = StaticSite::new("dist", base_url)?;
    site.page("/", &home)?;
    site.assets("static", "static")?;
    Ok(())
}

cargo run writes dist/.

Map URLs to files

Path passed to pageFile writtenServed as
/dist/index.html/
/features/dist/features/index.html/features/
/get-starteddist/get-started/index.html/get-started/

Set page metadata

let meta = Meta::new(title, description)
    .canonical(format!("{base_url}{path}"))
    .image(format!("{base_url}/static/img/card.png"));

let head = meta.head_tags();
MethodDescription
new(title, description)Escaped; safe to build from content.
canonical(url)Also the Open Graph URL. Optional.
image(url)The social-share image. Optional.

Publish extra files

let urls: Vec<String> = site
    .paths()
    .iter()
    .map(|path| format!("{}{path}", site.base_url()))
    .collect();

site.file("sitemap.xml", &your_sitemap(&urls))?;
site.file("robots.txt", "User-agent: *\nAllow: /\n")?;
MethodDescription
paths()Every page written, in order.
base_url()The prefix for absolute URLs.
file(path, content)Writes verbatim at the output root. Creates parent directories; refuses a path outside the output.
assets(from, to)Copies a directory recursively.

No robots.txt or sitemap.xml is written for you.

Deploy

dist/ is plain files. On a host that builds from Git: build command cargo run, publish directory dist.

A page whose content depends on the request (a form, a search, per-user content, the admin) stays on the live server.

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.

Behaviours

What the admin does with no configuration, and the key that changes it.

Forms

WhereDefaultChange it
A single-line fieldEnter submits the form.enter: off on the form; enter: off or enter: next on a field.
A textareaEnter is a newline; Cmd/Ctrl+Enter submits.None.
A picker or a repeater rowEnter acts inside it.data-lat-enter-scope and data-lat-enter-target on a widget of your own.
A field’s widthThe whole row.span: 1/2, 1/3, 2/3, 1/4, 3/4; break: true.
A narrow windowUnder 1100px a quarter is a half; under 768px every field is full.None.
A key the type does not readRefused by name.None.
A repeaterPast 3 fields a row, rows collapse to the line naming them; fewer stay open.display: list or inline.
A repeater rowMoves up and down, copies into a new row beneath it, and is removed.reorder: false, duplicate: false.
A refused saveThe form returns with its errors, values kept (a repeater’s rows and a checklist’s ticks included), and the cursor in the first field it refused.focus: off.
A new recordOpens with the cursor in its first field.focus: off.
Leaving with changes unsavedAsks first, with the cursor on Stay. Typing and undoing it is not a change.confirm_leave: false.
Save and continue editingOffered beside Save on a resource with relations; the save lands on the edit page. A create page names the sections it will have.Declare no relations.
A field with a max_length ruleThe control holds the limit, and a count appears once four fifths of it are used.counter: true or false.
A textareaAs tall as its text, from rows up to most of the window.grow: false.
A password fieldA button shows what was typed. Sending the form hides it again.reveal: false.
A field with presetFollows the named field as it is typed, until edited.Omit the key.
A select past 10 choicesSearched: a box that filters the choices as you type.search: true or false.
A picker or a searched selectOpens on focus with the choice made leading. Down and Up move, Enter chooses, Escape puts the choice back, leaving the box empty clears it.None.
A field with triggerShows, hides, enables, disables, empties or fills itself as the watched field changes. A hidden field is left out of the submission.Omit the key.
A checklist past 10 choicesOffers select all and select none.select_all: true or false.
A checklist past 20 choicesOffers a search box.search: true or false.
A checklist past 10 choices, in groupsA group with every box ticked, or none, starts closed. A group partly ticked starts open.expand: all or none.
A checklist groupIts box ticks the whole group, and shows a dash when some are ticked.None.
A checklist being searchedSelect all, select none and a group’s box act on the matches. Escape clears the search.None.
form:
  enter: off
  confirm_leave: false
  fields:
    sku:   { enter: next, span: 1/3 }
    links: { type: repeater, display: list, fields: { url: { input: url } } }
    tags:  { type: checklist, select_all: false, expand: all, options: [{ value: new }] }

Lists

WhereDefaultChange it
SearchAsks as you type.search: { on_enter: true }; search: { enabled: false }.
Columns and page sizeEach operator’s choice is remembered.per_page sets the size; per_page_options offers a choice of sizes.
A columnSortable, visible, read-only.sortable: false, invisible: true, editable: true.
A cell with editable: trueThe form field’s control, saved on change through the form’s rules, persister and listeners; a refused value shows its message under the control. With scripting off, a Save button. The column’s field must be on the resource’s form.None.
ExportOff.exportable: true.
Deleting rowsOff.deletable: true; it asks before it deletes.
Rows in an order of their ownOff.reorder: true keeps the order in sort_order; reorder: { column: position } names the column. An integer column, not null, default 0. The list opens in that order with a grip to drag and Move up / Move down on every row; a sorted, searched or filtered view shows neither. A has-many section’s list takes the same key.
Rows as a treeOff.tree: true reads the parent from parent_id; tree: { parent_key: folder_id, source: acme.folders } names the column and a picker source. The list opens on the rows with no parent; a row with children expands in place, or opens them as a page with scripting off. With a source, every row offers “Move to”, which picks the new parent from it or leaves it empty for the top level, and a record never moves under itself. With reorder too, a move stays among one parent’s children.
A rowOpens its record on a click or Enter, when the list has a form. A modified or middle click opens a new tab.row_click: none.
Row boxesShift and a click ticks or clears the range from the last box clicked.None.
Returning to a listThe sort, search and filters used last come back, for the session. Clearing them forgets them.remember: false.
A stored code in a columnShown as stored.labels: [{ value, label }] shows a label for each value.
list:
  search: { on_enter: true, prompt: Search posts }
  per_page_options: [25, 50, 100]
  exportable: true
  reorder: { column: position }
  tree: { source: acme.pages }
  row_click: none
  remember: false
  columns:
    title: { editable: true }
    status: { labels: [{ value: pub, label: Published }, { value: dr, label: Draft }] }

Messages and dialogs

WhereDefaultChange it
A success or info messageLeaves after five seconds.session.push_flash_sticky(..) keeps it until dismissed.
An error messageStays until dismissed.None.
A control with data-lat-confirmAsks before it acts.Omit the attribute.
A menuCloses on a click outside, a chosen link, or Escape.data-lat-keep-open.

Accounts and sessions

WhereDefaultChange it
A new passwordAt least 8 characters.[auth.password_policy] min_length.
A generated passwordTemporary: every screen leads to Preferences until it is replaced.lat admin create without --generate sets a password that is kept.
The top bar’s orderThe registry’s order. An operator drags the tabs into their own, kept with their preferences.“Reset the menu order” on Preferences.
Finding a page or a recordThe search button or Ctrl+K opens the command palette over menu entries, settings and the records of resources with a palette source.palette: <picker source> on a resource adds its records.
Failed sign-ins5 for a username from one address, 20 from one address, in 15 minutes.[auth] max_failures, max_failures_per_address, failure_window_secs.
A sessionEnds after 2 hours idle, or 12 hours.[auth] session_idle_timeout_secs, session_absolute_timeout_secs.
Expired sessionsRemoved at boot and hourly.None; lat admin purge runs it on demand.
[auth]
max_failures = 5
session_idle_timeout_secs = 7200

[auth.password_policy]
min_length = 12

Icons

The framework ships 197 icons. Name one in any descriptor that takes an icon: a settings item, a navigation entry, a resource.

SettingsItem::new("acme.blog", "Blog", fields).icon("newspaper")

An unknown name is refused when the application boots, with the nearest matches suggested, so a typo is caught before anybody sees a wrong glyph.

Adding one that is not here

The set is curated rather than exhaustive: upstream ships over two thousand glyphs, and shipping all of them would cost every consumer to serve almost nobody. A plugin registers what it needs:

registry.add_icon(IconReg::new(
    "map-pin-house",
    include_str!("../assets/icons/map-pin-house.svg"),
));

It is namespaced to your module automatically, so two plugins cannot collide, and you reference it as vendor.package:map-pin-house.

Flags

Fillable icons are closed shapes that read correctly filled, which is how a state is shown: a filled star means favourited. Filling is a render option, never part of the name, so there is no star-solid.

Mirrored icons flip under a right-to-left layout because their direction carries meaning. A clock does not mirror; a chevron does.

chrome (48)

Name
arrow-up-downmirrors in RTL
check
chevron-down
chevron-leftmirrors in RTL
chevron-rightmirrors in RTL
chevron-up
chevrons-leftmirrors in RTL
chevrons-rightmirrors in RTL
circle-alert
circle-checkfillable
circle-help
circle-x
columns-3
copy
download
ellipsis
ellipsis-vertical
external-linkmirrors in RTL
eye
eye-off
funnel
grip-vertical
history
info
layout-dashboard
layout-grid
listmirrors in RTL
loader-circle
log-inmirrors in RTL
log-outmirrors in RTL
menu
monitor
moon
panel-leftmirrors in RTL
pencil
plus
redo-2mirrors in RTL
refresh-cw
save
search
settings
sliders-horizontal
sun
trash-2
triangle-alert
undo-2mirrors in RTL
upload
x

people (12)

Name
contact
fingerprint
id-card
key
lock
lock-open
shield
shield-check
user
user-plus
user-round
users

content (34)

Name
bold
book-open
bookmarkfillable
braces
code
file
file-text
files
film
flagfillable
folder
folder-open
folder-plus
heading
heartfillable
image
images
italic
link
list-orderedmirrors in RTL
music
newspaper
notebook-pen
notepad-text
paperclip
pinfillable
quotemirrors in RTL
square-pen
starfillable
tag
tags
type
underline
unlink

communication (11)

Name
at-sign
bellfillable
bell-off
bell-ring
mail
megaphone
message-circle
message-square
phone
rss
sendmirrors in RTL

commerce (25)

Name
badge-percent
banknote
briefcase
building
building-2
calculator
chart-bar
chart-line
chart-pie
coins
credit-card
gift
landmark
package
percent
receipt
scale
shopping-bag
shopping-cart
store
ticket
trending-downmirrors in RTL
trending-upmirrors in RTL
truck
wallet

time-place (12)

Name
alarm-clock
calendar
calendar-days
clock
compass
globe
home
map
map-pin
map-pinned
navigation
timer

technical (27)

Name
bluetooth
bot
bug
camera
cloud
cloud-download
cloud-upload
cpu
database
git-branch
hard-drive
laptop
mic
plug
printer
qr-code
server
smartphone
sparkles
tablet
terminal
video
wand-sparkles
webhook
wifi
workflow
zap

objects (28)

Name
activity
award
blocks
box
boxes
brush
check-checkfillable
circlefillable
component
crown
droplet
flame
gauge
hammer
layers
layout-template
leaf
lightbulb
paintbrush
palette
puzzle
rocket
squarefillable
square-checkfillable
target
trianglefillable
trophy
wrench

API Reference

Every public type, function and trait carries doc comments.

cargo doc --workspace --no-deps --open