domma-cms 1.6.21 → 1.6.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugins/CLAUDE.md +128 -0
- package/plugins/_template/CLAUDE.md +23 -0
- package/plugins/_template/docs/guide.md +37 -0
- package/plugins/_template/plugin.js +4 -3
- package/scripts/build.js +8 -0
- package/server/services/plugins.js +26 -2
package/package.json
CHANGED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Plugin Development
|
|
2
|
+
|
|
3
|
+
Each plugin needs exactly 3 files:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
plugins/my-plugin/
|
|
7
|
+
plugin.json Required manifest (name, displayName, version, description, author, date, icon)
|
|
8
|
+
plugin.js Default export: Fastify plugin function
|
|
9
|
+
config.js Default export: plain object of config defaults
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
See `docs/plugin-development.md` for full plugin API, hooks, and injection points.
|
|
13
|
+
Each plugin directory has its own `CLAUDE.md` with routes, storage, and gotchas.
|
|
14
|
+
|
|
15
|
+
**User docs ship with the plugin:** `docs/*.md` (Markdown, optional `title`/`order` frontmatter, no H1,
|
|
16
|
+
shortcodes shown literally) appear under Documentation > Plugins for users who can use the plugin
|
|
17
|
+
(`docs.permission`, else the first sidebar permission). Update them in the same change as the feature -
|
|
18
|
+
see "User guides in the Documentation folder" in `docs/plugin-development.md`.
|
|
19
|
+
|
|
20
|
+
## Contracts every plugin gets wrong
|
|
21
|
+
|
|
22
|
+
These five faults were each found in more than one shipped plugin, and each one
|
|
23
|
+
is silent - nothing logs, nothing throws where you can see it, and the screen
|
|
24
|
+
just does less than it should. Copy `_template/`, not another plugin.
|
|
25
|
+
|
|
26
|
+
**Collection schemas.** `createCollection()` destructures `title` and calls
|
|
27
|
+
`.trim()`, and `validateEntryData()` reads `data[field.name]`. So a collection is
|
|
28
|
+
named by `title` (not `label`) and a field by `name` (not `key`). Get either
|
|
29
|
+
wrong and every schema throws; `setupPlugin()` catches that and says nothing, so
|
|
30
|
+
the collections are left as empty directories and every route answers
|
|
31
|
+
`Collection "…" not found`. Delete those directories before retrying - the mkdir
|
|
32
|
+
happens before the validation, so a second attempt reports "already exists"
|
|
33
|
+
instead of the real fault. Note this only bites a FRESH install: on a box where
|
|
34
|
+
the collection already exists, `setupPlugin` skips it and the broken schema is
|
|
35
|
+
invisible.
|
|
36
|
+
|
|
37
|
+
**Route params.** Domma's router calls `view.onMount($container)` with ONE
|
|
38
|
+
argument and never the params, and `R.current` is a METHOD - so
|
|
39
|
+
`R.current?.params?.id` reads `.params` off the function object and is always
|
|
40
|
+
`undefined`. An editor view that does this opens blank on every edit and Save
|
|
41
|
+
creates a duplicate. Read the hash, as every core editor view does:
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
const m = location.hash.match(/^#\/plugins\/my-plugin\/edit\/([^?#]+)/);
|
|
45
|
+
const id = m ? decodeURIComponent(m[1]) : null;
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Class names are Domma's, not Bootstrap's.** `form-control`, `d-flex`,
|
|
49
|
+
`justify-content-between`, `align-items-center`, `h2`, `h3`, `small`, `fw-bold`,
|
|
50
|
+
`font-monospace`, `btn-outline-secondary`, `col-sm-*` and `badge-outline` are
|
|
51
|
+
defined NOWHERE in this admin and style nothing. Domma's are `form-input` /
|
|
52
|
+
`form-textarea` / `form-select` / `form-checkbox`, `flex`, `justify-between`,
|
|
53
|
+
`items-center`, `text-2xl`, `text-sm`, `font-bold`, `font-mono`, `btn-outline`,
|
|
54
|
+
`col-*`, `badge-secondary`. For a stat tile the admin ships `.stat-card` /
|
|
55
|
+
`.stat-icon` / `.stat-value` / `.stat-label`. Put your own rules in
|
|
56
|
+
`admin/css/index.css` and declare it in the manifest's `admin.css` - not inline
|
|
57
|
+
on the element.
|
|
58
|
+
|
|
59
|
+
**`F.create` takes a schema, not a container.** The signature is
|
|
60
|
+
`F.create(schema, data, options).renderTo(el)`, values come back from
|
|
61
|
+
`form.getData()` and `form.validate()`, and `placeholder` / `hint` live under
|
|
62
|
+
`formConfig`. Passing an element first makes it the schema and the form renders
|
|
63
|
+
nothing at all. `admin/js/views/plugins.js` is the house pattern.
|
|
64
|
+
|
|
65
|
+
**Reading an H response.** Some routes send a bare array and some send
|
|
66
|
+
`{data: […]}`. `res.data ?? []` silently empties a screen that the API is
|
|
67
|
+
filling correctly. Write `res.data ?? (Array.isArray(res) ? res : [])`.
|
|
68
|
+
|
|
69
|
+
## The plugin banner is the admin's, and it has a door
|
|
70
|
+
|
|
71
|
+
The admin wraps every plugin view in `.dm-plugin-shell` at the point views are
|
|
72
|
+
registered (`admin/js/lib/plugin-chrome.js`), so the banner carrying the name,
|
|
73
|
+
the version and the licence is not yours to draw - which is why a template that
|
|
74
|
+
opens with its own `<h1>` shows the same words twice and has one stripped.
|
|
75
|
+
|
|
76
|
+
The facts read version, release date, licence and - for a paid plugin with an
|
|
77
|
+
entitlement - its expiry: "Expires" for a rental, "Support to" for a perpetual
|
|
78
|
+
licence, whose plugin never stops. That comes from `pluginExpiry()` in
|
|
79
|
+
`server/services/plugins.js`; a plugin does nothing to get it.
|
|
80
|
+
|
|
81
|
+
A screen-level control belongs IN that banner, and `addBannerAction(container,
|
|
82
|
+
{icon, title, onClick, id})` puts it there, to the LEFT of those facts. Settings
|
|
83
|
+
in particular goes there as a cog on the plugin's main screens - not as a
|
|
84
|
+
sidebar item, a tab, or a button in a pane of your own - so every plugin keeps
|
|
85
|
+
it in the same place. Import it at RUN
|
|
86
|
+
time and survive its absence:
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
try {
|
|
90
|
+
const chrome = await import('/admin/js/lib/plugin-chrome.js');
|
|
91
|
+
if (!chrome.addBannerAction?.($container, {...})) throw new Error('no banner');
|
|
92
|
+
} catch {
|
|
93
|
+
// older admin, or no chrome: put the control in your own toolbar
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
A static import would take the whole view down on a CMS whose `admin/` predates
|
|
98
|
+
the export - and `admin/` is replaced wholesale by the updater, so that is a
|
|
99
|
+
version skew you do not control.
|
|
100
|
+
|
|
101
|
+
## Smaller ones, same flavour
|
|
102
|
+
|
|
103
|
+
- `E.slideover().close()` only hides the panel and `destroy()` does not detach
|
|
104
|
+
its element, so every open leaves another one in the document. Do both, via
|
|
105
|
+
`onClose`, after the slide-out has run.
|
|
106
|
+
- `requireAdmin` reads `request.user`, which only `authenticate` populates - as a
|
|
107
|
+
lone preHandler it answers 401 to everyone, admin included. Always
|
|
108
|
+
`[authenticate, requireAdmin]`.
|
|
109
|
+
- Plugin settings live in `config/plugins.json`; read them from `options.settings`
|
|
110
|
+
or `getPluginStates()[name].settings` and write them with
|
|
111
|
+
`savePluginState(name, {settings})`. `getConfig`/`saveConfig` in
|
|
112
|
+
`server/config.js` take a FILE NAME, not a config object.
|
|
113
|
+
- `authenticate` is Bearer-only, so an `<a href target="_blank">` to any API
|
|
114
|
+
route arrives with no Authorization header and comes back 401. Fetch through
|
|
115
|
+
`H` and write the result into a window you opened on the click.
|
|
116
|
+
- An unknown `data-icon` renders an empty span and says nothing. Check the name
|
|
117
|
+
against the shipped bundle before using it. An icon inside a `data-if` needs
|
|
118
|
+
scanning AFTER the element comes back, or the span is scanned while detached
|
|
119
|
+
and stays empty - set the observable, `M.flush()`, then scan.
|
|
120
|
+
- **`type: 'date'` in an `F.create` schema cannot be submitted.** Forma renders
|
|
121
|
+
`<input type="date">`, which writes a string, and `M.types.date` accepts only
|
|
122
|
+
a real `Date`, so `validate()` fails with "Expected type date" every time and
|
|
123
|
+
the save button appears to do nothing. Declare the field with no `type` (the
|
|
124
|
+
model only type-checks when one is set) and attach **`E.datePicker`** to the
|
|
125
|
+
rendered input: it is one themed control in every browser, it writes ISO
|
|
126
|
+
`YYYY-MM-DD` into the input and it fires `input`/`change`, so the model and
|
|
127
|
+
`form.getData()` hear it exactly as they hear typing. `plugins/invoice` does
|
|
128
|
+
this in its editor, its list filters and its payment dialog.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# PLUGIN_DISPLAY_NAME - AI Assistant Guide
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
TODO: Describe what this plugin does.
|
|
5
|
+
|
|
6
|
+
## Files
|
|
7
|
+
- `plugin.json`, `plugin.js`, `config.js`
|
|
8
|
+
- `admin/views/index.js` - main admin view (`indexView`)
|
|
9
|
+
- `admin/templates/index.html` - view template
|
|
10
|
+
- `admin/css/index.css` - admin styles (auto-injected via plugin.json `admin.css`)
|
|
11
|
+
|
|
12
|
+
## Routes
|
|
13
|
+
All API routes are under `/api/plugins/PLUGIN_SLUG` and require authentication.
|
|
14
|
+
|
|
15
|
+
| Method | Path | Purpose | Location |
|
|
16
|
+
|---|---|---|---|
|
|
17
|
+
|
|
18
|
+
## Gotchas
|
|
19
|
+
- Use `S.get/set` not `localStorage`
|
|
20
|
+
- Use `H.get/post/put/delete` not `fetch()`
|
|
21
|
+
- Use `F.create()` for forms, `T.create()` for tables
|
|
22
|
+
- For HTML with inline styles or data-* attrs: `import { safeSetHTML } from '../../admin/js/lib/safe-html.js'`
|
|
23
|
+
- For interactive elements (buttons) inside dynamic HTML: build via DOM (`createElement`/`appendChild`)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Guide
|
|
3
|
+
---
|
|
4
|
+
One or two sentences on what this plugin does for a site owner. This file appears under
|
|
5
|
+
Documentation > Plugins in the admin while the plugin is enabled (see "User guides in the
|
|
6
|
+
Documentation folder" in docs/plugin-development.md). Do not start with a top-level heading -
|
|
7
|
+
the page header already shows the plugin's name.
|
|
8
|
+
|
|
9
|
+
## Getting started
|
|
10
|
+
|
|
11
|
+
1. Open **Tools > PLUGIN_DISPLAY_NAME**.
|
|
12
|
+
2. Click **New** and fill in the form.
|
|
13
|
+
3. Click **Save**.
|
|
14
|
+
|
|
15
|
+
## Screens
|
|
16
|
+
|
|
17
|
+
### PLUGIN_DISPLAY_NAME
|
|
18
|
+
|
|
19
|
+
What the list shows, what clicking a row does, and what the right-click menu offers.
|
|
20
|
+
|
|
21
|
+
## Settings
|
|
22
|
+
|
|
23
|
+
Click the cog on the plugin banner. List each setting and what it changes.
|
|
24
|
+
|
|
25
|
+
## Shortcodes
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
[PLUGIN_SLUG limit="5"]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Permissions and roles
|
|
32
|
+
|
|
33
|
+
Which permissions in **System > Roles** open the screen, and who holds them by default.
|
|
34
|
+
|
|
35
|
+
## Limitations
|
|
36
|
+
|
|
37
|
+
What it does not do, so nobody looks for it.
|
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
export default async function plugin(fastify, options) {
|
|
6
6
|
const { authenticate, requireAdmin } = options.auth;
|
|
7
|
-
const BASE = `/api/plugins/PLUGIN_SLUG`;
|
|
8
7
|
|
|
9
|
-
// Add your API routes here.
|
|
8
|
+
// Add your API routes here. Paths are RELATIVE: the CMS mounts every route
|
|
9
|
+
// in this file under /api/plugins/PLUGIN_SLUG, so '/items' answers at
|
|
10
|
+
// /api/plugins/PLUGIN_SLUG/items. A full path here would end up doubled.
|
|
10
11
|
// Example:
|
|
11
|
-
// fastify.get(
|
|
12
|
+
// fastify.get('/items', { preHandler: [authenticate] }, async (request, reply) => {
|
|
12
13
|
// return { items: [] };
|
|
13
14
|
// });
|
|
14
15
|
}
|
package/scripts/build.js
CHANGED
|
@@ -93,6 +93,14 @@ const PLUGIN_AS_IS_PATTERNS = [
|
|
|
93
93
|
'plugins/*/blocks/**/*.css',
|
|
94
94
|
// Images a plugin serves from public/ (blog's sample-post covers).
|
|
95
95
|
'plugins/*/public/**/*.svg',
|
|
96
|
+
// The scaffold template's Markdown: its CLAUDE.md and the docs/guide.md a new
|
|
97
|
+
// Tool starts its Documentation page from. The free-tier plugins ship whole
|
|
98
|
+
// from their lock, but _template goes through these patterns, which carried no
|
|
99
|
+
// .md - so every Tool scaffolded on an installed site lacked both, while the
|
|
100
|
+
// New plugin panel promised the CLAUDE.md.
|
|
101
|
+
'plugins/_template/**/*.md',
|
|
102
|
+
// The plugin-authoring guide the CMS's own CLAUDE.md points Tool authors to.
|
|
103
|
+
'plugins/CLAUDE.md',
|
|
96
104
|
];
|
|
97
105
|
|
|
98
106
|
/**
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* plugins/{name}/data/ - optional plugin-specific data store
|
|
12
12
|
*/
|
|
13
13
|
import fs from 'fs/promises';
|
|
14
|
+
import {readFileSync} from 'fs';
|
|
14
15
|
import path from 'path';
|
|
15
16
|
import matter from 'gray-matter';
|
|
16
17
|
import {getConfig, saveConfig} from '../config.js';
|
|
@@ -1253,6 +1254,25 @@ async function newestAdminMtime(root) {
|
|
|
1253
1254
|
return newest;
|
|
1254
1255
|
}
|
|
1255
1256
|
|
|
1257
|
+
/**
|
|
1258
|
+
* The plugins the engine ships with (the free tier): the names in
|
|
1259
|
+
* plugins/free-tier.lock.json. Read once; an install without the lock ships none.
|
|
1260
|
+
*
|
|
1261
|
+
* @returns {Set<string>}
|
|
1262
|
+
*/
|
|
1263
|
+
let shippedNames = null;
|
|
1264
|
+
export function shippedPluginNames() {
|
|
1265
|
+
if (!shippedNames) {
|
|
1266
|
+
try {
|
|
1267
|
+
const lock = JSON.parse(readFileSync(path.join(PLUGINS_DIR, 'free-tier.lock.json'), 'utf8'));
|
|
1268
|
+
shippedNames = new Set(Object.keys(lock.plugins || {}));
|
|
1269
|
+
} catch {
|
|
1270
|
+
shippedNames = new Set();
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
return shippedNames;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1256
1276
|
/**
|
|
1257
1277
|
* The licence line shown on a plugin's admin banner.
|
|
1258
1278
|
*
|
|
@@ -1266,12 +1286,16 @@ async function newestAdminMtime(root) {
|
|
|
1266
1286
|
* @param {object} manifest
|
|
1267
1287
|
* @returns {string}
|
|
1268
1288
|
*/
|
|
1269
|
-
export function pluginLicence(manifest) {
|
|
1289
|
+
export function pluginLicence(manifest, shipped = shippedPluginNames()) {
|
|
1270
1290
|
const stated = manifest.licence || manifest.license;
|
|
1271
1291
|
if (typeof stated === 'string' && stated.trim()) return stated.trim();
|
|
1272
1292
|
if (CORE_PLUGINS.has(manifest.name)) return 'Built in';
|
|
1273
1293
|
if (manifest.closedSource) return 'Commercial licence';
|
|
1274
|
-
|
|
1294
|
+
// Only the plugins the engine ships (the free tier, listed in its lock) came with
|
|
1295
|
+
// the CMS. Anything else that is neither bought nor licensed is one the site made:
|
|
1296
|
+
// a Tool scaffolded from the Marketplace used to be labelled as part of the CMS.
|
|
1297
|
+
if (shipped.has(manifest.name)) return 'Included with Domma CMS';
|
|
1298
|
+
return 'Your own Tool';
|
|
1275
1299
|
}
|
|
1276
1300
|
|
|
1277
1301
|
/**
|