domma-cms 1.8.33 → 1.8.35
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/CHANGELOG.md +8 -0
- package/package.json +1 -1
- package/plugins/docs/CLAUDE.md +318 -0
- package/plugins/docs/admin/css/docs.css +783 -0
- package/plugins/docs/admin/lib/api.js +340 -0
- package/plugins/docs/admin/lib/convert.js +482 -0
- package/plugins/docs/admin/lib/drafts.js +115 -0
- package/plugins/docs/admin/lib/editor.js +851 -0
- package/plugins/docs/admin/lib/find-replace.js +426 -0
- package/plugins/docs/admin/lib/folders.js +203 -0
- package/plugins/docs/admin/lib/panel-guard.js +73 -0
- package/plugins/docs/admin/lib/pdf-view.js +126 -0
- package/plugins/docs/admin/lib/send.js +156 -0
- package/plugins/docs/admin/lib/settings.js +142 -0
- package/plugins/docs/admin/lib/share.js +323 -0
- package/plugins/docs/admin/lib/shortcuts.js +44 -0
- package/plugins/docs/admin/lib/templates.js +194 -0
- package/plugins/docs/admin/lib/versions.js +192 -0
- package/plugins/docs/admin/templates/docs.html +92 -0
- package/plugins/docs/admin/templates/editor.html +86 -0
- package/plugins/docs/admin/views/docs.js +777 -0
- package/plugins/docs/admin/views/editor.js +978 -0
- package/plugins/docs/config.js +24 -0
- package/plugins/docs/docs/guide.md +270 -0
- package/plugins/docs/lib/extensions.js +213 -0
- package/plugins/docs/lib/importer.js +72 -0
- package/plugins/docs/lib/pdf-worker.js +48 -0
- package/plugins/docs/lib/pdf.js +222 -0
- package/plugins/docs/lib/store.js +748 -0
- package/plugins/docs/plugin.js +1108 -0
- package/plugins/docs/plugin.json +89 -0
- package/plugins/docs/plugin.public.js +114 -0
- package/plugins/docs/templates/public-doc.html +36 -0
- package/plugins/docs/tests/_env.js +10 -0
- package/plugins/docs/tests/_pdf.js +73 -0
- package/plugins/docs/tests/convert.test.js +95 -0
- package/plugins/docs/tests/drafts.test.js +82 -0
- package/plugins/docs/tests/pdf.test.js +86 -0
- package/plugins/docs/tests/plugin.test.js +816 -0
- package/plugins/docs/tests/shortcuts.test.js +30 -0
- package/plugins/free-tier.lock.json +105 -1
- package/plugins/garage/CLAUDE.md +51 -0
- package/plugins/garage/admin/css/garage.css +73 -0
- package/plugins/garage/admin/lib/garage.js +236 -0
- package/plugins/garage/admin/templates/garage.html +86 -0
- package/plugins/garage/admin/views/garage.js +581 -0
- package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
- package/plugins/garage/config.js +18 -0
- package/plugins/garage/docs/guide.md +118 -0
- package/plugins/garage/plugin.js +471 -0
- package/plugins/garage/plugin.json +68 -0
- package/plugins/garage/tests/access.test.js +134 -0
- package/plugins/garage/tests/garage.test.js +106 -0
- package/plugins/messages/CLAUDE.md +230 -0
- package/plugins/messages/admin/css/messages.css +117 -0
- package/plugins/messages/admin/lib/messages.js +452 -0
- package/plugins/messages/admin/templates/messages.html +76 -0
- package/plugins/messages/admin/views/messages.js +1219 -0
- package/plugins/messages/collections/user-message-threads/schema.json +22 -0
- package/plugins/messages/collections/user-messages/schema.json +23 -0
- package/plugins/messages/config.js +14 -0
- package/plugins/messages/docs/guide.md +176 -0
- package/plugins/messages/lib/call.js +138 -0
- package/plugins/messages/plugin.js +994 -0
- package/plugins/messages/plugin.json +59 -0
- package/plugins/messages/plugin.public.js +52 -0
- package/plugins/messages/templates/call.html +6 -0
- package/plugins/messages/tests/after-send.test.js +22 -0
- package/plugins/messages/tests/api.test.js +187 -0
- package/plugins/messages/tests/call.test.js +497 -0
- package/plugins/messages/tests/drafts.test.js +25 -0
- package/plugins/messages/tests/messages.test.js +167 -0
- package/plugins/theme-roller/CLAUDE.md +65 -0
- package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
- package/plugins/theme-roller/admin/lib/prompt.js +22 -0
- package/plugins/theme-roller/admin/lib/themes.js +196 -0
- package/plugins/theme-roller/admin/lib/view.js +88 -0
- package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
- package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
- package/plugins/theme-roller/config.js +1 -0
- package/plugins/theme-roller/docs/guide.md +104 -0
- package/plugins/theme-roller/plugin.js +263 -0
- package/plugins/theme-roller/plugin.json +42 -0
- package/plugins/theme-roller/tests/api.test.js +161 -0
- package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
- package/plugins/theme-roller/tests/themes.test.js +77 -0
- package/plugins/theme-roller/tests/view.test.js +55 -0
- package/server/routes/public.js +16 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Guide
|
|
3
|
+
order: 1
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Theme Roller lets you make your own themes. Start from any built-in theme, change its colours, fonts, spacing and borders with a live preview, and save it. Open it from **Theme Roller** in the admin sidebar ([Theme Roller](#/plugins/theme-roller)).
|
|
7
|
+
|
|
8
|
+
## What it does
|
|
9
|
+
|
|
10
|
+
- A custom theme is a built-in theme (its base) plus the settings you changed. Everything you did not change still comes from the base.
|
|
11
|
+
- Saved themes are listed under **Custom themes** wherever a theme is chosen in **Content > Theme**: the site theme, the admin theme, the day and night themes, and element overrides (one region of a page).
|
|
12
|
+
- You can also use a theme on the site or in the admin straight from Theme Roller.
|
|
13
|
+
- The **Custom CSS** tab edits the site's own CSS, added to every public page after the theme.
|
|
14
|
+
|
|
15
|
+
Custom themes need Domma CMS 0.73 or later. On an older version a warning says so; themes still save but cannot be used.
|
|
16
|
+
|
|
17
|
+
## Getting started
|
|
18
|
+
|
|
19
|
+
1. Open **Theme Roller**. The roller opens on the theme your site shows now.
|
|
20
|
+
2. In the roller, pick a base theme.
|
|
21
|
+
3. Change the colours, fonts, spacing or borders you want. The whole admin changes as you go, so you can see the result.
|
|
22
|
+
4. Press **Save** and give the theme a name (for example "Brand").
|
|
23
|
+
5. Open the **Saved themes** tab, right-click your theme and choose **Use on the site**.
|
|
24
|
+
|
|
25
|
+
## Screens
|
|
26
|
+
|
|
27
|
+
The banner has **New theme** and **Save** buttons. **Save** saves the theme on the Roller tab, or the CSS when you are on the Custom CSS tab.
|
|
28
|
+
|
|
29
|
+
### Roller
|
|
30
|
+
|
|
31
|
+
The theme editor. The bar above it shows the name of the theme you are editing (or **New theme**), an **Unsaved** tag when you have changes, and where the theme is in use (Site, Admin, Day, Night or Region).
|
|
32
|
+
|
|
33
|
+
- Pick the base theme and change any setting in the roller. The preview covers the whole admin while you are on this screen. When you leave, the admin goes back to its own theme.
|
|
34
|
+
- **New** - start a new theme.
|
|
35
|
+
- **Save** - save the theme. A new theme asks for a name first. Saving a theme that is in use updates the site straight away.
|
|
36
|
+
- **Save as...** - save your settings as a new theme, leaving the original as it was. Shown when you are editing a saved theme.
|
|
37
|
+
- The menu button next to it has the same actions as a theme in **Saved themes**.
|
|
38
|
+
|
|
39
|
+
If you leave with unsaved changes you are asked first. Unsaved work (in the roller or in Custom CSS) is offered back the next time you open Theme Roller.
|
|
40
|
+
|
|
41
|
+
### Saved themes
|
|
42
|
+
|
|
43
|
+
Every theme you have saved, with where each is used.
|
|
44
|
+
|
|
45
|
+
- Click a theme (or press Enter on it) to edit it in the roller.
|
|
46
|
+
- Right-click a theme (or use its menu button, or Shift+F10) for:
|
|
47
|
+
- **Edit in the roller**
|
|
48
|
+
- **Use on the site** / **Stop using on the site**
|
|
49
|
+
- **Use in the admin** / **Stop using in the admin**
|
|
50
|
+
- **Duplicate...** - a copy under a new name.
|
|
51
|
+
- **Rename...**
|
|
52
|
+
- **Export CSS** - downloads the theme as a `.css` file. It holds only your changes, as a block for `dm-custom-<id>` to use together with the base theme's class.
|
|
53
|
+
- **Delete...** - if the theme is in use, each place it was used goes back to its base theme.
|
|
54
|
+
|
|
55
|
+
Good to know:
|
|
56
|
+
|
|
57
|
+
- **Use on the site** when day/night switching is on asks first, then switches day/night switching off. The site then shows this one theme.
|
|
58
|
+
- **Stop using** puts that place back on the theme's base.
|
|
59
|
+
- **Use...** uses the saved version. Save first to include changes still in the roller.
|
|
60
|
+
- The admin shows the new admin theme once you leave the Theme Roller screen.
|
|
61
|
+
|
|
62
|
+
### Custom CSS
|
|
63
|
+
|
|
64
|
+
Hand-written CSS added to every public page, after the theme, so it has the last word. This is the same CSS as **System > Site Settings > Custom CSS**.
|
|
65
|
+
|
|
66
|
+
1. Wait for the CSS to load; the box stays locked until it has.
|
|
67
|
+
2. Edit it.
|
|
68
|
+
3. Press **Save CSS** or Ctrl+S.
|
|
69
|
+
|
|
70
|
+
## Using a custom theme elsewhere
|
|
71
|
+
|
|
72
|
+
Open **Content > Theme**. Your saved themes are listed under **Custom themes** in each theme picker:
|
|
73
|
+
|
|
74
|
+
- **Site theme** and **Admin theme**.
|
|
75
|
+
- **Day theme** and **Night theme**, under **Switch between a day and a night theme**.
|
|
76
|
+
- **Element overrides**, to theme one region of a page.
|
|
77
|
+
|
|
78
|
+
## Settings
|
|
79
|
+
|
|
80
|
+
Theme Roller has no settings of its own.
|
|
81
|
+
|
|
82
|
+
## Permissions and roles
|
|
83
|
+
|
|
84
|
+
Theme Roller uses the built-in **Theme** permission (group Configuration) in **System > Roles**; it adds none of its own.
|
|
85
|
+
|
|
86
|
+
| Action | Label | Allows |
|
|
87
|
+
| --- | --- | --- |
|
|
88
|
+
| `theme.read` | View | Open Theme Roller and see the saved themes |
|
|
89
|
+
| `theme.update` | Edit | Save, duplicate, rename and delete themes; use them on the site or in the admin |
|
|
90
|
+
|
|
91
|
+
The **Custom CSS** tab needs **Settings > Edit** (`settings.update`). Without it the tab cannot load or save.
|
|
92
|
+
|
|
93
|
+
## Tips
|
|
94
|
+
|
|
95
|
+
- Only what you change is stored. If the base theme is improved in a later version of Domma CMS, your theme picks that up.
|
|
96
|
+
- Duplicate a theme before trying a big change, so you can go back.
|
|
97
|
+
- Changing a theme's base theme while it is in use updates the site straight away.
|
|
98
|
+
- The **Theme Roller** sidebar badge shows how many custom themes you have and how many are in use. Its popover shows the site theme (or the day and night themes) and the admin theme.
|
|
99
|
+
|
|
100
|
+
## Limitations
|
|
101
|
+
|
|
102
|
+
- The public theme switcher (**Content > Theme > Theme switcher**) offers built-in themes only. Visitors cannot switch to a custom theme; they can only pick between the built-in ones.
|
|
103
|
+
- For safety, some values are refused: no `url()`, `@import`, comments, braces, semicolons, `<`, `>` or backslashes. A setting with such a value is dropped when the theme is saved.
|
|
104
|
+
- Custom themes need Domma CMS 0.73 or later.
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme Roller Plugin - Server
|
|
3
|
+
*
|
|
4
|
+
* Stores custom themes and offers them to the CMS. A theme is a built-in Domma
|
|
5
|
+
* theme plus the `--dm-*` tokens changed on top of it; the CMS applies it as the
|
|
6
|
+
* base's class plus `dm-custom-<id>` (core themeSettings.js), so a saved theme
|
|
7
|
+
* can be chosen anywhere a theme is - the site, the admin, day/night, a region
|
|
8
|
+
* override - from here or from Content > Theme.
|
|
9
|
+
*
|
|
10
|
+
* Needs the CMS's `hooks.registerThemes` (domma-cms 0.73). On an older CMS the
|
|
11
|
+
* themes still save and load, and the screen says why they cannot be used.
|
|
12
|
+
*
|
|
13
|
+
* Endpoints (prefix /api/plugins/theme-roller; the core `theme` permission):
|
|
14
|
+
* GET /status - supported?, the built-in themes, what is in use
|
|
15
|
+
* GET /themes - saved themes, each with where it is in use
|
|
16
|
+
* GET /themes/:id
|
|
17
|
+
* POST /themes - {label, baseTheme, changes}
|
|
18
|
+
* PUT /themes/:id - {label?, baseTheme?, changes?}
|
|
19
|
+
* POST /themes/:id/duplicate - {label?}
|
|
20
|
+
* DELETE /themes/:id - anything using it goes back to its base
|
|
21
|
+
* POST /themes/:id/use - {where: 'site' | 'admin'}
|
|
22
|
+
* POST /themes/:id/unuse - {where: 'site' | 'admin'} - back to its base
|
|
23
|
+
* GET /count - the sidebar badge + popover (countUrl)
|
|
24
|
+
*/
|
|
25
|
+
import fs from 'node:fs/promises';
|
|
26
|
+
import fsSync from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import {fileURLToPath} from 'node:url';
|
|
29
|
+
import {listThemeIds, loadThemeConfig, saveThemeConfig} from '../../server/services/themeSettings.js';
|
|
30
|
+
import * as cache from '../../server/services/cache/index.js';
|
|
31
|
+
import {badgeFor, cleanChanges, cleanLabel, ID_RE, uniqueId, usageOf, withoutTheme} from './admin/lib/themes.js';
|
|
32
|
+
|
|
33
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
const DEFAULT_DIR = path.join(__dirname, 'data', 'themes');
|
|
35
|
+
const DEFAULT_BASE = 'charcoal-dark';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A stored file, including the pre-1.1 shape (`name`, no label), as a theme.
|
|
39
|
+
* The id is the FILE's name: write() and remove() address `<id>.json`, so an
|
|
40
|
+
* id taken from inside the file could point them at a different file.
|
|
41
|
+
*/
|
|
42
|
+
function normalise(raw, fileId) {
|
|
43
|
+
const id = String(fileId || '').toLowerCase();
|
|
44
|
+
if (!ID_RE.test(id)) return null;
|
|
45
|
+
return {
|
|
46
|
+
id,
|
|
47
|
+
label: cleanLabel(raw.label ?? raw.name, id),
|
|
48
|
+
baseTheme: typeof raw.baseTheme === 'string' && raw.baseTheme ? raw.baseTheme : DEFAULT_BASE,
|
|
49
|
+
changes: cleanChanges(raw.changes).changes,
|
|
50
|
+
createdAt: raw.createdAt || raw.updatedAt || new Date().toISOString(),
|
|
51
|
+
updatedAt: raw.updatedAt || raw.createdAt || new Date().toISOString()
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default async function themeRollerPlugin(fastify, opts) {
|
|
56
|
+
const dir = opts.themesDir || DEFAULT_DIR;
|
|
57
|
+
const {authenticate, requireAdmin, requirePermission} = opts.auth;
|
|
58
|
+
const can = (action) => ({
|
|
59
|
+
preHandler: [authenticate, requirePermission ? requirePermission('theme', action) : requireAdmin]
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ---- Store: the files are the truth; `themes` mirrors them in memory so
|
|
63
|
+
// the CMS's provider - called on every page render - never touches disk.
|
|
64
|
+
const themes = new Map();
|
|
65
|
+
|
|
66
|
+
function loadAll() {
|
|
67
|
+
themes.clear();
|
|
68
|
+
let entries = [];
|
|
69
|
+
try { entries = fsSync.readdirSync(dir); } catch { return; }
|
|
70
|
+
for (const f of entries) {
|
|
71
|
+
if (!f.endsWith('.json')) continue;
|
|
72
|
+
try {
|
|
73
|
+
const t = normalise(JSON.parse(fsSync.readFileSync(path.join(dir, f), 'utf8')), f.slice(0, -5));
|
|
74
|
+
if (t) themes.set(t.id, t);
|
|
75
|
+
else fastify.log.warn(`[theme-roller] ${f} skipped - its name is not a usable theme id`);
|
|
76
|
+
} catch { /* a corrupt file is skipped, not fatal */ }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function write(theme) {
|
|
81
|
+
// The directory does not ship (data/ is a site's own), so it is made on
|
|
82
|
+
// first save - its absence is what made every save fail before 1.1.
|
|
83
|
+
await fs.mkdir(dir, {recursive: true});
|
|
84
|
+
const file = path.join(dir, `${theme.id}.json`);
|
|
85
|
+
// Unique per write: two saves of one theme must not share a temp file.
|
|
86
|
+
const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
87
|
+
try {
|
|
88
|
+
await fs.writeFile(tmp, JSON.stringify(theme, null, 4) + '\n', 'utf8');
|
|
89
|
+
await fs.rename(tmp, file);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
await fs.rm(tmp, {force: true});
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
themes.set(theme.id, theme);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function remove(id) {
|
|
98
|
+
await fs.rm(path.join(dir, `${id}.json`), {force: true});
|
|
99
|
+
themes.delete(id);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
loadAll();
|
|
103
|
+
|
|
104
|
+
// ---- Offer the themes to the CMS.
|
|
105
|
+
const supported = typeof opts.hooks?.registerThemes === 'function';
|
|
106
|
+
if (supported) {
|
|
107
|
+
opts.hooks.registerThemes(() => [...themes.values()].map(t => ({
|
|
108
|
+
id: t.id, label: t.label, baseTheme: t.baseTheme, tokens: t.changes
|
|
109
|
+
})));
|
|
110
|
+
} else {
|
|
111
|
+
fastify.log.warn('[theme-roller] this CMS has no hooks.registerThemes (needs domma-cms 0.73) - themes save, but cannot be used');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const builtins = () => listThemeIds();
|
|
115
|
+
// No readable stylesheet means no list to check against - the core treats
|
|
116
|
+
// that as allow-all, and so does this.
|
|
117
|
+
const validBase = (b) => typeof b === 'string' && /^[a-z0-9-]+$/.test(b) && (!builtins().length || builtins().includes(b));
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Claim a new id before the first await: two creates of "Brand" at once
|
|
121
|
+
* must not both get `brand` and have the second overwrite the first.
|
|
122
|
+
*/
|
|
123
|
+
async function create(theme) {
|
|
124
|
+
themes.set(theme.id, theme);
|
|
125
|
+
try {
|
|
126
|
+
await write(theme);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
themes.delete(theme.id);
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const sorted = () => [...themes.values()].sort((a, b) => a.label.localeCompare(b.label));
|
|
133
|
+
|
|
134
|
+
/** A theme a page or the admin already shows changed: drop the cached pages. */
|
|
135
|
+
async function touched(id) {
|
|
136
|
+
const cfg = await loadThemeConfig();
|
|
137
|
+
if (usageOf(id, cfg).length) {
|
|
138
|
+
try { await cache.invalidateTags(['site']); } catch { /* no cache configured */ }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function fail(reply, code, error) {
|
|
143
|
+
return reply.code(code).send({error});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// -----------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
fastify.get('/status', can('read'), async () => {
|
|
149
|
+
const cfg = await loadThemeConfig();
|
|
150
|
+
return {
|
|
151
|
+
supported,
|
|
152
|
+
builtins: builtins(),
|
|
153
|
+
inUse: {theme: cfg.theme, adminTheme: cfg.adminTheme, autoTheme: cfg.autoTheme?.enabled ? cfg.autoTheme : null}
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// The sidebar badge: how many themes, and on hover what the site and the
|
|
158
|
+
// admin wear and where each saved theme is used.
|
|
159
|
+
fastify.get('/count', can('read'), async () => {
|
|
160
|
+
const cfg = await loadThemeConfig();
|
|
161
|
+
return badgeFor(sorted().map(t => ({...t, usage: usageOf(t.id, cfg)})), cfg);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
fastify.get('/themes', can('read'), async () => {
|
|
165
|
+
const cfg = await loadThemeConfig();
|
|
166
|
+
return sorted().map(t => ({...t, usage: usageOf(t.id, cfg)}));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
fastify.get('/themes/:id', can('read'), async (req, reply) => {
|
|
170
|
+
const t = themes.get(req.params.id);
|
|
171
|
+
return t ? {...t, usage: usageOf(t.id, await loadThemeConfig())} : fail(reply, 404, 'Theme not found');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
fastify.post('/themes', can('update'), async (req, reply) => {
|
|
175
|
+
const {label, baseTheme, changes} = req.body || {};
|
|
176
|
+
const name = cleanLabel(label, '');
|
|
177
|
+
if (!name) return fail(reply, 400, 'A theme needs a name');
|
|
178
|
+
if (!validBase(baseTheme)) return fail(reply, 400, `Unknown base theme "${baseTheme}"`);
|
|
179
|
+
const id = uniqueId(name, {builtins: builtins(), taken: [...themes.keys()]});
|
|
180
|
+
if (!id) return fail(reply, 400, 'The name needs at least one letter or number');
|
|
181
|
+
const now = new Date().toISOString();
|
|
182
|
+
const {changes: clean, dropped} = cleanChanges(changes);
|
|
183
|
+
const theme = {id, label: name, baseTheme, changes: clean, createdAt: now, updatedAt: now};
|
|
184
|
+
await create(theme);
|
|
185
|
+
if (dropped.length) return reply.code(201).send({...theme, usage: [], dropped});
|
|
186
|
+
return reply.code(201).send({...theme, usage: []});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
fastify.put('/themes/:id', can('update'), async (req, reply) => {
|
|
190
|
+
const existing = themes.get(req.params.id);
|
|
191
|
+
if (!existing) return fail(reply, 404, 'Theme not found');
|
|
192
|
+
const {label, baseTheme, changes} = req.body || {};
|
|
193
|
+
if (baseTheme !== undefined && !validBase(baseTheme)) return fail(reply, 400, `Unknown base theme "${baseTheme}"`);
|
|
194
|
+
const updated = {
|
|
195
|
+
...existing,
|
|
196
|
+
...(label !== undefined && {label: cleanLabel(label, existing.label)}),
|
|
197
|
+
...(baseTheme !== undefined && {baseTheme}),
|
|
198
|
+
...(changes !== undefined && {changes: cleanChanges(changes).changes}),
|
|
199
|
+
updatedAt: new Date().toISOString()
|
|
200
|
+
};
|
|
201
|
+
await write(updated);
|
|
202
|
+
// In use somewhere with a new base: the CMS's remembered base (customBases)
|
|
203
|
+
// must follow, so re-save the config it is used in.
|
|
204
|
+
const cfg = await loadThemeConfig();
|
|
205
|
+
if (baseTheme !== undefined && baseTheme !== existing.baseTheme && usageOf(updated.id, cfg).length) {
|
|
206
|
+
await saveThemeConfig({});
|
|
207
|
+
}
|
|
208
|
+
await touched(updated.id);
|
|
209
|
+
return {...updated, usage: usageOf(updated.id, cfg)};
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
fastify.post('/themes/:id/duplicate', can('update'), async (req, reply) => {
|
|
213
|
+
const source = themes.get(req.params.id);
|
|
214
|
+
if (!source) return fail(reply, 404, 'Theme not found');
|
|
215
|
+
const name = cleanLabel(req.body?.label, `${source.label} copy`);
|
|
216
|
+
const id = uniqueId(name, {builtins: builtins(), taken: [...themes.keys()]});
|
|
217
|
+
if (!id) return fail(reply, 400, 'The name needs at least one letter or number');
|
|
218
|
+
const now = new Date().toISOString();
|
|
219
|
+
const copy = {...source, id, label: name, createdAt: now, updatedAt: now};
|
|
220
|
+
await create(copy);
|
|
221
|
+
return reply.code(201).send({...copy, usage: []});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
fastify.delete('/themes/:id', can('update'), async (req, reply) => {
|
|
225
|
+
const theme = themes.get(req.params.id);
|
|
226
|
+
if (!theme) return fail(reply, 404, 'Theme not found');
|
|
227
|
+
// Nothing may be left pointing at a theme that no longer exists: every
|
|
228
|
+
// use of it goes back to its base, which is what it looked like anyway.
|
|
229
|
+
const patch = withoutTheme(await loadThemeConfig(), theme.id, theme.baseTheme);
|
|
230
|
+
if (patch) await saveThemeConfig(patch);
|
|
231
|
+
await remove(theme.id);
|
|
232
|
+
return {success: true, replaced: patch ? Object.keys(patch) : []};
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
fastify.post('/themes/:id/use', can('update'), async (req, reply) => {
|
|
236
|
+
if (!supported) return fail(reply, 409, 'This CMS cannot use custom themes yet - it needs domma-cms 0.73 or later');
|
|
237
|
+
const theme = themes.get(req.params.id);
|
|
238
|
+
if (!theme) return fail(reply, 404, 'Theme not found');
|
|
239
|
+
const where = req.body?.where;
|
|
240
|
+
if (where === 'admin') {
|
|
241
|
+
await saveThemeConfig({adminTheme: theme.id});
|
|
242
|
+
} else if (where === 'site') {
|
|
243
|
+
// With day/night switching on, the site theme is not what shows -
|
|
244
|
+
// using this one means switching that off, which the screen asks first.
|
|
245
|
+
const cfg = await loadThemeConfig();
|
|
246
|
+
await saveThemeConfig({theme: theme.id, ...(cfg.autoTheme?.enabled && {autoTheme: {...cfg.autoTheme, enabled: false}})});
|
|
247
|
+
} else {
|
|
248
|
+
return fail(reply, 400, 'where must be "site" or "admin"');
|
|
249
|
+
}
|
|
250
|
+
return {success: true, usage: usageOf(theme.id, await loadThemeConfig())};
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
fastify.post('/themes/:id/unuse', can('update'), async (req, reply) => {
|
|
254
|
+
const theme = themes.get(req.params.id);
|
|
255
|
+
if (!theme) return fail(reply, 404, 'Theme not found');
|
|
256
|
+
const where = req.body?.where;
|
|
257
|
+
const cfg = await loadThemeConfig();
|
|
258
|
+
if (where === 'admin' && cfg.adminTheme === theme.id) await saveThemeConfig({adminTheme: theme.baseTheme});
|
|
259
|
+
else if (where === 'site' && cfg.theme === theme.id) await saveThemeConfig({theme: theme.baseTheme});
|
|
260
|
+
else if (where !== 'admin' && where !== 'site') return fail(reply, 400, 'where must be "site" or "admin"');
|
|
261
|
+
return {success: true, usage: usageOf(theme.id, await loadThemeConfig())};
|
|
262
|
+
});
|
|
263
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "theme-roller",
|
|
3
|
+
"displayName": "Theme Roller",
|
|
4
|
+
"version": "1.3.0",
|
|
5
|
+
"tier": "free",
|
|
6
|
+
"description": "Make your own themes: start from any built-in theme, change its colours, fonts, spacing and borders with a live preview, and save it. Saved themes join the built-in ones everywhere a theme is chosen - the site, the admin, day and night, a region of a page. Needs Domma CMS 0.73 or later to use them.",
|
|
7
|
+
"author": "Darryl Waterhouse",
|
|
8
|
+
"date": "2026-10-21",
|
|
9
|
+
"icon": "palette",
|
|
10
|
+
"contentPaths": [
|
|
11
|
+
"data/themes"
|
|
12
|
+
],
|
|
13
|
+
"admin": {
|
|
14
|
+
"sidebar": [
|
|
15
|
+
{
|
|
16
|
+
"id": "theme-roller",
|
|
17
|
+
"text": "Theme Roller",
|
|
18
|
+
"icon": "palette",
|
|
19
|
+
"url": "#/plugins/theme-roller",
|
|
20
|
+
"section": "#/plugins/theme-roller",
|
|
21
|
+
"permission": "theme",
|
|
22
|
+
"countUrl": "/api/plugins/theme-roller/count"
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"routes": [
|
|
26
|
+
{
|
|
27
|
+
"path": "/plugins/theme-roller",
|
|
28
|
+
"view": "plugin-theme-roller",
|
|
29
|
+
"title": "Theme Roller - Domma CMS"
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"views": {
|
|
33
|
+
"plugin-theme-roller": {
|
|
34
|
+
"entry": "theme-roller/admin/views/theme-roller-view.js",
|
|
35
|
+
"exportName": "themeRollerView"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"css": [
|
|
39
|
+
"admin/css/theme-roller.css"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Theme Roller API against the real CMS theme service - the CMS checkout's
|
|
3
|
+
* config/theme.json and site.json are snapshotted and restored byte for byte,
|
|
4
|
+
* because `make test` runs inside that checkout.
|
|
5
|
+
*/
|
|
6
|
+
import {test, before, after} from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import fs from 'node:fs/promises';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import Fastify from 'fastify';
|
|
12
|
+
import themeRollerPlugin from '../plugin.js';
|
|
13
|
+
import {listCustomThemes, loadThemeConfig, registerThemeProvider, unregisterThemeProvider}
|
|
14
|
+
from '../../../server/services/themeSettings.js';
|
|
15
|
+
|
|
16
|
+
const CONFIG = path.resolve('config');
|
|
17
|
+
const SNAP = ['theme.json', 'site.json'];
|
|
18
|
+
const saved = {};
|
|
19
|
+
let app, dir;
|
|
20
|
+
|
|
21
|
+
before(async () => {
|
|
22
|
+
for (const f of SNAP) saved[f] = await fs.readFile(path.join(CONFIG, f), 'utf8').catch(() => null);
|
|
23
|
+
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'theme-roller-'));
|
|
24
|
+
app = Fastify();
|
|
25
|
+
const pass = async () => {};
|
|
26
|
+
await app.register(themeRollerPlugin, {
|
|
27
|
+
themesDir: path.join(dir, 'themes'), // does not exist yet - saving must make it
|
|
28
|
+
auth: {authenticate: pass, requireAdmin: pass, requirePermission: () => pass},
|
|
29
|
+
hooks: {registerThemes: (fn) => registerThemeProvider('theme-roller', fn)}
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
after(async () => {
|
|
34
|
+
await app?.close();
|
|
35
|
+
unregisterThemeProvider('theme-roller');
|
|
36
|
+
for (const f of SNAP) {
|
|
37
|
+
if (saved[f] === null) await fs.rm(path.join(CONFIG, f), {force: true});
|
|
38
|
+
else await fs.writeFile(path.join(CONFIG, f), saved[f]);
|
|
39
|
+
}
|
|
40
|
+
await fs.rm(dir, {recursive: true, force: true});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const call = async (method, url, body) => {
|
|
44
|
+
const res = await app.inject({method, url, payload: body});
|
|
45
|
+
return {status: res.statusCode, body: res.json()};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
test('a first save works even though the themes folder does not exist yet (the 1.0 bug)', async () => {
|
|
49
|
+
const r = await call('POST', '/themes', {label: 'Brand Blue', baseTheme: 'ocean-dark',
|
|
50
|
+
changes: {'--dm-primary': '#1144ff', '--dm-x': 'a;b'}});
|
|
51
|
+
assert.equal(r.status, 201);
|
|
52
|
+
assert.equal(r.body.id, 'brand-blue');
|
|
53
|
+
assert.deepEqual(r.body.changes, {'--dm-primary': '#1144ff'});
|
|
54
|
+
const onDisk = JSON.parse(await fs.readFile(path.join(dir, 'themes', 'brand-blue.json'), 'utf8'));
|
|
55
|
+
assert.equal(onDisk.label, 'Brand Blue');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('saved themes are offered to the CMS', () => {
|
|
59
|
+
const offered = listCustomThemes().find(t => t.id === 'brand-blue');
|
|
60
|
+
assert.equal(offered.baseTheme, 'ocean-dark');
|
|
61
|
+
assert.deepEqual(offered.tokens, {'--dm-primary': '#1144ff'});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('bad input is refused with a reason', async () => {
|
|
65
|
+
assert.equal((await call('POST', '/themes', {label: '', baseTheme: 'ocean-dark'})).status, 400);
|
|
66
|
+
assert.match((await call('POST', '/themes', {label: 'X', baseTheme: 'nope'})).body.error, /Unknown base theme/);
|
|
67
|
+
// A built-in theme's name gets a suffix rather than shadowing it.
|
|
68
|
+
const r = await call('POST', '/themes', {label: 'ocean dark', baseTheme: 'ocean-dark'});
|
|
69
|
+
assert.equal(r.body.id, 'ocean-dark-2');
|
|
70
|
+
await call('DELETE', '/themes/ocean-dark-2');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('use on the site and in the admin goes through the CMS theme config', async () => {
|
|
74
|
+
const site = await call('POST', '/themes/brand-blue/use', {where: 'site'});
|
|
75
|
+
assert.equal(site.status, 200);
|
|
76
|
+
const admin = await call('POST', '/themes/brand-blue/use', {where: 'admin'});
|
|
77
|
+
assert.deepEqual(admin.body.usage.sort(), ['admin', 'site']);
|
|
78
|
+
const cfg = await loadThemeConfig();
|
|
79
|
+
assert.equal(cfg.theme, 'brand-blue');
|
|
80
|
+
assert.equal(cfg.adminTheme, 'brand-blue');
|
|
81
|
+
assert.equal(cfg.customBases['brand-blue'], 'ocean-dark', 'the CMS remembers the base');
|
|
82
|
+
const list = await call('GET', '/themes');
|
|
83
|
+
assert.deepEqual(list.body.find(t => t.id === 'brand-blue').usage.sort(), ['admin', 'site']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('changing the base of a theme in use moves the remembered base too', async () => {
|
|
87
|
+
const r = await call('PUT', '/themes/brand-blue', {baseTheme: 'forest-dark'});
|
|
88
|
+
assert.equal(r.body.baseTheme, 'forest-dark');
|
|
89
|
+
assert.equal((await loadThemeConfig()).customBases['brand-blue'], 'forest-dark');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('duplicate and rename', async () => {
|
|
93
|
+
const copy = await call('POST', '/themes/brand-blue/duplicate', {label: 'Brand Blue copy'});
|
|
94
|
+
assert.equal(copy.body.id, 'brand-blue-copy');
|
|
95
|
+
assert.deepEqual(copy.body.usage, [], 'a copy is not in use');
|
|
96
|
+
const renamed = await call('PUT', '/themes/brand-blue-copy', {label: 'Second brand'});
|
|
97
|
+
assert.equal(renamed.body.label, 'Second brand');
|
|
98
|
+
assert.equal(renamed.body.id, 'brand-blue-copy', 'renaming keeps the id');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('unuse puts a place back on the base', async () => {
|
|
102
|
+
await call('POST', '/themes/brand-blue/unuse', {where: 'admin'});
|
|
103
|
+
assert.equal((await loadThemeConfig()).adminTheme, 'forest-dark');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('deleting a theme in use leaves nothing pointing at it', async () => {
|
|
107
|
+
const r = await call('DELETE', '/themes/brand-blue');
|
|
108
|
+
assert.deepEqual(r.body.replaced, ['theme']);
|
|
109
|
+
const cfg = await loadThemeConfig();
|
|
110
|
+
assert.equal(cfg.theme, 'forest-dark');
|
|
111
|
+
assert.ok(!listCustomThemes().some(t => t.id === 'brand-blue'));
|
|
112
|
+
await assert.rejects(fs.access(path.join(dir, 'themes', 'brand-blue.json')));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('a pre-1.1 file (name, no label) still loads', async () => {
|
|
116
|
+
await fs.writeFile(path.join(dir, 'themes', 'old-one.json'),
|
|
117
|
+
JSON.stringify({name: 'old-one', baseTheme: 'charcoal-dark', changes: {'--dm-primary': '#abcdef'}}));
|
|
118
|
+
const app2 = Fastify();
|
|
119
|
+
const pass = async () => {};
|
|
120
|
+
await app2.register(themeRollerPlugin, {themesDir: path.join(dir, 'themes'),
|
|
121
|
+
auth: {authenticate: pass, requireAdmin: pass}, hooks: {}});
|
|
122
|
+
const list = (await app2.inject({method: 'GET', url: '/themes'})).json();
|
|
123
|
+
assert.ok(list.some(t => t.id === 'old-one' && t.label === 'old-one'));
|
|
124
|
+
const status = (await app2.inject({method: 'GET', url: '/status'})).json();
|
|
125
|
+
assert.equal(status.supported, false, 'no registerThemes: the screen says why they cannot be used');
|
|
126
|
+
const use = await app2.inject({method: 'POST', url: '/themes/old-one/use', payload: {where: 'site'}});
|
|
127
|
+
assert.equal(use.statusCode, 409);
|
|
128
|
+
await app2.close();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('a crafted id reaches no file', async () => {
|
|
132
|
+
for (const id of ['..%2Fx', '../x', 'x.json']) {
|
|
133
|
+
const r = await app.inject({method: 'DELETE', url: `/themes/${id}`});
|
|
134
|
+
assert.equal(r.statusCode, 404);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('two creates of the same name at once get two themes', async () => {
|
|
139
|
+
const [a, b] = await Promise.all([
|
|
140
|
+
call('POST', '/themes', {label: 'Twin', baseTheme: 'ocean-dark', changes: {}}),
|
|
141
|
+
call('POST', '/themes', {label: 'Twin', baseTheme: 'ocean-dark', changes: {}})
|
|
142
|
+
]);
|
|
143
|
+
assert.deepEqual([a.body.id, b.body.id].sort(), ['twin', 'twin-2']);
|
|
144
|
+
await call('DELETE', '/themes/twin');
|
|
145
|
+
await call('DELETE', '/themes/twin-2');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('tokens the server drops are named in the reply', async () => {
|
|
149
|
+
const r = await call('POST', '/themes', {label: 'Dropper', baseTheme: 'ocean-dark', changes: {'--dm-primary': 'red /*', '--dm-info': '#0a0'}});
|
|
150
|
+
assert.deepEqual(r.body.changes, {'--dm-info': '#0a0'});
|
|
151
|
+
assert.deepEqual(r.body.dropped, ['--dm-primary']);
|
|
152
|
+
await call('DELETE', '/themes/dropper');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('GET /count answers the sidebar badge contract', async () => {
|
|
156
|
+
const r = await call('GET', '/count');
|
|
157
|
+
assert.equal(r.status, 200);
|
|
158
|
+
assert.equal(typeof r.body.count, 'number');
|
|
159
|
+
assert.equal(r.body.details.title, 'Theme Roller');
|
|
160
|
+
assert.ok(Array.isArray(r.body.details.rows) && Array.isArray(r.body.details.items));
|
|
161
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** No prompt here is given its starting value through E.prompt's `inputValue` (a " in it cut the value short) - promptWith in admin/lib/prompt.js. */
|
|
2
|
+
import {test} from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {fileURLToPath} from 'node:url';
|
|
7
|
+
|
|
8
|
+
const ADMIN = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'admin');
|
|
9
|
+
|
|
10
|
+
test('admin prompts set their starting value with promptWith, not inputValue', async () => {
|
|
11
|
+
const offenders = [];
|
|
12
|
+
for (const dir of ['views', 'lib']) {
|
|
13
|
+
for (const name of await fs.readdir(path.join(ADMIN, dir))) {
|
|
14
|
+
if (!name.endsWith('.js')) continue;
|
|
15
|
+
const src = await fs.readFile(path.join(ADMIN, dir, name), 'utf8');
|
|
16
|
+
src.split('\n').forEach((line, i) => {
|
|
17
|
+
if (/\binputValue\s*:/.test(line) && !/^\s*(\*|\/\/)/.test(line)) offenders.push(`${dir}/${name}:${i + 1}`);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
assert.deepEqual(offenders, []);
|
|
22
|
+
});
|