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
| Call | Does |
|---|---|
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, delete | public 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, .button | HTML 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) → hex | for 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() → bool | the 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) → string | the address of a file in the module's assets/ folder, with a hash so browsers keep it (see Assets) |
ctx.semver.matches(range, version) → bool | does 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) → string | remove 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?) → bool | which 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.