Developing CMS modules
Access/permissions are documented in ACCESS.md.
A CMS module renders a page node. It exports a cms.node contract from its plugin.ts:
export const cms = {
node: {
css: ["pub/main.css"], // stylesheets, added to the page <head>
js: ["pub/main.js"], // client scripts, added to the page <head>
render, // (node, { ctx, vars }) => string — the node HTML
parts: { list }, // named sub-renderers, reloadable on their own
},
};
Render output must be a single root element
render (and any part that replaces a node) must return exactly one root element.
The CMS injects qcms-id / qcms-mod into the first tag of the (trimmed) output
(Node.htmlPrepared, regex /^<([^\s>]+)…/).
A leading <style> or several top-level elements break this: the qcms-* attributes land
on the wrong element, cms.initNode(...) gets that element as el, and your listeners are
never bound.
// ✗ wrong — <style> steals the qcms-id, the real content becomes a sibling
return `<style>…</style><div class=u2-flex>…</div>`;
// ✓ right — one root; put <style>/scripts inside it
return `<div class=u2-flex><style>…</style>…</div>`;
Parts and client wiring
Reloadable fragments go into parts, wrapped in <div cms-part="name">…</div>. In the
client, use the helpers from pub/js/cms.mjs instead of calling api directly:
cms.initNode("backend.superuser.requests.log", (el) => {
const nid = Number(cms.el.nid(el));
// reload one part with vars → server runs cms.node.parts[name](node, { ctx, vars })
cms.reloadPart(nid, "list", { filter });
// re-render the whole node
cms.reloadNode(nid, { do: "optimize" });
});
cms.initNode runs once per node element (matched by qcms-mod, the module name without
cms.). On the server, render and parts run in a request context, so getCtx() and app.t
are available.
GET parameter naming
A parameter needs a prefix when other modules may read the same request — action hooks, the
core pipeline, layouts, and content modules sharing a page. Prefix = module name with _
instead of ., then the name in camelCase: cms_editmode, cms_nodeFilesZip,
cms_noFrontend, cms_versions_space. No registry needed, and ctx.req.query.cms_editmode
stays plain property access.
Short names (id, search, tab) are fine where nothing else can collide: a backend node that
owns its page, an own route, a handler for its own upload field, or a value that must match the
node id (export_table=<node id>).
Exceptions (do not extend):
- cmspid, lang — core-owned, well-known short names.
Change tracking (node_changed)
Every content change writes a row into node_changed. lib/nodeChanged.ts listens to the db
events (table:insert/update/delete), so every write through the table API is recorded.
node_changed
id PK
log_id request log entry (index)
node_id the cont/page that changed (index)
page_id its containing page (== node_id for pages) (index)
data small JSON: { table, op, name?, lang?, cols? }
- A log, not a mapping: one row per change, no dedup — editing a text and adding a file in
one request gives two rows. Readers aggregate (
EXISTS,GROUP BY node_id,MAX(log.time)vialog).datais for display/debugging only, never for queries (JSON access differs per dialect); add a real column if you need a filter. - Text and file rows are found via their links: the node comes from
page_text/page.title_id/page_file. A new translation is anINSERTon an already linkedtextrow, so it is tracked too. - Deleted nodes stay traceable: on page delete,
page_idis read from the parent before the row is gone. - No request context (cron/CLI/boot): no row, like the versions history.
log_idbecomesNULLwhen the log row is deleted (likepage/text): the rows remain but lose who/when (log→sess→usr). Field values are in the_vers_*tables;node_changedonly links node and log.
Used by the 213 access guards in cms.versions/serverInterface.ts (getForNode,
logDetails) and the superuser history page (cms.backend.superuser.versions.cms). Older logs
have no rows (the access guard then denies).