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
| Crate | Provides |
|---|---|
laterite-core | Config, errors, the database layer, migrations, modules. |
laterite-auth | Backend users, sessions, roles and permissions. |
laterite-admin | The admin router, list and form screens, settings. |
laterite-media | Content-addressed file storage. Optional. |
laterite-web | Static-site generation and page metadata. Optional. |
laterite-cli | The lat command. |
laterite-macros | The t!, tn! and tp! translation macros. |
| Section | Covers |
|---|---|
| Getting Started | Installing, configuring and running an application. |
| Extending Laterite | Each capability: what to write and what can be set. |
| Reference | The 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:
| Prompt | Value |
|---|---|
| Application name | Any text, such as Acme Blog. Its slug, acme-blog, names the crate, directory and database. |
| Display timezone | An IANA name; type to search. |
| Listen address | host:port. Default 127.0.0.1:8080. |
| Database | PostgreSQL, MySQL/MariaDB or SQLite, and its connection details. Offers to create the database. |
| First administrator | Username, 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(())
}
}
| Method | Runs | Does |
|---|---|---|
migrations | Before register, in dependency order. | Applies the module’s own migrations. |
register | Once every module has migrated. | Contributes resources, settings models, permissions, menu entries, hooks. |
boot | Once 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.
| Layer | Source |
|---|---|
| 1 | default.toml. Required. |
| 2 | <APP_ENV>.toml. Optional. APP_ENV defaults to development. |
| 3 | local.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]
| Key | Description |
|---|---|
secure_cookie | Sets the Secure flag on cookies. true behind HTTPS. |
timezone | Default display timezone until an operator picks their own. See Dates and Timezones. |
locale | Default admin UI language. Falls back to en. |
path | URL path the admin mounts under. |
trusted_proxies | CIDR 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. |
paths | Remounts a module’s screens, or one screen, at another path. Two claims on one path abort the boot. |
storage | The directory files the admin keeps are stored under, relative to the working directory unless absolute. Uploaded import files go to private beneath it. |
secret | The 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]
| Key | Description |
|---|---|
session_idle_timeout_secs | Quiet time before a session ends, from the last request. Default 7200. |
session_absolute_timeout_secs | Ceiling from login, never extended. Default 43200. The earlier name session_ttl_secs is still read. |
remember_duration_secs | How long an unused “stay signed in” credential lasts, from its last use. Default 1209600. |
max_failures | Failed sign-ins from one address for one username before that address is locked out of it. Default 5. |
max_failures_per_address | Failed sign-ins from one address, any username, before the address is locked out. Default 20. |
failure_window_secs | Window the failures are counted over. Default 900. |
password_policy.min_length | The 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
| Setting | Where |
|---|---|
| Branding: name, colour-mode default, logo | Settings → Branding |
| Preferences: an operator’s own timezone | Preferences. 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.
| Flag | Description |
|---|---|
--dry-run | Print 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();
| Kind | Rendered as |
|---|---|
text | The stored value. Default. |
datetime | 14 Aug 2026, 15:53, in the viewer’s timezone. |
date | The date, in the viewer’s timezone. |
time | The time, in the viewer’s timezone. |
yes_no | Yes or No. |
A value that does not parse is shown as stored.
Choose the timezone
Resolved per request:
- The operator’s own preference, set under Preferences.
- Otherwise
backend.timezone, an IANA name such asAsia/Kolkata. Defaults toUTC.
“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.
| State | Where | Changing it costs |
|---|---|---|
| Which plugins are compiled in | plugins/plugins.toml | a rebuild |
| Which of them are enabled | the database | a 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"
| Command | Description |
|---|---|
lat plugin add | Records a plugin and syncs. |
lat plugin remove | Drops the entry. --delete removes the folder; a link only loses its link. |
lat plugin sync | Regenerates plugins-manifest/ from the list. Run after editing by hand. |
lat doctor | Checks 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
| Work | Licence |
|---|---|
| IBM Plex Sans, IBM Plex Mono | SIL Open Font License 1.1 |
| Space Grotesk | SIL Open Font License 1.1 |
| Lucide, a subset | ISC, with an MIT annex for icons derived from Feather |
| htmx | Zero-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.
| Builder | Description |
|---|---|
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?;
| Item | Does |
|---|---|
PreferencesModel | The 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_for | One operator’s model, or the default when they have none; a write for one operator. |
get_for, set_for | The same, as raw JSON. |
| Audit | A deployment setting’s save is audited; an operator’s own preference is not. |
Link a screen into the settings menu
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
| Change | Migration |
|---|---|
| Add a field | None. A missing key deserializes to the field’s default. |
| Remove a field | None. A stale key is ignored. |
| Rename a field | Read 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.
| Role | Holds |
|---|---|
ROLE_ADMIN, Administrator | Every permission that names no role, plus any naming it. The first operator holds it. |
ROLE_EDITOR, Editor | Only 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"),
);
| Where | Effect |
|---|---|
Resource::permission | Every route the resource mounts: 403 without the grant, the login screen when signed out. Unset leaves it open to any operator. |
SettingsItem::permission | Hides the item from the settings menu and refuses its form. |
ScreenReg::new(path, permission, ..) | Required. Enforced before the handler runs. |
ToolbarButton::require | The button is not rendered without the grant. |
How a grant matches
| Grant | Covers |
|---|---|
posts.approve | Exactly posts.approve. |
posts.* | posts.approve, posts.tags.create; not the bare posts. |
* | Everything. |
A superuser passes every check.
Resolve for one operator
- A superuser: allowed.
- The operator’s own override for that permission: Deny refuses, Allow grants.
- 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
| Permission | Grants |
|---|---|
backend.manage_users | The backend users list and per-user overrides. |
backend.manage_roles | The roles list and the role editor. |
backend.manage_plugins | The plugins screen. |
backend.view_audit_log | The audit log. |
backend.view_access_log | The 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
| Action | Where | Effect |
|---|---|---|
| Create | New on the list | Username, email, name, roles. A temporary password is generated and shown once; the account must replace it at first sign-in. |
| Assign roles | The edit screen | Only roles granting no more than you hold; never your own. |
| Override a permission | The edit screen | Allow or Deny one permission regardless of roles. |
| Deactivate | The edit screen | Signs the account out everywhere and refuses sign-in; reversible. |
| Reset password | The edit screen | A new temporary password shown once; every device signed out. Never your own account, never one holding more than you. |
| Unlock | The edit screen | Clears 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),
];
| Builder | Rule |
|---|---|
.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
| Outcome | Response |
|---|---|
| Refused | 422, the form re-rendered with its values. An htmx submit swaps the form; a plain one gets the whole page. |
| Saved | A flash, then the list: HX-Redirect for htmx, 303 otherwise. |
| API | 422 with { "field": ["message", ...] }. |
Errors
Admin handlers return Result<_, laterite_admin::AdminError>.
| Variant | Status | Page |
|---|---|---|
NotFound | 404 | Not found |
Forbidden | 403 | Forbidden |
Internal(_) | 500 | Something 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)))
}
| From | To |
|---|---|
CoreError::NotFound | 404 |
CoreError::Forbidden, CoreError::Unauthorized | 403 |
AuthError | Through CoreError, the same mapping |
| A database error, a render failure, a panic | 500 |
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);
| Where | Call |
|---|---|
| Rust | t!("..."); tn!("one", "many", n) for plurals. Both build a Text, localized at render. |
| Template | shell.t("..."); shell.tf(...) with numeric placeholders. |
| Descriptor labels | Already 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
- The operator’s preference, from Preferences.
- The browser’s
Accept-Language. backend.locale.- 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")),
);
}
ScreenReg | Description |
|---|---|
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>
| Method | Does |
|---|---|
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") }} ▾</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>
| Method | Does |
|---|---|
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") }}'>×</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>
| Attribute | On | Does |
|---|---|---|
data-lat-dialog="id" | Any control | Opens the dialog.lat-dialog with that id and focuses its first field. |
data-lat-dialog-set="field=value;other=value" | The opener | Fills the named fields before opening. |
data-lat-dialog-close | A control inside | Closes the dialog. Escape and a click on the backdrop close it too. |
.lat-dialog--wide | The dialog | 760px 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>
| Class | Renders |
|---|---|
lat-split | A 260px pane beside the content; lat-split--aside puts a 340px pane after it. One column under 900px. |
lat-pane | A framed block; lat-pane__head, lat-pane__bar, lat-pane__body, lat-pane__foot stack inside it. |
lat-nav, lat-nav__item, .is-active | A 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);
| Element | Builders | Renders |
|---|---|---|
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.
Link to yourself
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"));
| Builder | Does |
|---|---|
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.
| Method | Does |
|---|---|
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 key | Default | Does |
|---|---|---|
under | the top bar | The key of the section the entry sits under. |
icon | none | An icon name from the icon set. |
order | 0 | Sorts among siblings, lowest first; equal orders sort by label. |
after, before | none | Sits 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: false | Mounts the routes with no menu entry. |
NavItem | Does |
|---|---|
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. |
| Behaviour | Does |
|---|---|
| Permission | A 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. |
| Sidebar | On a section’s pages, its entries show in the context sidebar with the current one marked. |
| Breadcrumbs | On 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 Settings | Always first and last. |
| The operator’s own order | Dragging 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. |
| Keys | A 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")
| Key | Does |
|---|---|
palette | A registered picker source. The palette finds this resource’s records through it and opens each at its edit page. Absent keeps the records out. |
| Behaviour | Does |
|---|---|
| Opening | The search button in the top bar, or Ctrl+K (Cmd+K). Without scripting the button is /palette, the same finder as a page. |
| Finding | Menu 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. |
| Keys | Up 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
| Column | Holds |
|---|---|
| Last seen, First seen | When the URL was last and first requested. |
| Method, Path | The request; the path is kept to 512 characters, without its query string. |
| Status | 404. |
| Hits | How many times. |
| Behaviour | Does |
|---|---|
| Writing | The admin’s 404 fallback counts the request before answering. |
| Viewing | Settings › System › Request Log, for an operator holding backend.view_request_log. |
| Clearing | Rows 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
| Key | Description |
|---|---|
entity | The table both screens read and write. Required. |
path | Where the resource mounts, under your module’s namespace. Required. |
title | The navigation label, and the default title of both screens. Required. |
permission | The permission gating it. Required once a form is present. |
id_field | The primary key column. Default id. |
list | The list screen. Required. |
form | The create and edit screen. Optional. |
import | true, or { permission: ... }, to let records be imported into the resource. Needs form. Optional. |
relations | Related 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 }] }
| Type | Keys |
|---|---|
text | input (text, email, tel, number, url), placeholder, counter. A number input adds min, max, step; a url input adds copy. |
textarea | rows, placeholder, grow, counter |
select | options: a list of { value, label }; search |
radio | options |
checklist | options, select_all, search, expand. See Checklist. |
reference | source. The chosen id is stored as a number. |
id | None. A record id held as a number, as a hidden input; a relation’s foreign key is one. |
repeater | fields, min_items, max_items, display, summary_field, reorder, duplicate |
password | reveal |
switch, date | None |
A select filter | options |
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
| Key | Values | Default |
|---|---|---|
options | A list of { value, label, note } and { group, options }, nested to any depth. | None |
select_all | auto, true, false | auto: past 10 choices |
search | auto, true, false | auto: past 20 choices |
expand | auto, all, none | auto: 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 }
| Key | On | Values | Default |
|---|---|---|---|
enter | The form | submit, off | submit |
confirm_leave | The form | true, false | true: leaving with changes unsaved asks first |
focus | The form | auto, off | auto: a new record opens with the cursor in its first field, a refused save in the first field it refused |
enter | A field | submit, off, next | The form’s |
counter | text, textarea | auto, true, false | auto: with a max_length rule, shown once four fifths of it are used |
grow | textarea | true, false | true: as tall as its text, from rows up to most of the window |
reveal | password | true, false | true: a button shows what was typed |
search | select | auto, true, false | auto: past 10 choices, the dropdown is searched. An option with an empty value is what clearing chooses. |
display | repeater | auto, inline, list | auto: past 3 fields a row, rows collapse to the line naming them |
reorder | repeater | true, false | true: each row moves up and down |
duplicate | repeater | true, false | true: 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]" }
| Key | Value |
|---|---|
preset | The field followed, or { field, type }. Follows it as it is typed, until this field is edited; emptying this field hands it back. |
preset.type | slug (default): hello-world. url: /hello-world. file: spaces to hyphens. exact: as typed. |
trigger.action | show, hide, enable, disable, empty, fill[value], several joined by |. A hidden field is left out of the submission. |
trigger.field | The field watched. name[] watches every ticked value of a checklist. |
trigger.condition | checked, 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.
| Key | Description |
|---|---|
span | The 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. |
break | true 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()
}
| Key | Default | Does |
|---|---|---|
label | required | One related record, singular: the New or Attach button, the child form’s title. |
title | the name, humanized | The section heading. |
entity | required | The related table. |
foreign_key | one of the two | Has-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. |
pivot | one of the two | Many-to-many: the table joining the two, keys below. |
list | required | The section’s list: the same keys as a resource’s list. Its columns are the related table’s. |
form | required for has-many | Has-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. |
permission | the parent’s | A permission the operator must also hold. |
| Pivot key | Default | Does |
|---|---|---|
table | required | The pivot table. |
parent_key | required | The column holding the parent’s id. Written by the framework from the URL. |
related_key | required | The column holding the related record’s id. Chosen in the picker on Attach, fixed afterwards. |
source | required | The registered picker source Attach searches. |
id_field | id | The pivot row’s id column. |
| Path | Does |
|---|---|
/products/7/edit | The parent’s form, then a section per relation. |
/products/7/variants | The section’s list on its own, sorted and paged. |
/products/7/variants/new | Has-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/edit | Has-many: the related record 3. Many-to-many: pivot row 3’s fields. Only under its own parent. |
/products/7/variants/delete | Has-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/reorder | Has-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
| Key | Where | Does |
|---|---|---|
| Enter | A single-line field | Submits the form. |
| Enter | A textarea | A newline. |
| Cmd/Ctrl+Enter | Anywhere in a form | Submits it. |
| Enter | A picker’s search box, a repeater row | Acts 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, End | A picker’s list | Move the highlight. Down on a closed list opens it. |
| Escape | A menu, a dialog | Closes 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
| Control | Behaviour |
|---|---|
| Column header | Sorts; a second click flips it. Only a declared, sortable column sorts. |
| Column | ListColumn 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 code | labels: [{ value: login_success, label: Signed in }] shows the label for the value, whatever the column’s type. A value without one shows as stored. |
| Row | Opens 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 boxes | Shift and a click ticks, or clears, every box between it and the last one clicked. |
| Search | Asks 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). |
| Filters | boolean, 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 setup | Each operator chooses which columns show, and the page size. Picking every column clears the choice. |
| Export | On a list that calls .exportable(): CSV or JSON of the current query without paging. Over 20,000 rows is refused. |
| Pager | Keeps the sort, search and filters. |
| Page size | per_page. per_page_options offers a choice in the list setup, remembered per operator. |
| The query | The 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();
});
| Label | Does |
|---|---|
go | Names the confirming button. Default OK. |
cancel | Names 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">
| Attribute | Does |
|---|---|
data-lat-widget="form" | Asks before leaving with changes unsaved. |
data-lat-refused | The form holds a submission that was not saved: it opens changed, with the cursor in the first field carrying an error. |
data-lat-focus | first opens with the cursor in the first field; off leaves it alone. |
data-lat-confirm-leave="off" | Never asks. |
data-lat-counter | On 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-type | On 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-condition | On 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();
| Event | Detail | Cancelable |
|---|---|---|
repeater:before-add | count | Yes |
repeater:added | row, index, count | No |
repeater:before-remove | row, index, count | Yes |
repeater:removed | index, count | No |
repeater:moved | row, from, to, count | No |
repeater:duplicated | row, index, count | No |
ref-picker:changed | id, label, previous | No |
ref-picker:cleared | previous | No |
selection:changed | ids, count | No |
checklist:changed | values, count, total | No |
flash:shown | text, level | No |
flash:dismissed | text | No |
confirm:opened | text | No |
confirm:confirmed, confirm:cancelled | None | No |
copy:copied | value | No |
reveal:shown, reveal:hidden | None | No |
preset:filled | from, value | No |
trigger:changed | field, met | No |
| Controller | Methods |
|---|---|
| Repeater | add(), remove(index), move(from, to), duplicate(index), count() |
| Record picker, searched select | value(), label(), choose({ id, label }), clear() |
| Checklist | values(), count(), set(values), all(), none(), search(text) |
| Form | changed(), settle(): takes the form as it stands for saved |
| Password reveal | show(), hide() |
| Helper | Does |
|---|---|
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.
| Normalizer | Effect |
|---|---|
Lowercase | Case. |
Diacritics | NFKD, then combining marks dropped. |
Punctuation | Removed. |
Whitespace | Collapsed. |
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.
| Expander | Effect |
|---|---|
Numerals | English number words and digits, both ways, on whole tokens: 7 rivers finds seven rivers. |
Aliases | Pairs 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
| Surface | Profile |
|---|---|
| Picker source | TableSource::with_search(profile). |
| List search box | The 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.
| Scope | Seam |
|---|---|
| One field on a form | A field type |
| One entity’s storage shape | A persister |
| Any record, cross-cutting | A listener |
| Another module’s screen | A 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()))))
}
}
| Method | Role |
|---|---|
view_key | The name a descriptor uses. A dotted vendor.name; a taken key aborts the boot. |
view_model | The submitted or stored value as a view-model. |
render_default | The markup. |
to_attr | The submitted string as the typed value that reaches the database. Err refuses the save. |
option_keys | The keys the type reads from a field’s entry. Any other key is refused by name. |
submitted | For 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_options | Those 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
| Rule | How |
|---|---|
| Works with no configuration | Every key has a default chosen for the common case: type: acme.money alone renders a usable control. |
| Each default can be turned off | One key per behaviour, named for what it does, read from the field’s entry. |
| Its keys are declared | option_keys lists them, so a typo is refused by name. |
| Enter stays inside it | A root marked data-lat-enter-scope when the control holds an input of its own. See Admin Interactivity. |
| It says what it did | lat.emit(root, 'acme.money:changed', detail); a before- event is cancelable. |
| It can be driven | The island’s initialiser returns its controller; lat.get(el) finds it. |
| It is listed | One row per behaviour in your plugin’s docs: the default, and the key that changes it. See Behaviours. |
| Works before scripts load | The 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>;
}
| Method | Default |
|---|---|
load | None. The pre-write row, for listeners. |
delete | Refuses. |
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
| Field | Meaning |
|---|---|
resource.path | The resource’s path as declared, relative to the admin root (/products). |
resource.base_path | Where its list mounted (/admin/products). |
resource.label | Its menu label. |
resource.permission | The permission the feature’s routes are gated by. |
resource.form | Its 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"))
}));
| Key | Does |
|---|---|
target | The 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 closure | Takes the resource as declared, returns it changed. Anything the builders set: columns, fields, relations, import, the palette source, the menu placement. |
| Order | Hooks 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
}
| Hook | Runs | Return |
|---|---|---|
before_save | Inside the transaction | Err(ErrorBag) refuses and rolls back; the operator sees the messages on their fields. |
after_save | After commit | Side effects. Cannot roll back. |
before_delete | Inside the transaction | Err(Text) refuses and rolls back. |
after_delete | After commit | Side 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 listener | Is |
|---|---|
event | The 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)
}
}))
}
}
| Part | Rule |
|---|---|
NAME | The module’s id, then the fact in the past tense: acme.blog.published. Two types under one name stop the boot, naming both. |
| Payload | Serde in both directions. #[non_exhaustive] with a constructor, so a field added later breaks no listener. |
| Moment | After the write has committed. |
| Where | The bus |
|---|---|
| A screen or a public route | ctx.events() on RouteCtx |
Routes added with Bootstrap::extend | ctx.events() on BootstrapCtx |
| A listener | cx.events() |
Beside an AuthService | auth.events() |
Framework events
Types are in laterite_auth::events.
| Name | Type | Fields |
|---|---|---|
auth.signed_in | SignedIn | user_id, username, ip_address, user_agent |
auth.sign_in_failed | SignInFailed | user_id (none for an unknown username), username, ip_address, user_agent |
auth.locked_out | LockedOut | user_id, username, ip_address, user_agent |
auth.signed_out | SignedOut | user_id |
auth.password_changed | PasswordChanged | user_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
| Field | Value |
|---|---|
| When | The time of the change. |
| Operator | The username, kept on the entry after the account is removed. A system change records the process name. |
| Action | A dot-keyed name: backend.role.update, backend.plugin.disable. |
| Target | A type, id and name, when the action has one. |
| Change | Recorded |
|---|---|
| A role created or edited | Yes |
| A user’s permissions changed | Yes |
| A password changed | Yes, as backend.user.password_change, never the password |
An operator created from setup or lat admin create | Yes, as backend.user.create, credited to the process |
| A plugin enabled or disabled | Yes |
| A settings model saved | Yes, without the contents |
| A record created or edited through a resource form | Yes |
| An operator’s own preferences | No |
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:
| Event | Recorded when |
|---|---|
login_success | A sign-in succeeded. |
login_failure | A password was wrong, or the username unknown. |
locked_out | An attempt was refused by the lockout. |
logout | An operator signed out. |
password_changed | An 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.
| Gate | Passes when |
|---|---|
| Origin | Sec-Fetch-Site: same-origin, or Origin matches app.url. In development the request Host stands in. |
| Token | The 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?;
| Argument | Value |
|---|---|
user_id | The account whose password changes. |
new_password | Refused under password_policy.min_length (default 8). |
keep_token | The session to keep signed in, or None to end all of them. |
actor | Who 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, ¤t, &new_password, &session_token, &ctx)
.await?;
| Error | Returned when |
|---|---|
AuthError::InvalidCredentials | current does not match. Counts as a failed sign-in. |
AuthError::TooManyAttempts | The account is locked out. |
AuthError::Refused | The 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
| Event | The 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"
| Term | Meaning |
|---|---|
| Blob | Bytes, named by their BLAKE3 hash. |
| Record | A file with a stable id, pointing at a blob. |
| Link | Attaches 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.
| Variable | Value |
|---|---|
upload_id | The input’s id, for its label. |
upload_accept | An accept list, or empty. |
upload_multiple | true for several files. |
upload_url | Empty: 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_fields | Fields sent before each file (folder=3;kind=image). |
upload_hint | A line under the prompt. |
data-upload-submit | On 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."}]}
| Event | Detail |
|---|---|
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"));
}
| Disk | Serving |
|---|---|
| Public | Files served directly at the path. |
| Private | No 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"
| Key | Default | Meaning |
|---|---|---|
bucket | The bucket. | |
region | auto | The region name. |
endpoint | AWS | The store’s URL (R2, MinIO, B2, Spaces, Wasabi). |
access_key, secret_key | the SDK’s default chain | Static credentials. |
prefix | "" | A key prefix every blob goes under. |
public_url | none | The origin blobs are reachable at unsigned; unset, the disk is private and serves through presigned URLs. |
path_style | true with an endpoint | endpoint/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": []}
| Step | On a disk that presigns | On any other disk |
|---|---|---|
direct | A PUT URL under quarantine/<32 hex>/<name>, good for an hour. | {"direct": false}: the uploader posts the file instead. |
complete | The 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.
| Method | Default | On S3Disk |
|---|---|---|
StorageDriver::presigned_put(path, content_type, expires_in) | None | A presigned PUT. |
StorageDriver::copy(from, to) | get then put | A 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).
| Permission | Grants |
|---|---|
media.browse | The tab. |
media.upload | Upload into it. |
media.manage | Rename, retitle, move, and make or remove folders. |
media.delete | Delete a file no record links to. |
media.delete_linked | Delete 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?;
| Call | Refused when |
|---|---|
folder::create, folder::rename | The name has a slash or a control character, or a sibling has it. |
folder::move_to | The target is the folder or below it. |
folder::delete | The 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>:
| Header | Value |
|---|---|
ETag | The hash; If-None-Match answers 304. |
Cache-Control | public, max-age=31536000, immutable |
X-Content-Type-Options | nosniff |
Content-Disposition | inline, 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>
| Part | Value |
|---|---|
e | Expiry, seconds since the epoch. |
t | HMAC-SHA256 over the hash and the expiry, with backend.secret. |
Cache-Control | private, no-store |
| Bare, expired, altered or retargeted | 404 |
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 }
| Option | Default | Meaning |
|---|---|---|
mode | file | image offers images alone and shows thumbnails. |
max | 1 | How 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=.
| Event | Detail |
|---|---|
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
| Field | Meaning |
|---|---|
x, y, w, h | The area kept, in the image’s own pixels, clamped to it. |
mode | new (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">
| Builder | Default | Meaning |
|---|---|---|
Preset::new(name) | The name in the URL. | |
.width(px), .height(px) | unbounded | The box. |
.fit() | yes | Inside 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::Auto | Jpeg | Png |
.quality(1..=100) | 82 | JPEG quality. |
| Built in | Box | Used by |
|---|---|---|
thumb | 320 × 320, fit | The library grid. |
preview | 1200 × 1200, fit | The 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>".
| Limit | Value |
|---|---|
| Side | 12 000 pixels, read from the header before decoding. |
| Pixels | 50 million. |
| Decode memory | 512 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
| Flag | Default | Meaning |
|---|---|---|
--grace <hours> | 24 | A record younger than this is never garbage. |
--dry-run | off | Prints id, disk, hash and name; removes nothing. |
--storage <dir> | backend.storage | Where 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"))));
}
| Key | Default | Does |
|---|---|---|
import: true | off | Mounts the pages at /admin/products/import and shows an Import button beside the list’s export menu. |
import.permission | the resource’s | The permission an operator needs to import. |
backend.storage | storage | The directory the uploaded file is kept under, in private, as a media record outside the library. |
DiskReg named private | none | Keeps 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
| Page | Does |
|---|---|
/import | The 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 file | Every row through the rules and conversions with nothing written; the report says what an import would do. |
| Import now | The 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.
| Option | Default | Does |
|---|---|---|
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)) | none | A row whose key is already there is passed over and noted. |
.duplicates(Duplicates::update(key)) | none | A row whose key is already there updates that record; fields the file does not carry keep their values. |
.keep_blank(bool) | true | On an update, a blank matched cell leaves the record’s value as it is. false clears the field. |
.dry_run(bool) | false | Nothing is written; the counts say what an import would do. |
.first_row_titles(bool) | true | The first row names the columns and is passed over. |
.delimiter(u8) | b',' | The byte between values. |
.titles(vec) | none | The file’s titles, so a later file with the same titles is proposed this matching. |
.media(id) | none | The 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(),
}
| Field | Meaning |
|---|---|
status | Running, 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, errors | Row counts. On a dry run, what an import would do. |
notes | One per message: row (the line in the file), kind (Error or Skipped), field, message (a Text, localized where it renders). |
spec | What ran, dry_run included. |
resource, media_id, actor_label, started_at, finished_at | What 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 write | Stored as | Helper |
|---|---|---|
bool | integer 0/1 | bool_col in schema; binds as bool; read with AnyRowExt::get_bool |
DateTime<Utc> | text, RFC 3339 at fixed precision | Convert at the query boundary |
| JSON object or array | text | serde_json at the boundary; no in-database JSON operators |
| Key, id or code column | varchar(255) | key_col; .text() only for unindexed prose |
| Any string read | String | AnyRowExt::get_text, get_text_opt; never try_get::<String> |
| Unsigned integer | The next wider signed | Automatic |
| New row id | i64 | query::insert_returning_id |
| Cast to string | char or text | Expr::col(c).cast_as(Alias::new(text_cast(db.backend))) |
| Insert, ignoring a duplicate | query::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
| Backend | Behaviour |
|---|---|
| SQLite | Foreign keys are enforced only with PRAGMA foreign_keys = ON; sqlx sets it. Do not disable it. |
| MySQL | varchar 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"
| Type | Role |
|---|---|
Meta | The shared <head> tags: title, description, canonical URL, Open Graph and Twitter card. |
StaticSite | Collects 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 page | File written | Served as |
|---|---|---|
/ | dist/index.html | / |
/features/ | dist/features/index.html | /features/ |
/get-started | dist/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();
| Method | Description |
|---|---|
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")?;
| Method | Description |
|---|---|
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.
| Setting | Default | Meaning |
|---|---|---|
backend.paths."laterite.theme/themes" | /themes | Where 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
| Key | Type | Default | Meaning |
|---|---|---|---|
name | string | "" | The theme’s display name. |
author | string | "" | Who made it. |
homepage | string | "" | The author’s site. |
description | string | "" | One line about the theme. |
preview | string | none | A URL for the image on the theme list; the initials stand in without one. |
parent | string | none | The theme whose templates fill in for the ones this theme lacks. |
require | list of module ids | [] | The modules the theme’s templates need. |
form | form descriptor | none | The 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
| Property | Standard |
|---|---|
| Size | 1200 × 800 pixels. Minimum 600 × 400. |
| Aspect | 3:2, exactly. |
| Format | PNG or JPEG. WebP is accepted. |
| Weight | Under 300 KB. |
| Subject | The home page as the theme renders it, cropped from the top of the viewport at 1200 pixels wide. |
| Framing | No browser chrome, device frame, drop shadow, border or rounded corners: the image edges are the page edges. |
| Overlays | No added text, badges, logos or colour swatches. |
| Content | Representative content and real images; no placeholder blocks. |
| Colour scheme | Light, unless the theme is dark only. |
| Transparency | None: an opaque image. |
| Shown at | 184 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
| Kind | Stored as | Holds |
|---|---|---|
Kind::Layout | layout | The frame a page renders inside. |
Kind::Page | page | A page served at its URL. |
Kind::Partial | partial | A fragment a layout or page includes. |
Kind::Content | content | A block of HTML, Markdown or text. |
Kind::Asset | asset | A 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
}
| Key | Default | Meaning |
|---|---|---|
url | required | The URL pattern. |
layout | none | The 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. |
hidden | false | Served only to an operator previewing the theme. |
| Pattern | Matches |
|---|---|
/blog | /blog, in any case |
/blog/:slug | one segment, bound to slug |
/blog/:slug? | the segment or nothing |
/blog/:page?1 | the 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>
| Name | Gives |
|---|---|
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 layout | Where 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, ¶ms);
}
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>
| Name | Gives |
|---|---|
this.page | The page’s settings and name. |
this.params | The URL’s parameters. |
this.theme | code and name. |
this.layout | The layout the page named. |
this.url | The request path. |
this.preview | Whether an operator is previewing. |
head | The title, description, canonical, Open Graph and robots tags from the page’s settings; a setting may carry an expression. |
| Page at | Serves |
|---|---|
/404 | An unmatched URL, with status 404. |
/error | A page that failed to render, with status 500. |
| URL | Serves |
|---|---|
/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?;
| Call | Refused when |
|---|---|
create_theme | The code is taken, is not lowercase letters, digits and hyphens, or the parent is missing or leads back to the theme. |
update_theme_config | The theme is locked, or the parent is missing or leads back. |
save_template, delete_template | The theme is locked, or the path is not a template path. |
delete_theme | The 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:
| Kind | Name |
|---|---|
Kind::Layout | layouts/default.htm |
Kind::Page | pages/home.htm |
Kind::Partial | partials/nav.htm |
Kind::Asset | assets/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 %}
| Name | Gives |
|---|---|
{% 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. |
theme | code, 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>
| Name | Gives |
|---|---|
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.properties | The attached values, {{ :name }} resolved from the URL and defaults filled. |
cx.db, cx.params, cx.path, cx.theme, cx.preview | The 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') }}
| Name | Gives |
|---|---|
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:
| Field | Meaning |
|---|---|
| Show visitors the maintenance page | On, a visitor gets status 503 with Retry-After: 600. |
| Message | The plain notice’s text, when the theme has no page at /maintenance. |
| Page at | Serves |
|---|---|
/maintenance | Every 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.
| Flag | Default | Meaning |
|---|---|---|
--out | dist | The output directory. |
--base-url | the application’s app.url, else one derived from server.listen | The 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 field | Meaning |
|---|---|
--replace, Replace templates | Replace the templates and the config the theme already has; each replaced template keeps a revision. Without it, an existing template is skipped. |
code | The 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
| Where | Default | Change it |
|---|---|---|
| A single-line field | Enter submits the form. | enter: off on the form; enter: off or enter: next on a field. |
| A textarea | Enter is a newline; Cmd/Ctrl+Enter submits. | None. |
| A picker or a repeater row | Enter acts inside it. | data-lat-enter-scope and data-lat-enter-target on a widget of your own. |
| A field’s width | The whole row. | span: 1/2, 1/3, 2/3, 1/4, 3/4; break: true. |
| A narrow window | Under 1100px a quarter is a half; under 768px every field is full. | None. |
| A key the type does not read | Refused by name. | None. |
| A repeater | Past 3 fields a row, rows collapse to the line naming them; fewer stay open. | display: list or inline. |
| A repeater row | Moves up and down, copies into a new row beneath it, and is removed. | reorder: false, duplicate: false. |
| A refused save | The 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 record | Opens with the cursor in its first field. | focus: off. |
| Leaving with changes unsaved | Asks first, with the cursor on Stay. Typing and undoing it is not a change. | confirm_leave: false. |
| Save and continue editing | Offered 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 rule | The control holds the limit, and a count appears once four fifths of it are used. | counter: true or false. |
| A textarea | As tall as its text, from rows up to most of the window. | grow: false. |
| A password field | A button shows what was typed. Sending the form hides it again. | reveal: false. |
A field with preset | Follows the named field as it is typed, until edited. | Omit the key. |
| A select past 10 choices | Searched: a box that filters the choices as you type. | search: true or false. |
| A picker or a searched select | Opens 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 trigger | Shows, 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 choices | Offers select all and select none. | select_all: true or false. |
| A checklist past 20 choices | Offers a search box. | search: true or false. |
| A checklist past 10 choices, in groups | A group with every box ticked, or none, starts closed. A group partly ticked starts open. | expand: all or none. |
| A checklist group | Its box ticks the whole group, and shows a dash when some are ticked. | None. |
| A checklist being searched | Select 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
| Where | Default | Change it |
|---|---|---|
| Search | Asks as you type. | search: { on_enter: true }; search: { enabled: false }. |
| Columns and page size | Each operator’s choice is remembered. | per_page sets the size; per_page_options offers a choice of sizes. |
| A column | Sortable, visible, read-only. | sortable: false, invisible: true, editable: true. |
A cell with editable: true | The 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. |
| Export | Off. | exportable: true. |
| Deleting rows | Off. | deletable: true; it asks before it deletes. |
| Rows in an order of their own | Off. | 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 tree | Off. | 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 row | Opens 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 boxes | Shift and a click ticks or clears the range from the last box clicked. | None. |
| Returning to a list | The sort, search and filters used last come back, for the session. Clearing them forgets them. | remember: false. |
| A stored code in a column | Shown 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
| Where | Default | Change it |
|---|---|---|
| A success or info message | Leaves after five seconds. | session.push_flash_sticky(..) keeps it until dismissed. |
| An error message | Stays until dismissed. | None. |
A control with data-lat-confirm | Asks before it acts. | Omit the attribute. |
| A menu | Closes on a click outside, a chosen link, or Escape. | data-lat-keep-open. |
Accounts and sessions
| Where | Default | Change it |
|---|---|---|
| A new password | At least 8 characters. | [auth.password_policy] min_length. |
| A generated password | Temporary: every screen leads to Preferences until it is replaced. | lat admin create without --generate sets a password that is kept. |
| The top bar’s order | The 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 record | The 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-ins | 5 for a username from one address, 20 from one address, in 15 minutes. | [auth] max_failures, max_failures_per_address, failure_window_secs. |
| A session | Ends after 2 hours idle, or 12 hours. | [auth] session_idle_timeout_secs, session_absolute_timeout_secs. |
| Expired sessions | Removed 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-down | mirrors in RTL | |
check | ||
chevron-down | ||
chevron-left | mirrors in RTL | |
chevron-right | mirrors in RTL | |
chevron-up | ||
chevrons-left | mirrors in RTL | |
chevrons-right | mirrors in RTL | |
circle-alert | ||
circle-check | fillable | |
circle-help | ||
circle-x | ||
columns-3 | ||
copy | ||
download | ||
ellipsis | ||
ellipsis-vertical | ||
external-link | mirrors in RTL | |
eye | ||
eye-off | ||
funnel | ||
grip-vertical | ||
history | ||
info | ||
layout-dashboard | ||
layout-grid | ||
list | mirrors in RTL | |
loader-circle | ||
log-in | mirrors in RTL | |
log-out | mirrors in RTL | |
menu | ||
monitor | ||
moon | ||
panel-left | mirrors in RTL | |
pencil | ||
plus | ||
redo-2 | mirrors in RTL | |
refresh-cw | ||
save | ||
search | ||
settings | ||
sliders-horizontal | ||
sun | ||
trash-2 | ||
triangle-alert | ||
undo-2 | mirrors 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 | ||
bookmark | fillable | |
braces | ||
code | ||
file | ||
file-text | ||
files | ||
film | ||
flag | fillable | |
folder | ||
folder-open | ||
folder-plus | ||
heading | ||
heart | fillable | |
image | ||
images | ||
italic | ||
link | ||
list-ordered | mirrors in RTL | |
music | ||
newspaper | ||
notebook-pen | ||
notepad-text | ||
paperclip | ||
pin | fillable | |
quote | mirrors in RTL | |
square-pen | ||
star | fillable | |
tag | ||
tags | ||
type | ||
underline | ||
unlink |
communication (11)
| Name | ||
|---|---|---|
at-sign | ||
bell | fillable | |
bell-off | ||
bell-ring | ||
mail | ||
megaphone | ||
message-circle | ||
message-square | ||
phone | ||
rss | ||
send | mirrors 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-down | mirrors in RTL | |
trending-up | mirrors 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-check | fillable | |
circle | fillable | |
component | ||
crown | ||
droplet | ||
flame | ||
gauge | ||
hammer | ||
layers | ||
layout-template | ||
leaf | ||
lightbulb | ||
paintbrush | ||
palette | ||
puzzle | ||
rocket | ||
square | fillable | |
square-check | fillable | |
target | ||
triangle | fillable | |
trophy | ||
wrench |
API Reference
Every public type, function and trait carries doc comments.
cargo doc --workspace --no-deps --open
| Crate | Published |
|---|---|
lateritecmf | https://docs.rs/lateritecmf |
laterite-core | https://docs.rs/laterite-core |
laterite-auth | https://docs.rs/laterite-auth |
laterite-admin | https://docs.rs/laterite-admin |
laterite-media | https://docs.rs/laterite-media |
laterite-web | https://docs.rs/laterite-web |
laterite-cli | https://docs.rs/laterite-cli |
laterite-macros | https://docs.rs/laterite-macros |