# Domma CMS - AI Assistant Guide

## Project Overview

This is a **Domma CMS** project. Stack: Fastify 5 (ESM) backend, Domma SPA admin, SSR public site.
Content is Markdown + YAML frontmatter in `content/pages/`. Config is JSON in `config/`.
Server runs on port **4096** by default (`config/server.json`).

## Domma Framework - Use These, Not Vanilla JS

Domma provides built-in solutions. Never use vanilla JS equivalents.

| Alias                                            | Purpose                                  | NOT this                           |
|--------------------------------------------------|------------------------------------------|------------------------------------|
| `$('#el')`                                       | DOM selection + manipulation             | `document.querySelector()`         |
| `S.set/get`                                      | Storage                                  | `localStorage.setItem/getItem`     |
| `H.get/post/put/delete`                          | HTTP                                     | `fetch()`, `XMLHttpRequest`        |
| `D()`                                            | Date manipulation (Moment-style)         | Manual `Date` arithmetic           |
| `_`                                              | Array/object utils (`_.map`, `_.filter`) | Always evaluate if native suffices |
| `M.create(blueprint)`                            | Reactive data models                     | Manual state management            |
| `F.create(selector, {blueprint})`                | Form generation                          | Manual `<form>` HTML               |
| `E.toast/confirm/modal/tabs/accordion/slideover` | UI components                            | Manual HTML/CSS/JS components      |
| `E.contextMenu(sel, {items})`                    | Right-click menus                        | Manual `contextmenu` listeners     |
| `I.scan()` / `<span data-icon="name">`           | Icons                                    | Manual SVG or icon fonts           |
| `T.create(selector, {data, columns})`            | Tables                                   | Manual `<table>` generation        |

## Architecture

**DO NOT modify `server/` or `admin/`** - both are replaced wholesale by the upstream updater.
Yours to edit: `public/css/site.css`, `content/custom.css`, `plugins/`, and `config/` (see Config
Reference for which config files the updater merges vs leaves alone).

**URL → file mapping:**

- `/` → `content/pages/index.md`
- `/about` → `content/pages/about.md`
- `/services` → `content/pages/services/index.md`
- `/404` → `content/pages/404.md` (auto-served on missing pages, if present)

## Content Pages

Pages are Markdown files with YAML frontmatter:

```markdown
---
title: Page Title
layout: default          # default | landing | blank
description: SEO desc
visibility: public       # public | private | role-name
---

Page content here. Shortcodes work anywhere in the body.
```

**Page visibility** is enforced server-side in `server/routes/public.js` - unauthenticated users and insufficient role levels receive a 403/redirect.

### Shortcodes

~28 shortcode types. **Full list and syntax: `docs/markdown-shortcodes.md`** - read it before writing or
editing shortcode markup; the nesting rules below are the part the doc does not make obvious.

### Shortcode Nesting Rules

Processing order (innermost first):

1. `[dconfig]` - always first
2. `[grid]` / `[row]` / `[col]`
3. `[card]`
4. `[tabs]`, `[accordion]`, `[carousel]`, `[hero]`, `[table]`, `[badge]`, `[countdown]`, `[timeline]`, `[spacer]`,
   `[center]`, `[icon]`, `[cta]`, `[block]`, `[view]`, `[collection]`, `[text]`, `[button]`, `[link]`,
   `[listgroup]`, `[form]`, `[banner]`, `[celebrate]`
5. `[slideover]` - always last (can contain anything above)

Safe nesting: `[card]` inside `[slideover]`, `[grid]` inside `[slideover]`, `[col]` inside `[grid]`.
Do NOT nest `[slideover]` inside another `[slideover]`.

## Public Site Patterns

The server injects these globals on every public page:

```js
window.__CMS_NAV__      // Navigation config (brand, items)
window.__CMS_SITE__     // Site config (title, theme, footer, smtp, etc.)
window.__CMS_DCONFIG__  // Page-level declarative config (merged with inline [dconfig])
```

`public/js/site.js` reads these to initialise the Domma navbar, footer, and page components.
Domma components (tabs, accordion, carousel, etc.) in `.page-body` are auto-initialised by `site.js`.
Add custom public-site JS to `public/js/site.js` or new files loaded from `public/js/`.

## Core Services (`server/services/`)

Business logic lives in `server/services/` - `ls` it and read the file you need; names are literal
(`collections.js`, `users.js`, `markdown.js`, `plugins.js`, …). Non-obvious ones:

