Modules

Entry script and ctx

Entry script

init(ctx) runs once per load and registers everything; the other functions are called later with plain values and return Ok(...) or Err.

fn init(ctx) -> Result<(), Error> {
  ctx.db.migrate("posts", 1, |t| { t.text("title"); t.text("body"); t.bool("published"); })?;
  ctx.routes.get("/blog", archive)?;
  ctx.routes.get("/blog/*", post)?;
  ctx.widgets.register("blog-feed", { label: "Blog feed", settings: { limit: { type: "int", default: 3 } } }, feed)?;
  ctx.admin.menu("Posts", admin)?;
  ctx.hooks.on("head", |page| Ok("<link rel=\"alternate\" href=\"/blog\">"))?;
  Ok(())
}

fn archive(req) -> Result<Html, Error> {
  let posts = ctx.db.find("posts", { where: { published: true }, order: "created_at desc", limit: 20 })?;
  ctx.render("archive.html", { posts: posts })
}

ctx

CallDoes
ctx.db.migrate(table, version, |t| {...})ensures the table exists; the closure declares fields with defaults, required fields and indexes (see Data)
ctx.db.insert(table, map) → Ok(row)insert; rows carry id, created_at, updated_at, created_ts
ctx.db.update(table, id, map, merge = true)merge or replace a row's fields
ctx.db.delete(table, id) → Ok(bool)
ctx.db.get(table, id) → Ok(Some(row)) / Ok(None)
ctx.db.find(table, { where, order, limit, offset }) → Ok(list)where takes values or operators ({ price: { gte: 10 } }), nested fields ("address.city") and or; order: "views desc, title" (see Data)
ctx.db.first(table, query) / ctx.db.count(table, query)
ctx.db.page(table, { where, order, page, per_page }) → Ok({ rows, total, page, per_page, pages, has_prev, has_next })one page of rows and the totals for a pager (per_page 20 by default, at most 200)
ctx.db.aggregate(table, { where, group, sum, avg, min, max }) → Ok(…)counts, and sums, averages, smallest and largest of numeric fields; with group a list of { key, count, sum_<field>, … }, without one a single map. group: "created_at:day" (week, month, year) buckets by time
ctx.db.update_where(table, where, map) → Ok(count)merge map into every matching row; where may not be empty
ctx.db.delete_where(table, where) → Ok(count) / ctx.db.clear(table) → Ok(count)delete matching rows, or every row
ctx.db.increment(table, id, field, by = 1) → Ok(row)add to a number in one statement, so two requests never lose a count
ctx.db.transaction(|| { …; Ok(v) })every ctx.db call inside runs in one transaction: committed when the function returns, rolled back when it returns Err or fails
ctx.store.get(key) → Ok(Some(v)) / Ok(None), ctx.store.set(key, v, { ttl }?), ctx.store.delete(key), ctx.store.incr(key, by?, { ttl }?) → Ok(total), ctx.store.keys(prefix?)small values by key, the module's own (see Store)
ctx.routes.get(path, fn, options?), also post, put, patch, deletepublic routes; path may have :name parameters; options ask core to check sign-in, roles, capabilities and rates first (see Routes)
ctx.widgets.register(kind, { label, settings }, fn)a page-builder widget; fn(settings, place) returns HTML (see Widgets)
ctx.admin.menu(title, fn, { capability }?)an admin screen; fn(req) gets { method, query, form, files, user } and returns HTML; forms post back (see Admin screens)
ctx.admin.card(title, fn, { capability }?)a dashboard card; title must be in admin
ctx.ui.table, .form, .notice, .tabs, .pager, .stats, .buttonHTML for admin screens in the admin's look (see Admin screens)
ctx.theme.style_section({ id, label, description, groups, sample }) → Ok(())controls in the Theme screen over the module's own theme tokens (every token starts with <module id>-), beside a live preview of sample. No permission needed (see Theme styles)
ctx.hooks.on(name, fn)listen to one of core's hooks and events (see Hooks and events) or a name other modules call; the name must be in hooks, except settings.changed
ctx.hooks.call(name, payload) → Ok(Some(answer)) / Ok(None)ask another running module listening on name and wait up to 10 s; needs name in calls
ctx.files.info(id) → Ok(Some({ id, name, size, sha256, content_type }))a stored file; needs files = true
ctx.files.delete(id) → Ok(bool)
ctx.files.send(id, { filename, content_type, headers })route response that streams the file; only x-* and cache-control headers pass
ctx.files.package(id) → Ok({ kind, id, name, version, description, gansey, source, permissions, manifest })what a stored module or theme-kit package says about itself, read from its own module.toml (or theme.toml, kind: "kit"); Err when the file is not a package in the shape gansey mod pack writes
ctx.sign.public_key() → Ok(hex)the site's ed25519 public key; needs sign = true
ctx.sign.file(id) → Ok(hex)ed25519 signature over a stored file's bytes
ctx.sha256(text) → hexfor comparing tokens without storing them
ctx.crypto.hmac_sha256(key, message) → hex, .sha256(text), .equal(a, b), .base64_encode(s, url?), .base64_decode(s) → Ok(text), .uuid()checking webhook signatures (equal compares in constant time) and making identifiers
ctx.forms.token(name) / ctx.forms.check(token, name, { min_seconds, max_age }) → Ok(age) / Err(why)signed, timestamped tokens for public forms (see Public forms and webhooks)
ctx.captcha.html({ fresh, compact }?, ip?) → HTML, ctx.captcha.check(req) → Ok(true) / Err(why), ctx.captcha.active() → boolthe site's robot check in a form: the widget to print inside it, and the check of what came back (see The robot check). No permission needed
ctx.limits.hit(key, "5/m") → Ok(bool)count a request against a limit; false once it is over
ctx.assets.url(path) → stringthe address of a file in the module's assets/ folder, with a hash so browsers keep it (see Assets)
ctx.semver.matches(range, version) → booldoes version satisfy a range like >=0.4, <0.6; false when either does not parse
ctx.jobs.every("1h", fn, name?)repeating work (30s, 5m, 1h, 1d); needs jobs = true
ctx.jobs.cron("0 3 * * mon", fn, name?) / ctx.jobs.daily("03:30", fn, name?)on a cron schedule or at a time each day, UTC
ctx.queue.handle(name, fn), ctx.queue.push(name, payload, { delay, attempts }?) → Ok(job id), ctx.queue.stats()work kept in the database and retried when it fails (see Jobs and the queue)
ctx.http.get(url, _, headers?, { timeout }?) / ctx.http.post(url, body, headers?, { timeout }?)→ Ok({ status, ok, body, json }), hosts must be declared, or http = ["*"] for any public address (checked before it goes, redirects not followed; see Public forms and webhooks); timeout in seconds (default 20, at most 120). The module waits, so keep these short
ctx.http.background({ url, method?, body?, headers?, timeout?, hook, context? }) → Ok(request id)a slow request that runs off the module's thread (default timeout 60 s, at most 120; four at a time per module). When it ends, this module's hook listener gets { id, context, ok, status, json, body, error } (body only when the answer is not JSON). hook must be in hooks; hosts as for get
ctx.settings.define({ name: { type, label, default, … }, … })declare the module's settings; the admin shows them as a form (Modules → the module → Settings). See Settings
ctx.settings.get(name) / ctx.settings.all()a value, or every value as a map (the stored one, else the default)
ctx.settings.set(name, value) / ctx.settings.set(map) → Ok(all values)store values, checked against the schema
ctx.media.list({ q, limit, offset, images_only }) → Ok(list)library items, newest first: the row (filename, mime_type, width, alt_text, …), url, thumb_url, optimisation and variants (each with its url); needs media = "read"
ctx.media.get(id) → Ok(Some(item))one item
ctx.media.status() → Ok({ settings, signature, managed_by, counts, queue })the next-generation settings in force, how much of the library is optimised with them, and the optimise queue
ctx.media.optimise(id or [ids]) → Ok(queued)make an item's WebP/AVIF copies again with the current settings; needs media = "manage"
ctx.media.optimise_all({ force }) → Ok(queued)queue every image not optimised with the current settings (every image with force: true)
ctx.media.configure({ formats, webp_quality, avif_quality }) → Ok({ settings, signature })choose the formats (["webp"], ["webp", "avif"], ["avif"]) and qualities (1–100) the library's copies use. They apply while this module runs; switched off or removed, core goes back to WebP at 80
ctx.cache.stats() → Ok({ pages, bytes, entries, policy })the page cache: each entry is { path, bytes, hits, cached_at }; needs cache = true
ctx.cache.clear() / ctx.cache.clear("/about") → Ok(count)drop every cached page, or one path
ctx.cache.configure({ exclude, page_max_age, asset_max_age }) → Ok(policy)paths never cached (/account, or /shop/* for a section) and how many seconds browsers may keep pages and the bundled assets (0: revalidate every time). Applies while this module runs
ctx.pages.list() → Ok([{ id, title, slug, path, url, description, updated_at }])published pages; needs pages = true
ctx.pages.get("/about") → Ok(Some({ …, published }))one page by path, published or not
ctx.minify.html(html) / ctx.minify.css(css) → stringremove comments and extra whitespace; pre, textarea and script contents and attribute values are left alone, style contents get the CSS treatment
ctx.email.send(to, subject, body, { cc, bcc, reply_to }?) → Ok(sent)plain text through the site's mailer (sent is false when the site has none and only logged it). to, cc and bcc take an address, several separated by commas, or a list; any address may carry a name (Ada <[email protected]>); reply_to is one address; blanks are dropped. Err names an address that does not parse, and comes too with no to or more than 50 recipients in all: ctx.email.send(owners, "New enquiry", text, { cc: "[email protected]", reply_to: visitor })?
ctx.users.invite(email, name, role) → Ok({ created, invited, dev_link })create an account with role and email a link to choose a password; role must be listed in users. Existing accounts keep their roles
ctx.users.grant(user_id, role) / ctx.users.revoke(user_id, role) → Ok(user)give or take away a role listed in users
ctx.users.get(id or email) → Ok(Some({ id, email, name, roles, active, created_at, last_login_at })), ctx.users.list({ role, q, limit, offset })look accounts up; needs read_users = true
ctx.content.types(), .terms(taxonomy), .entries(type, { status, term, q, order, limit, offset }), .entry(type, slug) / .entry(id)structured content; needs content = "read" (see Content)
ctx.content.create(type, { title, slug, excerpt, fields, terms, status }), .update(id, {…}), .publish(id), .unpublish(id), .delete(id), .ensure_type({ slug, name, fields })write content; needs content = "write"
ctx.events.emit("<module id>.<name>", payload) → Ok(count)tell every module listening on that hook, without waiting (see Events between modules)
ctx.modules.list(), ctx.modules.has(id, range?) → boolwhich modules are installed and running
ctx.locales.configure({ default, prefixes })serve the site under language prefixes; needs locales = true
ctx.render(template, data) → Ok(html)a template from templates/
ctx.html(s), ctx.json(v), ctx.text(body, content_type), ctx.redirect(url, status?), ctx.not_found(html?), ctx.status(code, body?), ctx.response({ … })response helpers for routes (see Routes)
ctx.site.name, ctx.site.url, ctx.module.id, ctx.log(...)

Route handlers may return a string (HTML, wrapped in the site's header and footer), ctx.html(s), ctx.json(v), ctx.redirect(url) or a map with status, html, title, raw: true (no chrome); Routes has the rest.