domma-cms 0.55.1 → 0.66.0
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/CLAUDE.md +130 -2
- package/README.md +4 -4
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +3 -3
- package/admin/js/lib/page-picker.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -0
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/lib/slideover-resizable.js +1 -0
- package/admin/js/templates/context-menu-editor.html +212 -0
- package/admin/js/templates/context-menus.html +16 -0
- package/admin/js/templates/plugin-code.html +1 -1
- package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
- package/admin/js/templates/settings.html +16 -97
- package/admin/js/templates/theme.html +173 -0
- package/admin/js/views/context-menu-editor.js +55 -0
- package/admin/js/views/context-menus.js +5 -0
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +25 -23
- package/admin/js/views/search.js +1 -0
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +1 -0
- package/bin/cli.js +2 -0
- package/bin/update.js +13 -2
- package/config/menus/admin-sidebar.json +129 -23
- package/config/plugins.json +5 -5
- package/config/search.json +13 -0
- package/config/theme.json +18 -0
- package/package.json +12 -4
- package/plugins/_lib/admin/mail/compose-window.js +914 -0
- package/plugins/_lib/admin/mail/contacts.js +301 -0
- package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
- package/plugins/_lib/admin/mail/folder-tree.js +254 -0
- package/plugins/_lib/admin/mail/identity.js +480 -0
- package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
- package/plugins/_lib/admin/mail/keyboard.js +136 -0
- package/plugins/_lib/admin/mail/mail.css +1 -0
- package/plugins/_lib/admin/mail/mail.html +71 -0
- package/plugins/_lib/admin/mail/panes.js +253 -0
- package/plugins/_lib/admin/mail/reader-view.js +4453 -0
- package/plugins/_lib/admin/mail/resizable.js +26 -0
- package/plugins/_lib/admin/mail/rules.js +343 -0
- package/plugins/_lib/admin/mail/scheduling.js +203 -0
- package/plugins/_lib/admin/mail/section-kit.js +277 -0
- package/plugins/_lib/admin/mail/templates.js +238 -0
- package/plugins/_lib/admin/mail/threads.js +200 -0
- package/plugins/_lib/admin/mail/vacation.js +269 -0
- package/plugins/_lib/admin/ui/help.css +1 -0
- package/plugins/_lib/admin/ui/help.js +174 -0
- package/plugins/_lib/admin/ui/resizable.js +151 -0
- package/plugins/_lib/dataStore.js +117 -0
- package/plugins/_lib/mail/accounts.js +919 -0
- package/plugins/_lib/mail/bodyTokens.js +101 -0
- package/plugins/_lib/mail/compose.js +256 -0
- package/plugins/_lib/mail/defaults.js +57 -0
- package/plugins/_lib/mail/diagnostics.js +154 -0
- package/plugins/_lib/mail/envelope.js +161 -0
- package/plugins/_lib/mail/folders.js +192 -0
- package/plugins/_lib/mail/handoff.js +274 -0
- package/plugins/_lib/mail/imapPool.js +293 -0
- package/plugins/_lib/mail/mbox.js +74 -0
- package/plugins/_lib/mail/pollSchedule.js +79 -0
- package/plugins/_lib/mail/poller.js +135 -0
- package/plugins/_lib/mail/priority.js +138 -0
- package/plugins/_lib/mail/readRoutes.js +680 -0
- package/plugins/_lib/mail/render.js +291 -0
- package/plugins/_lib/mail/ruleRunner.js +152 -0
- package/plugins/_lib/mail/scheduler.js +254 -0
- package/plugins/_lib/mail/secretbox.js +229 -0
- package/plugins/_lib/mail/send.js +396 -0
- package/plugins/_lib/mail/store.js +1002 -0
- package/plugins/_lib/mail/sync.js +292 -0
- package/plugins/_lib/mail/syncPlan.js +126 -0
- package/plugins/_lib/mail/syncSelection.js +82 -0
- package/plugins/_lib/mail/unsubscribe.js +183 -0
- package/plugins/_lib/mail/vacationRunner.js +114 -0
- package/plugins/_lib/mail/write.js +473 -0
- package/plugins/_lib/schemaSync.js +83 -0
- package/plugins/_template/admin/css/index.css +0 -0
- package/plugins/_template/admin/templates/index.html +4 -4
- package/plugins/_template/admin/views/index.js +7 -0
- package/plugins/analytics/admin/css/index.css +1 -0
- package/plugins/analytics/admin/templates/analytics.html +22 -13
- package/plugins/analytics/plugin.json +3 -0
- package/plugins/blog/admin/css/index.css +1 -0
- package/plugins/blog/admin/templates/blog.html +30 -18
- package/plugins/blog/admin/templates/categories.html +2 -2
- package/plugins/blog/admin/templates/comments.html +2 -2
- package/plugins/blog/admin/templates/post-editor.html +34 -34
- package/plugins/blog/admin/templates/settings.html +6 -3
- package/plugins/blog/admin/views/blog.js +8 -5
- package/plugins/blog/admin/views/categories.js +5 -10
- package/plugins/blog/admin/views/comments.js +5 -5
- package/plugins/blog/admin/views/post-editor.js +39 -20
- package/plugins/blog/admin/views/settings.js +52 -50
- package/plugins/blog/collections/categories/schema.json +7 -6
- package/plugins/blog/collections/comments/schema.json +11 -10
- package/plugins/blog/collections/posts/schema.json +14 -13
- package/plugins/blog/plugin.js +36 -13
- package/plugins/blog/plugin.json +13 -5
- package/plugins/blog/plugin.public.js +312 -0
- package/plugins/contacts/admin/css/index.css +1 -0
- package/plugins/contacts/admin/templates/contacts.html +128 -0
- package/plugins/contacts/admin/views/contacts.js +237 -4
- package/plugins/contacts/collections/user-contacts/schema.json +108 -0
- package/plugins/contacts/plugin.js +214 -27
- package/plugins/contacts/plugin.json +4 -1
- package/plugins/invoice/admin/css/index.css +1 -0
- package/plugins/invoice/admin/templates/editor.html +140 -49
- package/plugins/invoice/admin/templates/index.html +153 -23
- package/plugins/invoice/admin/templates/issuers.html +2 -5
- package/plugins/invoice/admin/templates/receivers.html +2 -5
- package/plugins/invoice/admin/views/contacts-source.js +266 -0
- package/plugins/invoice/admin/views/editor.js +366 -199
- package/plugins/invoice/admin/views/export.js +199 -0
- package/plugins/invoice/admin/views/help-content.js +61 -0
- package/plugins/invoice/admin/views/index.js +582 -94
- package/plugins/invoice/admin/views/issuers.js +24 -17
- package/plugins/invoice/admin/views/media.js +172 -0
- package/plugins/invoice/admin/views/party-view.js +305 -67
- package/plugins/invoice/admin/views/payments.js +127 -0
- package/plugins/invoice/admin/views/print.js +130 -0
- package/plugins/invoice/admin/views/receivers.js +49 -16
- package/plugins/invoice/admin/views/send.js +212 -0
- package/plugins/invoice/admin/views/settings.js +594 -0
- package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
- package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
- package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
- package/plugins/invoice/collections/invoices/schema.json +19 -13
- package/plugins/invoice/config.js +27 -6
- package/plugins/invoice/pdf.js +164 -0
- package/plugins/invoice/plugin.js +1217 -44
- package/plugins/invoice/plugin.json +10 -9
- package/plugins/invoice/templates/_base.css +1 -0
- package/plugins/invoice/templates/classic-nologo.html +100 -0
- package/plugins/invoice/templates/classic.html +91 -0
- package/plugins/invoice/templates/invoice-print.html +24 -0
- package/plugins/invoice/templates/minimal.html +99 -0
- package/plugins/invoice/templates/modern-nologo.html +114 -0
- package/plugins/invoice/templates/modern.html +113 -0
- package/plugins/invoice/templates/templates.json +11 -0
- package/plugins/mail-reader/admin/views/mail.js +19 -0
- package/plugins/mail-reader/config.js +7 -0
- package/plugins/mail-reader/plugin.js +48 -0
- package/plugins/mail-reader/plugin.json +33 -0
- package/plugins/notes/admin/views/notes.js +1 -1
- package/plugins/notes/plugin.json +2 -2
- package/plugins/surveys/lib/audience.js +37 -0
- package/plugins/surveys/lib/campaigns.js +43 -0
- package/plugins/surveys/lib/ledger.js +110 -0
- package/plugins/surveys/lib/sending.js +106 -0
- package/plugins/surveys/lib/stats.js +62 -0
- package/plugins/surveys/lib/submit.js +95 -0
- package/plugins/surveys/lib/tokens.js +28 -0
- package/plugins/surveys/plugin.public.js +149 -0
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
- package/public/css/forms.css +1 -1
- package/public/css/search.css +1 -0
- package/public/css/site.css +1 -1
- package/public/js/collection-context.js +2 -2
- package/public/js/context-menus.js +1 -0
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/search.js +1 -0
- package/public/js/site.js +1 -1
- package/scripts/build.js +37 -3
- package/scripts/copy-domma.js +48 -0
- package/scripts/seed.js +1996 -0
- package/server/routes/api/collections.js +34 -0
- package/server/routes/api/context-menus.js +104 -0
- package/server/routes/api/forms.js +42 -3
- package/server/routes/api/notifications.js +69 -19
- package/server/routes/api/plugins.js +50 -6
- package/server/routes/api/search.js +43 -0
- package/server/routes/api/theme.js +69 -0
- package/server/routes/public.js +42 -7
- package/server/server.js +74 -0
- package/server/services/adapters/FileAdapter.js +6 -1
- package/server/services/content.js +26 -0
- package/server/services/contextMenus.js +477 -0
- package/server/services/markdown.js +70 -9
- package/server/services/permissionRegistry.js +24 -0
- package/server/services/pluginFiles.js +52 -11
- package/server/services/plugins.js +229 -6
- package/server/services/renderer.js +144 -22
- package/server/services/roles.js +1 -1
- package/server/services/search-migration.js +82 -0
- package/server/services/search.js +413 -0
- package/server/services/sidebar-migration.js +1 -0
- package/server/services/themeSettings.js +541 -0
- package/server/services/users.js +8 -0
- package/server/templates/page.html +4 -2
- package/plugins/contacts/data/contacts.json +0 -20
- package/plugins/notes/data/notes.json +0 -1
- package/plugins/site-search/admin/views/site-search.js +0 -116
- package/plugins/site-search/config.js +0 -15
- package/plugins/site-search/plugin.js +0 -188
- package/plugins/site-search/plugin.json +0 -40
- package/plugins/site-search/public/inject-body.html +0 -17
- package/plugins/site-search/public/inject-head.html +0 -1
- package/plugins/site-search/public/search.css +0 -1
- package/plugins/site-search/public/search.js +0 -1
- package/plugins/todo/data/todos.json +0 -1
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context menus - right-click menus bound to a region of a page.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately NOT part of the menu system. A nav menu renders into a named
|
|
5
|
+
* slot and has a server render path; a context menu has neither. It binds to a
|
|
6
|
+
* CSS selector, exists only in the browser, and is resolved at right-click time
|
|
7
|
+
* by Domma walking outward from the clicked element. So `resolveLocation` and
|
|
8
|
+
* `buildMenuNav` are not reused here - see the domma-menus skill, which says as
|
|
9
|
+
* much.
|
|
10
|
+
*
|
|
11
|
+
* What IS reused is `binding`, in exactly the shape menus already use, so
|
|
12
|
+
* per-page and per-project scoping behaves identically and editors only learn
|
|
13
|
+
* it once.
|
|
14
|
+
*
|
|
15
|
+
* Storage mirrors menus: one file per menu under config/context-menus/.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'fs/promises';
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import {fileURLToPath} from 'url';
|
|
21
|
+
import * as cache from './cache/index.js';
|
|
22
|
+
|
|
23
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const CONFIG_DIR = path.resolve(__dirname, '..', '..', 'config');
|
|
25
|
+
const CTX_DIR = path.join(CONFIG_DIR, 'context-menus');
|
|
26
|
+
|
|
27
|
+
const SLUG_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
28
|
+
|
|
29
|
+
/** Cached index, invalidated on every write. Mirrors getAllMenus(). */
|
|
30
|
+
let _index = null;
|
|
31
|
+
|
|
32
|
+
export function invalidateContextMenuIndex() {
|
|
33
|
+
_index = null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Drop every cached page so an edited menu reaches the front end.
|
|
38
|
+
*
|
|
39
|
+
* `site` is the tag to use: routes/public.js stores each page under
|
|
40
|
+
* [`page:<urlPath>`, ...page.cacheTags, 'nav', 'site'], so `site` is the only
|
|
41
|
+
* one that covers all of them, and a context menu can apply to any page.
|
|
42
|
+
*
|
|
43
|
+
* This was two bugs in one line. It called `invalidateTag` (singular), which
|
|
44
|
+
* the cache module does not export, and it passed 'pages', which nothing is
|
|
45
|
+
* ever tagged with - so even the corrected call would have matched nothing.
|
|
46
|
+
* The catch swallowed the TypeError, and because the cache is off in dev and
|
|
47
|
+
* on in production, editing a menu worked locally and did nothing live for an
|
|
48
|
+
* hour. Failures are logged now: "the cache is optional" is a reason to carry
|
|
49
|
+
* on after a cache error, not a reason to hide one.
|
|
50
|
+
*/
|
|
51
|
+
async function invalidatePageCache() {
|
|
52
|
+
try {
|
|
53
|
+
await cache.invalidateTags(['site']);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.warn(`[context-menus] Page cache not invalidated: ${err.message}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function validateSlug(slug) {
|
|
60
|
+
return typeof slug === 'string' && slug.length <= 64 && SLUG_RE.test(slug);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function ensureContextMenusDir() {
|
|
64
|
+
await fs.mkdir(CTX_DIR, {recursive: true});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ctxPath(slug) {
|
|
68
|
+
return path.join(CTX_DIR, `${slug}.json`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function readCtxFile(slug) {
|
|
72
|
+
return JSON.parse(await fs.readFile(ctxPath(slug), 'utf8'));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function writeCtxFile(menu) {
|
|
76
|
+
await ensureContextMenusDir();
|
|
77
|
+
await fs.writeFile(ctxPath(menu.slug), JSON.stringify(menu, null, 2) + '\n', 'utf8');
|
|
78
|
+
invalidateContextMenuIndex();
|
|
79
|
+
await invalidatePageCache();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Validation
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
const INHERIT = new Set(['append', 'prepend', false]);
|
|
87
|
+
const TRANSITIONS = new Set(['scale', 'fade', 'slide', 'none']);
|
|
88
|
+
const DENSITIES = new Set(['comfortable', 'compact']);
|
|
89
|
+
const SCALE = new Set(['none', 'sm', 'md', 'lg', 'xl']);
|
|
90
|
+
const ACCENTS = new Set(['primary', 'success', 'danger', 'warning', 'info']);
|
|
91
|
+
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
|
92
|
+
|
|
93
|
+
/*
|
|
94
|
+
* Action types an authored item may carry. Arbitrary JavaScript is deliberately
|
|
95
|
+
* not one of them: admin/ is replaced wholesale by the updater, so anything an
|
|
96
|
+
* editor pasted would be lost on the next update, and a stored script running
|
|
97
|
+
* on public pages is a cross-site scripting hole with extra steps. Anything
|
|
98
|
+
* bespoke goes through `run-action`, which names an Actions artefact that has
|
|
99
|
+
* its own permissions.
|
|
100
|
+
*/
|
|
101
|
+
export const ACTION_TYPES = new Set([
|
|
102
|
+
'navigate', // {url}
|
|
103
|
+
'filter', // {field, op, value} - collection displays
|
|
104
|
+
'search', // opens the display's search
|
|
105
|
+
'sort', // {field, dir}
|
|
106
|
+
'export', // {format}
|
|
107
|
+
'copy-link',
|
|
108
|
+
'print',
|
|
109
|
+
'open-modal', // {block} - renders a block into a modal
|
|
110
|
+
'run-action', // {slug} - an Actions artefact
|
|
111
|
+
'mark-as-spam', // {field} - flags the clicked entry, default field `spam`
|
|
112
|
+
|
|
113
|
+
/*
|
|
114
|
+
* Specials - the things a browser's own context menu offers. They take no
|
|
115
|
+
* arguments and each decides for itself whether it applies to the click:
|
|
116
|
+
* back greys out with no history behind it, paste unless the click landed
|
|
117
|
+
* in something editable. That is `disabled` rather than hidden, so the menu
|
|
118
|
+
* keeps a stable shape and reads the way a native one does.
|
|
119
|
+
*/
|
|
120
|
+
'reload',
|
|
121
|
+
'back',
|
|
122
|
+
'forward',
|
|
123
|
+
'copy', // the current selection
|
|
124
|
+
'cut',
|
|
125
|
+
'paste',
|
|
126
|
+
'select-all',
|
|
127
|
+
'site-search' // opens the public search overlay (public/js/search.js)
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
const ITEM_TYPES = new Set(['item', 'divider', 'header', 'checkbox', 'radio']);
|
|
131
|
+
|
|
132
|
+
/*
|
|
133
|
+
* A selector an editor types is applied with querySelector at right-click time.
|
|
134
|
+
* These are the ones that would bind the menu to the entire document and make
|
|
135
|
+
* every other menu on the page unreachable - a mistake that looks like "context
|
|
136
|
+
* menus are broken" rather than like a typo.
|
|
137
|
+
*/
|
|
138
|
+
const FORBIDDEN_SELECTORS = new Set(['*', 'html', 'body', ':root', 'head']);
|
|
139
|
+
|
|
140
|
+
function isPlainObject(v) {
|
|
141
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Validate a context menu. Returns an array of human-readable errors; empty
|
|
146
|
+
* means valid. Mirrors validateMenu()'s contract so the routes handle both the
|
|
147
|
+
* same way.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} menu
|
|
150
|
+
* @returns {string[]}
|
|
151
|
+
*/
|
|
152
|
+
export function validateContextMenu(menu) {
|
|
153
|
+
const errors = [];
|
|
154
|
+
|
|
155
|
+
if (!isPlainObject(menu)) return ['Context menu must be an object'];
|
|
156
|
+
if (!validateSlug(menu.slug)) errors.push(`Invalid slug: ${menu.slug}`);
|
|
157
|
+
if (!menu.name || typeof menu.name !== 'string') errors.push('Name is required');
|
|
158
|
+
|
|
159
|
+
// --- target ---
|
|
160
|
+
const t = menu.target;
|
|
161
|
+
if (!isPlainObject(t)) {
|
|
162
|
+
errors.push('target is required');
|
|
163
|
+
} else {
|
|
164
|
+
if (!t.selector || typeof t.selector !== 'string') {
|
|
165
|
+
errors.push('target.selector is required');
|
|
166
|
+
} else {
|
|
167
|
+
const sel = t.selector.trim();
|
|
168
|
+
if (FORBIDDEN_SELECTORS.has(sel.toLowerCase())) {
|
|
169
|
+
errors.push(`target.selector "${sel}" would bind the whole page`);
|
|
170
|
+
}
|
|
171
|
+
for (const key of ['selector', 'match', 'exclude']) {
|
|
172
|
+
const v = t[key];
|
|
173
|
+
if (v === undefined || v === null || v === '') continue;
|
|
174
|
+
if (typeof v !== 'string') { errors.push(`target.${key} must be a string`); continue; }
|
|
175
|
+
try {
|
|
176
|
+
document?.querySelector?.(v);
|
|
177
|
+
} catch { /* no DOM on the server - checked below instead */ }
|
|
178
|
+
// Cheap structural check: a selector with unbalanced brackets is
|
|
179
|
+
// a typo that would otherwise throw inside the visitor's browser.
|
|
180
|
+
const balanced = (a, b) => (v.split(a).length === v.split(b).length);
|
|
181
|
+
if (!balanced('[', ']') || !balanced('(', ')')) {
|
|
182
|
+
errors.push(`target.${key} is not a valid selector: ${v}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// --- cascade ---
|
|
189
|
+
if (menu.inherit !== undefined && !INHERIT.has(menu.inherit)) {
|
|
190
|
+
errors.push('inherit must be "append", "prepend" or false');
|
|
191
|
+
}
|
|
192
|
+
if (menu.priority !== undefined && !Number.isInteger(menu.priority)) {
|
|
193
|
+
errors.push('priority must be an integer');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- theme ---
|
|
197
|
+
const th = menu.theme;
|
|
198
|
+
if (th !== undefined) {
|
|
199
|
+
if (!isPlainObject(th)) {
|
|
200
|
+
errors.push('theme must be an object');
|
|
201
|
+
} else {
|
|
202
|
+
if (th.accent && !ACCENTS.has(th.accent) && !HEX_RE.test(th.accent)) {
|
|
203
|
+
errors.push(`theme.accent must be a preset key or #rrggbb: ${th.accent}`);
|
|
204
|
+
}
|
|
205
|
+
for (const key of ['radius', 'shadow']) {
|
|
206
|
+
if (th[key] && !SCALE.has(th[key])) {
|
|
207
|
+
errors.push(`theme.${key} must be one of none, sm, md, lg, xl`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (th.density && !DENSITIES.has(th.density)) {
|
|
211
|
+
errors.push('theme.density must be "comfortable" or "compact"');
|
|
212
|
+
}
|
|
213
|
+
if (th.transition && !TRANSITIONS.has(th.transition)) {
|
|
214
|
+
errors.push('theme.transition must be scale, fade, slide or none');
|
|
215
|
+
}
|
|
216
|
+
if (th.opacity !== undefined && th.opacity !== null) {
|
|
217
|
+
const n = Number(th.opacity);
|
|
218
|
+
if (!Number.isFinite(n) || n < 20 || n > 100) {
|
|
219
|
+
errors.push('theme.opacity must be between 20 and 100');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// --- items ---
|
|
226
|
+
if (!Array.isArray(menu.items)) {
|
|
227
|
+
errors.push('items must be an array');
|
|
228
|
+
} else {
|
|
229
|
+
validateItems(menu.items, errors, 'items');
|
|
230
|
+
const usable = menu.items.filter(
|
|
231
|
+
(i) => isPlainObject(i) && !['divider', 'header'].includes(i.type)
|
|
232
|
+
);
|
|
233
|
+
if (!usable.length) {
|
|
234
|
+
errors.push('A menu needs at least one item that is not a divider or header');
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return errors;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function validateItems(items, errors, trail, depth = 0) {
|
|
242
|
+
if (depth > 4) { errors.push(`${trail}: submenus nested more than 4 deep`); return; }
|
|
243
|
+
|
|
244
|
+
items.forEach((item, i) => {
|
|
245
|
+
const at = `${trail}[${i}]`;
|
|
246
|
+
if (!isPlainObject(item)) { errors.push(`${at} must be an object`); return; }
|
|
247
|
+
|
|
248
|
+
const type = item.type || 'item';
|
|
249
|
+
if (!ITEM_TYPES.has(type)) { errors.push(`${at}.type "${type}" is not recognised`); return; }
|
|
250
|
+
if (type === 'divider') return;
|
|
251
|
+
if (!item.text || typeof item.text !== 'string') errors.push(`${at}.text is required`);
|
|
252
|
+
if (type === 'header') return;
|
|
253
|
+
|
|
254
|
+
const hasSub = Array.isArray(item.submenu) && item.submenu.length;
|
|
255
|
+
if (hasSub) {
|
|
256
|
+
validateItems(item.submenu, errors, `${at}.submenu`, depth + 1);
|
|
257
|
+
return; // a parent carries its children, not an action
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const a = item.action;
|
|
261
|
+
if (!isPlainObject(a)) {
|
|
262
|
+
errors.push(`${at}.action is required (or give it a submenu)`);
|
|
263
|
+
} else if (!ACTION_TYPES.has(a.type)) {
|
|
264
|
+
errors.push(`${at}.action.type "${a.type}" is not recognised`);
|
|
265
|
+
} else {
|
|
266
|
+
if (a.type === 'navigate' && !a.url) errors.push(`${at}.action.url is required`);
|
|
267
|
+
if (a.type === 'run-action' && !a.slug) errors.push(`${at}.action.slug is required`);
|
|
268
|
+
if (a.type === 'open-modal' && !a.block) errors.push(`${at}.action.block is required`);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function normaliseBinding(binding) {
|
|
274
|
+
if (!isPlainObject(binding)) return undefined;
|
|
275
|
+
const out = {};
|
|
276
|
+
if (Array.isArray(binding.projects)) out.projects = binding.projects.filter((p) => typeof p === 'string');
|
|
277
|
+
if (Array.isArray(binding.pages)) out.pages = binding.pages.filter((p) => typeof p === 'string');
|
|
278
|
+
return (out.projects?.length || out.pages?.length) ? out : undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function shape(input) {
|
|
282
|
+
const menu = {
|
|
283
|
+
slug: input.slug,
|
|
284
|
+
name: input.name,
|
|
285
|
+
description: input.description || '',
|
|
286
|
+
enabled: input.enabled !== false,
|
|
287
|
+
target: {
|
|
288
|
+
selector: String(input.target?.selector || '').trim(),
|
|
289
|
+
...(input.target?.match && {match: String(input.target.match).trim()}),
|
|
290
|
+
...(input.target?.exclude && {exclude: String(input.target.exclude).trim()})
|
|
291
|
+
},
|
|
292
|
+
inherit: input.inherit === undefined ? 'append' : input.inherit,
|
|
293
|
+
...(Number.isInteger(input.priority) && input.priority !== 0 && {priority: input.priority}),
|
|
294
|
+
...(isPlainObject(input.theme) && Object.keys(input.theme).length && {theme: input.theme}),
|
|
295
|
+
items: Array.isArray(input.items) ? input.items : []
|
|
296
|
+
};
|
|
297
|
+
const binding = normaliseBinding(input.binding);
|
|
298
|
+
if (binding) menu.binding = binding;
|
|
299
|
+
if (isPlainObject(input.meta) && Object.keys(input.meta).length) menu.meta = input.meta;
|
|
300
|
+
return menu;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// CRUD
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
/** List all context menus, metadata only. Sorted by slug. */
|
|
308
|
+
export async function listContextMenus() {
|
|
309
|
+
await ensureContextMenusDir();
|
|
310
|
+
const files = await fs.readdir(CTX_DIR);
|
|
311
|
+
const out = [];
|
|
312
|
+
for (const file of files) {
|
|
313
|
+
if (!file.endsWith('.json')) continue;
|
|
314
|
+
const slug = file.slice(0, -5);
|
|
315
|
+
if (!validateSlug(slug)) continue;
|
|
316
|
+
try {
|
|
317
|
+
const menu = await readCtxFile(slug);
|
|
318
|
+
out.push({
|
|
319
|
+
slug: menu.slug || slug,
|
|
320
|
+
name: menu.name || '',
|
|
321
|
+
description: menu.description || '',
|
|
322
|
+
enabled: menu.enabled !== false,
|
|
323
|
+
selector: menu.target?.selector || '',
|
|
324
|
+
match: menu.target?.match || '',
|
|
325
|
+
itemsCount: Array.isArray(menu.items) ? menu.items.length : 0,
|
|
326
|
+
...(menu.binding && {binding: menu.binding}),
|
|
327
|
+
meta: menu.meta || {}
|
|
328
|
+
});
|
|
329
|
+
} catch (err) {
|
|
330
|
+
console.warn(`[context-menus] Skipping malformed menu "${slug}": ${err.message}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return out.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Every menu in full, cached in-process. Used by the page resolver. */
|
|
337
|
+
export async function getAllContextMenus() {
|
|
338
|
+
if (_index) return _index;
|
|
339
|
+
await ensureContextMenusDir();
|
|
340
|
+
const files = await fs.readdir(CTX_DIR);
|
|
341
|
+
const out = [];
|
|
342
|
+
for (const file of files) {
|
|
343
|
+
if (!file.endsWith('.json')) continue;
|
|
344
|
+
const slug = file.slice(0, -5);
|
|
345
|
+
if (!validateSlug(slug)) continue;
|
|
346
|
+
try {
|
|
347
|
+
out.push(await readCtxFile(slug));
|
|
348
|
+
} catch { /* listContextMenus() already warns */ }
|
|
349
|
+
}
|
|
350
|
+
_index = out;
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export async function getContextMenu(slug) {
|
|
355
|
+
if (!validateSlug(slug)) return null;
|
|
356
|
+
try {
|
|
357
|
+
return await readCtxFile(slug);
|
|
358
|
+
} catch (err) {
|
|
359
|
+
if (err.code === 'ENOENT') return null;
|
|
360
|
+
throw err;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export async function createContextMenu(input) {
|
|
365
|
+
if (!validateSlug(input?.slug)) throw new Error(`Invalid slug: ${input?.slug}`);
|
|
366
|
+
if (await getContextMenu(input.slug)) throw new Error(`Context menu "${input.slug}" already exists`);
|
|
367
|
+
|
|
368
|
+
const menu = shape(input);
|
|
369
|
+
const errors = validateContextMenu(menu);
|
|
370
|
+
if (errors.length) throw new Error(errors.join('; '));
|
|
371
|
+
|
|
372
|
+
await writeCtxFile(menu);
|
|
373
|
+
return menu;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export async function updateContextMenu(slug, input) {
|
|
377
|
+
const existing = await getContextMenu(slug);
|
|
378
|
+
if (!existing) throw new Error(`Context menu "${slug}" not found`);
|
|
379
|
+
|
|
380
|
+
// The slug is the filename and the identity; renaming is a delete plus a
|
|
381
|
+
// create, not an edit.
|
|
382
|
+
const menu = shape({...existing, ...input, slug});
|
|
383
|
+
const errors = validateContextMenu(menu);
|
|
384
|
+
if (errors.length) throw new Error(errors.join('; '));
|
|
385
|
+
|
|
386
|
+
await writeCtxFile(menu);
|
|
387
|
+
return menu;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export async function deleteContextMenu(slug) {
|
|
391
|
+
if (!validateSlug(slug)) throw new Error(`Invalid slug: ${slug}`);
|
|
392
|
+
try {
|
|
393
|
+
await fs.unlink(ctxPath(slug));
|
|
394
|
+
} catch (err) {
|
|
395
|
+
if (err.code === 'ENOENT') throw new Error(`Context menu "${slug}" not found`);
|
|
396
|
+
throw err;
|
|
397
|
+
}
|
|
398
|
+
invalidateContextMenuIndex();
|
|
399
|
+
await invalidatePageCache();
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export async function duplicateContextMenu(slug) {
|
|
404
|
+
const source = await getContextMenu(slug);
|
|
405
|
+
if (!source) throw new Error(`Context menu "${slug}" not found`);
|
|
406
|
+
|
|
407
|
+
let candidate = `${slug}-copy`;
|
|
408
|
+
let n = 2;
|
|
409
|
+
while (await getContextMenu(candidate)) candidate = `${slug}-copy-${n++}`;
|
|
410
|
+
|
|
411
|
+
return createContextMenu({...source, slug: candidate, name: `${source.name} (copy)`});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
// Page resolution
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
/** `/shop/*` matches `/shop/thing`; an exact path matches itself. */
|
|
419
|
+
function matchesPage(pattern, urlPath) {
|
|
420
|
+
if (!pattern || !urlPath) return false;
|
|
421
|
+
if (pattern === urlPath) return true;
|
|
422
|
+
if (pattern.endsWith('/*')) return urlPath.startsWith(pattern.slice(0, -1));
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Every context menu that applies to a page.
|
|
428
|
+
*
|
|
429
|
+
* Unlike a slot, this is NOT one-menu-wins: several can apply at once and
|
|
430
|
+
* Domma decides which answers a given right-click by walking outward from the
|
|
431
|
+
* click. That is the same resolution the `overlay` slot uses, one level down -
|
|
432
|
+
* region rather than slot.
|
|
433
|
+
*
|
|
434
|
+
* A menu with no binding is site-wide. A menu with a binding applies only where
|
|
435
|
+
* it matches, so binding is a narrowing, never a widening.
|
|
436
|
+
*
|
|
437
|
+
* @param {{urlPath?: string, project?: string}} ctx
|
|
438
|
+
* @returns {Promise<object[]>} payloads for the browser, in binding order
|
|
439
|
+
*/
|
|
440
|
+
export async function resolveContextMenusForPage(ctx = {}) {
|
|
441
|
+
const all = await getAllContextMenus();
|
|
442
|
+
const {urlPath, project} = ctx;
|
|
443
|
+
|
|
444
|
+
const applies = all.filter((menu) => {
|
|
445
|
+
if (menu.enabled === false) return false;
|
|
446
|
+
const b = menu.binding;
|
|
447
|
+
if (!b) return true; // site-wide
|
|
448
|
+
if (b.pages?.length && urlPath && b.pages.some((p) => matchesPage(p, urlPath))) return true;
|
|
449
|
+
if (b.projects?.length && project && b.projects.includes(project)) return true;
|
|
450
|
+
return false;
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
/*
|
|
454
|
+
* A bound menu is more specific than a site-wide one, so it is sent later:
|
|
455
|
+
* where two menus bind the same element Domma breaks the tie on `priority`
|
|
456
|
+
* and then on registration order, and the more specific one should win.
|
|
457
|
+
*/
|
|
458
|
+
applies.sort((a, b) => Number(Boolean(a.binding)) - Number(Boolean(b.binding)));
|
|
459
|
+
|
|
460
|
+
return applies.map(toClientPayload);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The browser needs the binding and the items, not the editor's metadata.
|
|
465
|
+
* Keeping the payload narrow also keeps it out of the page source for menus a
|
|
466
|
+
* visitor cannot use.
|
|
467
|
+
*/
|
|
468
|
+
export function toClientPayload(menu) {
|
|
469
|
+
return {
|
|
470
|
+
slug: menu.slug,
|
|
471
|
+
target: menu.target,
|
|
472
|
+
inherit: menu.inherit === undefined ? 'append' : menu.inherit,
|
|
473
|
+
...(menu.priority && {priority: menu.priority}),
|
|
474
|
+
...(menu.theme && {theme: menu.theme}),
|
|
475
|
+
items: menu.items || []
|
|
476
|
+
};
|
|
477
|
+
}
|
|
@@ -97,12 +97,44 @@ function buildShortcodeFilter(attrs) {
|
|
|
97
97
|
* Load a block template by name from content/blocks/{name}.html.
|
|
98
98
|
* Throws if the file does not exist.
|
|
99
99
|
*
|
|
100
|
+
* The template's HTML comments are DROPPED here, before anything looks at it.
|
|
101
|
+
* Every block ships with a header comment documenting its parameters and
|
|
102
|
+
* showing an example call - documentation for whoever opens the file, which
|
|
103
|
+
* has no business on a public page and, worse, is still live markup by the
|
|
104
|
+
* time the shortcode processor sees it.
|
|
105
|
+
*
|
|
106
|
+
* `plugin-card` is what that costs: its example reads
|
|
107
|
+
* `[collection slug="plugins" display="block" block="plugin-card" …]` with no
|
|
108
|
+
* `/]`, so the processor took it for an unclosed tag and swallowed the rest of
|
|
109
|
+
* the render. Four plugin cards came out as one empty `<div>`, and the block's
|
|
110
|
+
* stylesheet came out as prose. The card that renders documentation for itself
|
|
111
|
+
* is not a rare shape - every bundled block has that comment - so this is
|
|
112
|
+
* fixed at the load point rather than in one template.
|
|
113
|
+
*
|
|
100
114
|
* @param {string} blockName
|
|
101
115
|
* @returns {Promise<string>}
|
|
102
116
|
*/
|
|
103
117
|
async function loadBlockTemplate(blockName) {
|
|
104
118
|
const safe = path.basename(blockName).replace(/[^a-z0-9-]/g, '');
|
|
105
|
-
|
|
119
|
+
const raw = await readFile(path.join(BLOCKS_DIR, `${safe}.html`), 'utf8');
|
|
120
|
+
return stripTemplateComments(raw);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Remove HTML comments from a block template.
|
|
125
|
+
*
|
|
126
|
+
* Unterminated (`<!--` with no `-->`) is treated as comment-to-end: a truncated
|
|
127
|
+
* comment leaking its remainder onto the page is the failure this exists to
|
|
128
|
+
* prevent, so the safe reading is that everything after it was meant to be one.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} tpl
|
|
131
|
+
* @returns {string}
|
|
132
|
+
*/
|
|
133
|
+
export function stripTemplateComments(tpl) {
|
|
134
|
+
return String(tpl || '')
|
|
135
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
136
|
+
.replace(/<!--[\s\S]*$/, '')
|
|
137
|
+
.trim();
|
|
106
138
|
}
|
|
107
139
|
|
|
108
140
|
/**
|
|
@@ -258,9 +290,23 @@ function blockTemplateFields(tpl) {
|
|
|
258
290
|
return [...names].map(n => ({name: n, label: n}));
|
|
259
291
|
}
|
|
260
292
|
|
|
261
|
-
|
|
293
|
+
/**
|
|
294
|
+
* Wrap a block's CSS in a `<style>` scoped to that block.
|
|
295
|
+
*
|
|
296
|
+
* BLANK LINES ARE REMOVED, and that is the whole of the second bug here. A
|
|
297
|
+
* block rendered inside another shortcode's wrapper - `[reveal]`, a `[col]`,
|
|
298
|
+
* anything - arrives mid-HTML-block as far as Markdown is concerned, and a
|
|
299
|
+
* blank line ENDS an HTML block: everything after the first one in the
|
|
300
|
+
* stylesheet came back wrapped in `<p>` and was printed on the page as text.
|
|
301
|
+
* Readable CSS is not worth a stylesheet in the body copy, so the blank lines
|
|
302
|
+
* go and the line breaks stay.
|
|
303
|
+
*/
|
|
304
|
+
export function buildBlockStyleTag(blockName, css) {
|
|
262
305
|
if (!css || !css.trim()) return '';
|
|
263
|
-
const body = String(css)
|
|
306
|
+
const body = String(css)
|
|
307
|
+
.replace(/<\/style/gi, '<\\/style')
|
|
308
|
+
.replace(/\n\s*\n+/g, '\n')
|
|
309
|
+
.trim();
|
|
264
310
|
return `<style>[data-block="${escapeAttr(blockName)}"] {\n${body}\n}</style>\n`;
|
|
265
311
|
}
|
|
266
312
|
|
|
@@ -1940,8 +1986,14 @@ export async function refreshComponentTagAllowlist() {
|
|
|
1940
1986
|
export function collectAndRewriteComponents(source) {
|
|
1941
1987
|
const used = new Set();
|
|
1942
1988
|
|
|
1989
|
+
// Scrub first: a `[component …/]` or a `<dm-x>` tag written inside a fenced
|
|
1990
|
+
// block is documentation showing the syntax, not a component to mount.
|
|
1991
|
+
// Rewriting it turns the example into the thing it was explaining, and
|
|
1992
|
+
// counting it pulls that component's script onto a page that never uses it.
|
|
1993
|
+
const {scrubbed, restore} = scrubCodeRegions(source);
|
|
1994
|
+
|
|
1943
1995
|
// Rewrite shortcode form → raw tag form.
|
|
1944
|
-
const rewritten =
|
|
1996
|
+
const rewritten = scrubbed.replace(
|
|
1945
1997
|
/\[component\s+([^\]]+?)\s*\/?\]/g,
|
|
1946
1998
|
(_, attrs) => {
|
|
1947
1999
|
const nameMatch = attrs.match(/name\s*=\s*["']([a-z][a-z0-9-]*)["']/);
|
|
@@ -1957,7 +2009,7 @@ export function collectAndRewriteComponents(source) {
|
|
|
1957
2009
|
used.add(m[1]);
|
|
1958
2010
|
}
|
|
1959
2011
|
|
|
1960
|
-
return {output: rewritten, used};
|
|
2012
|
+
return {output: restore(rewritten), used};
|
|
1961
2013
|
}
|
|
1962
2014
|
|
|
1963
2015
|
export function escapeAttr(str) {
|
|
@@ -3652,7 +3704,12 @@ async function processFormBlocks(markdown, tagSet) {
|
|
|
3652
3704
|
const raw = await readFile(filePath, 'utf8');
|
|
3653
3705
|
const form = JSON.parse(raw);
|
|
3654
3706
|
await expandReferenceFields(form);
|
|
3655
|
-
|
|
3707
|
+
// `actions` holds the notification email recipients and the webhook URL.
|
|
3708
|
+
// The embed is base64 in the page source, which is not a secret keeping
|
|
3709
|
+
// measure, so strip them exactly as GET /forms/:slug/public does - the
|
|
3710
|
+
// browser never reads them, the submit route resolves them server-side.
|
|
3711
|
+
const {actions: _actions, ...publicForm} = form;
|
|
3712
|
+
const encoded = Buffer.from(JSON.stringify(publicForm)).toString('base64');
|
|
3656
3713
|
const extraClass = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
|
|
3657
3714
|
const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
|
|
3658
3715
|
replacement = `<div class="cms-form-embed${extraClass}" data-form-inline="${escapeAttr(encoded)}"${idAttr}></div>`;
|
|
@@ -3868,11 +3925,15 @@ function processSlideoverBlocks(markdown) {
|
|
|
3868
3925
|
* @returns {Promise<string>}
|
|
3869
3926
|
*/
|
|
3870
3927
|
async function processMenuBlocks(markdown, user, ctx = {}) {
|
|
3928
|
+
// Scrub first, or a `[menu …/]` written inside a fenced block as an example
|
|
3929
|
+
// of the syntax is rendered into a real nav and then escaped into the code
|
|
3930
|
+
// block - the docs page shows the output instead of the shortcode.
|
|
3931
|
+
const {scrubbed, restore} = scrubCodeRegions(markdown);
|
|
3871
3932
|
const re = /\[menu(\s+[^\]]*?)?\s*\/\]/gi;
|
|
3872
|
-
const matches = [...
|
|
3933
|
+
const matches = [...scrubbed.matchAll(re)];
|
|
3873
3934
|
if (!matches.length) return markdown;
|
|
3874
3935
|
|
|
3875
|
-
let out =
|
|
3936
|
+
let out = scrubbed;
|
|
3876
3937
|
for (const m of matches) {
|
|
3877
3938
|
const attrs = parseShortcodeAttrs(m[1] || '');
|
|
3878
3939
|
// A bare `slug` / `location` with no value parses as the boolean `true`
|
|
@@ -3925,7 +3986,7 @@ async function processMenuBlocks(markdown, user, ctx = {}) {
|
|
|
3925
3986
|
});
|
|
3926
3987
|
out = out.replace(m[0], html);
|
|
3927
3988
|
}
|
|
3928
|
-
return out;
|
|
3989
|
+
return restore(out);
|
|
3929
3990
|
}
|
|
3930
3991
|
|
|
3931
3992
|
/** Visible diagnostic for an unusable [menu] shortcode - mirrors [form]'s. */
|
|
@@ -87,6 +87,19 @@ export const REGISTRY = [
|
|
|
87
87
|
{key: 'delete', label: 'Delete', description: 'Delete menus'}
|
|
88
88
|
]
|
|
89
89
|
},
|
|
90
|
+
{
|
|
91
|
+
key: 'context-menus',
|
|
92
|
+
label: 'Context Menus',
|
|
93
|
+
description: 'Author right-click menus and the regions they bind to.',
|
|
94
|
+
icon: 'menu',
|
|
95
|
+
group: 'Structure',
|
|
96
|
+
actions: [
|
|
97
|
+
{key: 'read', label: 'View', description: 'View context menus'},
|
|
98
|
+
{key: 'create', label: 'Create', description: 'Create new context menus'},
|
|
99
|
+
{key: 'update', label: 'Edit', description: 'Edit context menus'},
|
|
100
|
+
{key: 'delete', label: 'Delete', description: 'Delete context menus'}
|
|
101
|
+
]
|
|
102
|
+
},
|
|
90
103
|
{
|
|
91
104
|
key: 'projects',
|
|
92
105
|
label: 'Projects',
|
|
@@ -165,6 +178,17 @@ export const REGISTRY = [
|
|
|
165
178
|
{key: 'delete', label: 'Delete', description: 'Delete users'}
|
|
166
179
|
]
|
|
167
180
|
},
|
|
181
|
+
{
|
|
182
|
+
key: 'theme',
|
|
183
|
+
label: 'Theme',
|
|
184
|
+
description: 'Choose the site and admin themes, fonts, and per-element theme overrides.',
|
|
185
|
+
icon: 'droplet',
|
|
186
|
+
group: 'Configuration',
|
|
187
|
+
actions: [
|
|
188
|
+
{key: 'read', label: 'View', description: 'View theme settings and overrides'},
|
|
189
|
+
{key: 'update', label: 'Edit', description: 'Change themes, fonts and overrides'}
|
|
190
|
+
]
|
|
191
|
+
},
|
|
168
192
|
{
|
|
169
193
|
key: 'settings',
|
|
170
194
|
label: 'Settings',
|