- `permissionRegistry.js` - central permission map; route guards read the **cache at request time**
- `hooks.js` - plugin hook registry (`registerShortcode`, `registerSanitizeRules`, `registerMenuLocation`, …)
- `cache/index.js` - pluggable response cache with tag-based invalidation; see `docs/cache.md`

## Storage Adapters

Collection entry I/O is delegated to a storage adapter. Schema management is always file-based.

- **Default (free):** `FileAdapter` - JSON files under `content/collections/<slug>/`
- **Optional (Pro):** `MongoAdapter` - native MongoDB driver; collections prefixed `cms_`; `mongodb` is in `optionalDependencies` (not `dependencies`), so it is not required for the free tier
- Per-collection config in `schema.json`: `"storage": { "adapter": "mongodb", "connection": "default" }`
- `adapterRegistry.getAdapter(slug)` resolves the adapter and caches it; call `invalidate(slug)` after schema changes
- `user-types` collection is **always** file-based regardless of global storage config
- Absence of `config/connections.json` = file mode (server.js wraps Mongo init in try/catch)

## Roles & Permissions

Roles are **dynamic** - stored as entries in `content/collections/user-types/` (a preset collection).
Seeded on first run with: `admin`, `manager`, `editor`, `subscriber`.

- `server/services/roles.js` - `seed()`, `load()`, `invalidate()`, `getRoleLevel(role)`,
  `getPermissionsFor(resource)`, `getRoleHierarchy()` (returns role names sorted most-privileged
  first, level 0 = admin, so the least-privileged role is `.at(-1)`)
- `server/services/permissionRegistry.js` - central permission map; route guards read the cache at request time
- `server/services/rowAccess.js` - row-level access control for collections
- Route guards use `requirePermission('resource')`, **not** hardcoded role names
- `requireAdmin()` checks `getRoleLevel(role) === 0` (not a hardcoded name)
- `canManageUser()` uses `getRoleLevel()` for level comparison

## Plugin Development

Plugins live in `plugins/<name>/` - see `plugins/CLAUDE.md` (loaded when working there) for the required
file structure, and `docs/plugin-development.md` for the full API, hooks, and injection points.

## Config Reference

| File                         | Owner                 | Purpose                                         |
|------------------------------|-----------------------|-------------------------------------------------|
| `config/server.json`         | CMS (merge on update) | Port, host, CORS, uploads                       |
| `config/auth.json`           | CMS (merge on update) | JWT expiry, bcrypt rounds                       |
| `config/content.json`        | CMS (merge on update) | Content dirs, page defaults                     |
| `config/presets.json`        | CMS (merge on update) | Preset collection schemas                       |
| `config/site.json`           | Yours                 | Site title, brand, footer, SMTP                 |
| `config/theme.json`          | Yours                 | Front-end + admin theme, auto day/night, fonts, per-element overrides (migrated out of `site.json`; the old keys stay there as a one-way mirror) |
| `config/menus/<slug>.json`   | Yours                 | One file per menu (tree of items + style/behaviour overrides) |
| `config/menu-locations.json` | Yours                 | Slot → menu slug map (built-in slots: `navbar`, `footer-primary`, `footer-legal`) |
| `config/plugins.json`        | Yours                 | Plugin enabled/disabled + settings              |
| `config/connections.json`    | Yours (Pro only)      | Named MongoDB connections; absence = file mode  |
| `config/effects.json`        | Yours                 | Effects / celebrate shortcode config            |
| `config/search.json`         | Yours                 | Public site search: enabled, trigger, placement, limits |
| `config/cache.json`          | Yours (optional)      | Response cache: enabled, driver (memory/none/redis), TTL - absence = defaults (off in dev, on in prod) |

See `docs/configuration.md` for full schema reference.

## Theme & element overrides

Themes, fonts and per-element overrides live in `config/theme.json`, edited at **Content > Theme**
(`server/services/themeSettings.js`). The matching `site.json` keys are a **one-way mirror** rewritten on
save for plugins and fleet tooling - nothing in the CMS reads them, so do not add a reader.

A Domma theme is only `.dm-theme-<id> { --dm-*: … }`, so an override re-themes a region by re-declaring
those tokens on a selector; `getThemeDeclarations()` copies them straight out of the shipped
`domma-themes.css`, which is also what feeds the admin's theme and token pickers (`/api/theme/catalog`) -
never hardcode a token list, an invented `--dm-*` resolves to nothing. The compiled `<style
id="dm-theme-overrides">` is injected **before** `content/custom.css` so hand-written CSS still wins, and
both render paths in `renderer.js` go through `buildThemeView()` - wiring one and not the other is the
standing trap in that file. Token values that could close their own declaration are refused, not escaped.

## Multi-menu system, overlays & admin sidebar

Menus live in `config/menus/<slug>.json`, mapped to slots by `config/menu-locations.json` (built-in
slots: `navbar`, `footer-primary`, `footer-legal`, `admin-sidebar`, plus the multi-menu `overlay`
slot). Menus render on TWO paths - `server/services/menuRender.js` for the `[menu]` shortcode and
overlays, and Domma in the browser for the public navbar - so a change to one is absent from the
other. Bindings, highlighting, orientation, floating panels, structural items, and the
menu-data-driven admin sidebar all have non-obvious contracts: full detail in the
**`domma-menus` skill** (`.claude/skills/domma-menus/SKILL.md`) - load it before touching any of that.
## Form triggers

A trigger hangs off a field (`field.triggers[]`) and acts on the FORM when the answers meet a
condition - banner, toast, hide/disable/require other fields, block the submit, jump a wizard step,
celebrate, redirect, run an Action. It is the sibling of `field.logic`, sharing its condition engine,
and lives in the same file: `public/js/form-logic-engine.js` (`resolveTriggerState`, pure) plus the
runtime methods on `FormLogicRuntime`.

Two kinds of action and the distinction is load-bearing: **state** actions apply while the condition
holds and are REVERTED when it stops (so the runtime tracks what it applied last pass and clears the
difference); **event** actions fire once on the transition into a branch, and never on the first
render. `then` and `otherwise` are separate branch keys, which is what lets an answer swinging back
re-fire.

Anything a trigger decides about submission is re-enforced in `routes/api/forms.js` from the same
function - a browser-only block is a suggestion. Four places attach the runtime (three in
`public/js/forms.js`, one in `site.js`) plus the editor's live preview; they all ask
`needsLogicRuntime`/`fbNeedsRuntime` rather than spelling the predicate out, because one of them
forgetting triggers is the obvious failure. `goto-step` is announced as a `dm-form-trigger` DOM event
rather than performed, since the wizard handle belongs to the embed.

## Site search

Search is part of the CMS, not a plugin - `server/services/search.js` (settings, `stripMarkdown`, `scorePage`,
`searchPages`), `server/routes/api/search.js` (`/api/search`), `public/js/search.js` + `public/css/search.css`
(the overlay), `admin/js/views/search.js` (**System > Search**), stored in `config/search.json`.

The renderer EMBEDS the settings as `window.__CMS_SEARCH__` and loads the script directly - there is no settings
fetch any more - so `buildSearchAssets()` has to be wired into **both** render paths in `renderer.js`, the same
standing trap as `buildThemeView()`. Every key in that object reaches the page source, which is why
`normaliseSearchSettings()` whitelists rather than merges. `enabled: false` emits nothing at all.

`window.DommaSiteSearch.open()` keeps its old name on purpose: context menus and site scripts already call it.
There is no index - each query scans `listPages()` - and drafts and private pages never reach a result.

`server/services/search-migration.js` runs once at startup to lift an existing site off the old plugin: settings out
of plugins.json, the plugins.json entry gone, and `plugins/site-search/` deleted (only if it is the bundled one -
the updater never removes a retired plugin, and a leftover copy would run a SECOND search UI on every page).

## Context menus

Every context menu in the CMS registers with Domma (`E.contextMenu`) rather than listening on
`document` or on an element directly. Domma keeps ONE document listener and a registry of bound
containers, and resolves a right-click by walking outward from the target, so the nearest menu
answers and script load order cannot decide the winner. A private listener is safe only while it is
the page's only context menu, which stopped being true the moment there were two.

Both existing menus are **hardened**: they render bespoke panels through Domma's `render` callback
rather than as item lists, so no ancestor items merge into them. The collection menu additionally
sets `exclusive: true` - it owns its display outright and nothing bound deeper is offered the click,
which is what stops an authored menu from taking over a collection display. Neither is a veto: a
menu that declines (no model, no fields) steps aside and the click reaches whatever encloses it.

- `public/js/collection-context.js` - public filter/sort/group/export panel, `exclusive`
- `admin/js/lib/shortcode-context-menu.js` - page-editor shortcode menu, `inherit: false`

Both keep a fallback to their original listener for when Domma predates 0.43.0.

**Authored menu actions** come from a fixed vocabulary in `contextMenus.js` (`ACTION_TYPES`), never
from stored JavaScript - `admin/` is replaced wholesale by the updater, and a stored script running on
public pages is XSS with extra steps. Adding one means three files in step:
`ACTION_TYPES` (server validation), `SPECIALS`/`toHandler` in `public/js/context-menus.js` (behaviour),
and `ACTION_GROUPS` in the editor (the picker). The "specials" - reload/back/forward/copy/cut/paste/
select-all/site-search - carry a `disabled` **function**, which Domma resolves on every gesture, so
Paste greys out per right-click rather than per page load. Prefer `disabled` to hiding: a menu that
changes shape depending on where you clicked is disorienting, and greyed-out is what native menus do.
`forward` cannot be detected at all by the browser, so it keys off whether this page load was a
`back_forward` navigation - an honest heuristic, not a real capability check. `site-search` calls
`window.DommaSiteSearch.open()`, the one handle `public/js/search.js` exports.

## Collection displays & public filtering

`[collection]` and `[view]` render in any of table, cards, list, block, accordion, timeline,
carousel or listgroup (`display="…"`), server-side in `markdown.js`, with a public right-click
menu (`public/js/collection-context.js`) adding filter, search, sort, group-by, export, print and
shareable `dmf=` hash state on top. Each display type filters differently and export has its own
permission block: full detail in the **`domma-collection-displays` skill**
(`.claude/skills/domma-collection-displays/SKILL.md`) - load it before changing a display, the
context menu, or export serialisation.
## Slideovers & admin view width

Every slideover in the admin - core views AND plugin views - is resizable with a
remembered width, because `admin/js/app.js` patches `E.slideover` once at boot
(`admin/js/lib/slideover-resizable.js`). It is done at the constructor, not at the
~40 call sites: a call-site sweep drifts, misses new panels, and cannot reach the
ones plugins open. The width is keyed off the slideover's TITLE
(`dm.slideover.width.<slug>`), so two panels sharing a title share a width.

- Only `position: 'right'` / `'left'` get a handle; a bottom sheet has no vertical
  edge to pull and is left alone.
- `admin.css` pins slideovers at `width: … !important`, so the applied width must be
  an **inline `!important`** - an ordinary inline style loses that cascade.
- The panel carries its state on `element.__dmResize` (`{storageKey, defaultWidth,
  width, apply}`). That object is the contract `plugins/_lib/admin/ui/resizable.js`
  re-points when a plugin wants its own key - a plugin must NOT import from `admin/`,
  which the updater replaces wholesale.
- Measuring a width right after setting it reads the OLD value: the slideover carries
  an inline `transition: 300ms`. That is also why `.is-resizing` kills the transition
  with `!important` during a drag.

**View width**: `.view-container` caps at 1200px with no `margin: 0 auto`, so a view
sits left with the remainder empty. Plugin views lift the cap via
`:has(> .dm-plugin-shell)` - the shell is stamped by `plugin-chrome.js`, so this
reaches every plugin without the author doing anything. Four core editors lift it the
same way, keyed on a marker element (`#editor-meta-tabs`, `#block-editor-body`,
`#ep-editor-tabs`, `.fb-fields-layout`).

## Key Gotchas

1. **Structural menu items**: `{type:'separator'}` and `{type:'spacer', size}` carry no text/url - every renderer (menuRender, site.js navbar+footer, sidebar-renderer, menu-editor flatten/nest) needs a branch, and anything zipping items against rendered DOM must skip them or the indices shift. Domma's navbar has no spacer concept, so site.js strips them pre-render and re-inserts `<li>`s afterwards **against a snapshot** of the children, not the live HTMLCollection. `size: 'flex'` needs a flex parent, so the renderers stamp `dm-menu--has-flex-spacer` / `dm-admin-sidebar--has-flex-spacer` rather than making every list flex.
2. **Menu item sub-items**: use `items` key, NOT `children` - Domma navbar and the `[menu]` shortcode both read `items`.
3. **Domma collections**: use `.get(0)` NOT `[0]` to get native DOM elements.
4. **`.html()` strips interactive elements**: `E.modal().setContent(x)` also strips HTML - use light DOM slot
   projection (`modal.element.appendChild(myDomElement)`) for buttons/inputs.
5. **Landing layout**: `layout: landing` removes the standard page wrapper - add your own container CSS.
6. **Grid shortcode**: uses Domma Grid (`grid`, `grid-cols-N`) - NOT the `.col` compatibility class.
7. **Event delegation namespaces**: `$(doc).on('click.ns', '.sel', cb)` silently fails - use direct `addEventListener`
   or unnamespaced `$(el).on('click', cb)`.
8. **Tabs component classes**: wrapper `.tabs`, list `.tab-list`, trigger `button.tab-item`, panel `.tab-panel` (not
   `.tabs-nav`, `.tabs-content`, `.tabs-pane`).
9. **JWT_SECRET required**: `server/server.js` hard-fails on startup if `JWT_SECRET` is missing, insecure, or shorter
   than 32 characters. Set it in `.env`.
10. **Page visibility enforcement**: `server/routes/public.js` checks `page.visibility` against the visitor's role
   level - unauthenticated visitors cannot access private or role-restricted pages.
   **Draft preview**: `status: draft` still 404s the public, but a viewer holding `pages.read` gets the real page plus
   a banner (`buildDraftBanner` in `renderer.js`, passed via `renderPage(page, {preview})`). Draft renders bypass the
   response cache and carry `no-store` + `noindex` - never route one through `cache.wrap`. Browser navigations
   authenticate via the `dm_session` cookie (`server/services/viewerSession.js`), which is read **only** by the public
   page renderer; `authenticate()` stays Bearer-only, so no write route accepts ambient credentials.
   **Editor preview**: `POST /api/pages/preview/full` renders unsaved `{urlPath, frontmatter, body}` through the real
   `renderPage`; the editor injects the result as iframe `srcdoc`. `collectFrontmatter()` in `page-editor.js` is shared
   by Save and preview on purpose - fork them and the preview starts lying.
   **Share links**: `server/services/previewLinks.js` + `GET /_preview?token=…`. The token goes in the QUERY STRING, not
   a path param - Fastify caps a route param at 100 chars and a signed token is ~350, so `/_preview/:token` silently
   404s. Revocation needs the server-side index (`content/preview-links.json`); a JWT alone cannot be withdrawn.
11. **Block templates are live markup, comments included**: `content/blocks/<name>.html` is loaded
   through `loadBlockTemplate`, which STRIPS HTML comments - the doc comment every block carries
   contains an example shortcode call, and an unclosed one (`[collection …]` with no `/]`) is read
   by the processor and eats the rest of the render. The companion `.css` is emitted inline by
   `buildBlockStyleTag`, which removes blank lines: a blank line ends an HTML block in Markdown, so
   a block used inside `[reveal]` or a `[col]` would otherwise print its own stylesheet on the page
   as `<p>` text. Both fixed at the render layer, not per template.
12. **404 page**: create `content/pages/404.md` to customise the not-found response; it is auto-served on missing routes.
13. **Admin route params**: Domma's router calls `view.onMount($container)` with ONE argument - it does
   **not** pass route params. An editor view reads its slug from `location.hash`
   (`location.hash.match(/^#\/thing\/edit\/([^?#]+)/)`), the way menu-editor, form-editor and
   page-editor do. A second `params` parameter is always `undefined`, so the view silently opens in
   "new" mode and Save creates a duplicate instead of updating - which is how the Context Menu editor
   shipped in 0.56.0 with a dead preview and an edit screen that never loaded anything.

## Docs

- `docs/getting-started.md` - Installation, first run, setup wizard
- `docs/markdown-shortcodes.md` - Full shortcode syntax and examples
- `docs/configuration.md` - Complete config schema for all JSON files
- `docs/plugin-development.md` - Plugin API, lifecycle hooks, injection points
- `docs/theming.md` - Available themes, CSS variables, customisation
- `docs/api-reference.md` - REST API endpoints (for headless / external integrations)

## Projects

Projects group related artefacts under a named slug - sidebar navigation, per-user access scope,
and a tag the scaffolder stamps on recipe-produced artefacts. Records live in the preset collection
`content/collections/projects/`; artefacts carry an optional `meta.project`, and anything untagged
belongs to the protected `core` project by resolution fallback, never by stamping. Note the two
distinct user fields: `user.meta.project` (administrative ownership, sidebar placement) vs
`user.projects: []` (access scope, empty = unrestricted). Full detail in the **`domma-projects`
skill** (`.claude/skills/domma-projects/SKILL.md`) - load it before touching project records,
artefact tagging, or per-project access checks.
## External API & API tokens, custom API endpoints

The external surface - `/api/v1/:slug` collection access, project-scoped API tokens, and API Builder
endpoints at `/api/x/<project><path>` - has its own contracts (strict token auth, project binding,
read-field allowlists, cross-project refusals). Full detail in the **`domma-api-surface` skill**
(`.claude/skills/domma-api-surface/SKILL.md`) - load it before touching any of that.
