domma-cms 0.54.2 → 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 +151 -77
- 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/menu-editor.html +19 -25
- 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 +27 -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/menu-editor.js +13 -13
- 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 +11 -2
- package/bin/lib/smtp-defaults.js +53 -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/menu-highlight.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/menu-decor.mjs +1 -1
- 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/scripts/setup.js +8 -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/email.js +29 -3
- package/server/services/health.js +23 -2
- package/server/services/markdown.js +70 -9
- package/server/services/menuRender.js +28 -4
- package/server/services/menus.js +25 -1
- 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 +148 -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
|
@@ -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. */
|
|
@@ -14,7 +14,7 @@ import {colourToCss, floatToCss, floatBehaviour, spacerSize} from './menus.js';
|
|
|
14
14
|
// Highlight helpers live once, in the browser module, and are imported here -
|
|
15
15
|
// the same shared-module arrangement collection-export.mjs has with
|
|
16
16
|
// collections.js. Nothing in them touches the DOM.
|
|
17
|
-
import {highlightAttrs, isCurrentItem} from '../../public/js/menu-decor.mjs';
|
|
17
|
+
import {highlightAttrs, itemHighlightAttrs, isCurrentItem, menuAlign} from '../../public/js/menu-decor.mjs';
|
|
18
18
|
|
|
19
19
|
/** Attribute-safe escaping - mirrors escapeAttr in markdown.js. */
|
|
20
20
|
function esc(str) {
|
|
@@ -57,9 +57,26 @@ export function renderMenuItemsAsUl(items, currentPath = '') {
|
|
|
57
57
|
const current = isCurrentItem(it.url, currentPath);
|
|
58
58
|
const classAttr = current ? ' class="is-active" aria-current="page"' : '';
|
|
59
59
|
|
|
60
|
+
// Per-item highlight override. `data-menu-item-*` rather than the
|
|
61
|
+
// menu's `data-menu-*` so the CSS can let the item win without the two
|
|
62
|
+
// rules colliding on specificity - see the note in menu-highlight.css.
|
|
63
|
+
// The sanitiser allows `data-*` and `style` on every tag, so both
|
|
64
|
+
// survive the `[menu]` shortcode's trip through sanitize-html.
|
|
65
|
+
const ihl = itemHighlightAttrs(it);
|
|
66
|
+
const ihlAttrs = [
|
|
67
|
+
ihl.hover && ` data-menu-item-hover="${esc(ihl.hover)}"`,
|
|
68
|
+
ihl.active && ` data-menu-item-active="${esc(ihl.active)}"`
|
|
69
|
+
].filter(Boolean).join('');
|
|
70
|
+
|
|
60
71
|
// Colour override (the server sanitiser allows inline style on <a>).
|
|
72
|
+
// The item's accent rides in the same style attribute: it inherits, so
|
|
73
|
+
// it shadows the container's `--dm-menu-highlight` for this item alone.
|
|
61
74
|
const colourCss = it.colour ? colourToCss(it.colour) : '';
|
|
62
|
-
const
|
|
75
|
+
const styleCss = [
|
|
76
|
+
colourCss && `color:${colourCss}`,
|
|
77
|
+
ihl.accent && `--dm-menu-highlight:${ihl.accent}`
|
|
78
|
+
].filter(Boolean).join(';');
|
|
79
|
+
const styleAttr = styleCss ? ` style="${esc(styleCss)}"` : '';
|
|
63
80
|
|
|
64
81
|
// Badge - static text, or a count stamped on by resolveMenuDecorations.
|
|
65
82
|
let badge = '';
|
|
@@ -69,7 +86,7 @@ export function renderMenuItemsAsUl(items, currentPath = '') {
|
|
|
69
86
|
badge = `<span class="dm-menu-badge"${badgeStyle}>${esc(String(it.badge.text))}</span>`;
|
|
70
87
|
}
|
|
71
88
|
|
|
72
|
-
return `<li><a href="${href}"${classAttr}${styleAttr}>${text}${badge}</a>${child}</li>`;
|
|
89
|
+
return `<li><a href="${href}"${classAttr}${styleAttr}${ihlAttrs}>${text}${badge}</a>${child}</li>`;
|
|
73
90
|
}).join('');
|
|
74
91
|
return `<ul>${lis}</ul>`;
|
|
75
92
|
}
|
|
@@ -99,6 +116,13 @@ export function buildMenuNav(menu, items, opts = {}) {
|
|
|
99
116
|
: (['horizontal', 'vertical'].includes(menu.orientation) ? menu.orientation : null);
|
|
100
117
|
const orientClass = orientation ? ` dm-menu--${orientation}` : '';
|
|
101
118
|
|
|
119
|
+
// Alignment only has anywhere to go in a horizontal layout - a vertical
|
|
120
|
+
// list is as wide as its box. Unlike the navbar, an unset value emits no
|
|
121
|
+
// class: a `[menu]` in the page body has always packed left and a silent
|
|
122
|
+
// change of that on upgrade is not wanted.
|
|
123
|
+
const align = orientation === 'horizontal' ? menuAlign(menu) : '';
|
|
124
|
+
const alignClass = align ? ` dm-menu--align-${align}` : '';
|
|
125
|
+
|
|
102
126
|
const floatCss = opts.noFloat ? '' : floatToCss(menu);
|
|
103
127
|
const floatClass = floatCss ? ' dm-menu-floating' : '';
|
|
104
128
|
const floatAnchorAttr = floatCss
|
|
@@ -134,7 +158,7 @@ export function buildMenuNav(menu, items, opts = {}) {
|
|
|
134
158
|
? ` style="${esc([floatCss, hlStyle].filter(Boolean).join(';'))}"`
|
|
135
159
|
: '';
|
|
136
160
|
|
|
137
|
-
return `<nav class="dm-menu dm-menu--${esc(menu.slug)}${orientClass}${variantClass}${floatClass}${collapsedClass}${flexClass}${klass}"`
|
|
161
|
+
return `<nav class="dm-menu dm-menu--${esc(menu.slug)}${orientClass}${alignClass}${variantClass}${floatClass}${collapsedClass}${flexClass}${klass}"`
|
|
138
162
|
+ ` data-menu="${esc(menu.slug)}"${styleAttr}${floatAnchorAttr}${behAttrs}${hlAttrs}>`
|
|
139
163
|
+ buildFloatChrome(menu, beh)
|
|
140
164
|
+ renderMenuItemsAsUl(items, opts.currentPath || '')
|
package/server/services/menus.js
CHANGED
|
@@ -17,7 +17,7 @@ import * as cache from './cache/index.js';
|
|
|
17
17
|
// The highlight vocabularies are defined once, in the browser module, and
|
|
18
18
|
// imported here so validation and the renderer can never drift apart.
|
|
19
19
|
import {
|
|
20
|
-
HIGHLIGHT_HOVER, HIGHLIGHT_ACTIVE,
|
|
20
|
+
HIGHLIGHT_HOVER, HIGHLIGHT_ACTIVE, MENU_ALIGN,
|
|
21
21
|
LEGACY_STYLE_HOVER, LEGACY_STYLE_ACTIVE, legacyStyleHighlight,
|
|
22
22
|
PRESET_COLOUR_VARS, PRESET_COLOUR_FALLBACKS, colourToCss
|
|
23
23
|
} from '../../public/js/menu-decor.mjs';
|
|
@@ -437,6 +437,7 @@ export async function createMenu(input) {
|
|
|
437
437
|
...(input.variant != null && {variant: input.variant}),
|
|
438
438
|
...(input.position != null && {position: input.position}),
|
|
439
439
|
...(input.orientation != null && {orientation: input.orientation}),
|
|
440
|
+
...(input.align != null && {align: input.align}),
|
|
440
441
|
...(input.side != null && {side: input.side}),
|
|
441
442
|
...(input.appearOnHover != null && {appearOnHover: !!input.appearOnHover}),
|
|
442
443
|
...(input.float != null && {float: input.float}),
|
|
@@ -481,6 +482,7 @@ export async function updateMenu(slug, input) {
|
|
|
481
482
|
...(input.variant != null && {variant: input.variant}),
|
|
482
483
|
...(input.position != null && {position: input.position}),
|
|
483
484
|
...(input.orientation != null && {orientation: input.orientation}),
|
|
485
|
+
...(input.align != null && {align: input.align}),
|
|
484
486
|
...(input.side != null && {side: input.side}),
|
|
485
487
|
...(input.appearOnHover != null && {appearOnHover: !!input.appearOnHover}),
|
|
486
488
|
...(input.float != null && {float: input.float}),
|
|
@@ -677,6 +679,9 @@ export function validateMenu(menu) {
|
|
|
677
679
|
}
|
|
678
680
|
}
|
|
679
681
|
}
|
|
682
|
+
if (menu.align != null && menu.align !== '' && !MENU_ALIGN.includes(menu.align)) {
|
|
683
|
+
errors.push(`align must be one of: ${MENU_ALIGN.join(', ')}`);
|
|
684
|
+
}
|
|
680
685
|
if (menu.highlight != null) {
|
|
681
686
|
if (typeof menu.highlight !== 'object' || Array.isArray(menu.highlight)) {
|
|
682
687
|
errors.push('highlight must be an object');
|
|
@@ -760,6 +765,25 @@ export function validateMenu(menu) {
|
|
|
760
765
|
if (item.colour != null && !isValidColour(item.colour)) {
|
|
761
766
|
errors.push(`Item ${path}: colour must be a preset key or #rrggbb`);
|
|
762
767
|
}
|
|
768
|
+
// Per-item highlight override. Same shape and same value sets as
|
|
769
|
+
// the menu-level `highlight` block validated above - an item that
|
|
770
|
+
// omits a half falls through to the menu's choice for that half.
|
|
771
|
+
if (item.highlight != null) {
|
|
772
|
+
if (typeof item.highlight !== 'object' || Array.isArray(item.highlight)) {
|
|
773
|
+
errors.push(`Item ${path}: highlight must be an object`);
|
|
774
|
+
} else {
|
|
775
|
+
const {hover, active, accent} = item.highlight;
|
|
776
|
+
if (hover != null && hover !== '' && !HIGHLIGHT_HOVER.includes(hover)) {
|
|
777
|
+
errors.push(`Item ${path}: highlight.hover must be one of: ${HIGHLIGHT_HOVER.join(', ')}`);
|
|
778
|
+
}
|
|
779
|
+
if (active != null && active !== '' && !HIGHLIGHT_ACTIVE.includes(active)) {
|
|
780
|
+
errors.push(`Item ${path}: highlight.active must be one of: ${HIGHLIGHT_ACTIVE.join(', ')}`);
|
|
781
|
+
}
|
|
782
|
+
if (accent != null && accent !== '' && !isValidColour(accent)) {
|
|
783
|
+
errors.push(`Item ${path}: highlight.accent must be a preset key or #rrggbb`);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
763
787
|
});
|
|
764
788
|
}
|
|
765
789
|
return errors;
|
|
@@ -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',
|
|
@@ -7,8 +7,16 @@
|
|
|
7
7
|
* valid names), paths are normalised and containment-checked after symlink
|
|
8
8
|
* resolution, and only known text extensions are editable.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* A plugin marked `"closedSource": true` in its manifest is refused - licensed
|
|
11
|
+
* code is distributed to a site so it can RUN there, not so it can be read out
|
|
12
|
+
* of it, and the editor was handing over the server source in full. The one
|
|
13
|
+
* exception is a SUPER-ADMIN (role level 0), which on the fleet exists only on
|
|
14
|
+
* the manager: the people who publish the paid plugins are the people who need
|
|
15
|
+
* to read and edit them. Customer sites are all level 1, so nothing changes
|
|
16
|
+
* there - see the `allowLicensed` argument, set from the request's user.
|
|
17
|
+
*
|
|
18
|
+
* Thrown errors carry `statusCode` (400 invalid, 403 licensed, 404 missing,
|
|
19
|
+
* 413 too large) for direct mapping in the route layer.
|
|
12
20
|
*/
|
|
13
21
|
import fs from 'fs/promises';
|
|
14
22
|
import path from 'path';
|
|
@@ -40,7 +48,7 @@ function fail(statusCode, message) {
|
|
|
40
48
|
* @param {string} name
|
|
41
49
|
* @returns {Promise<string>} Absolute plugin directory
|
|
42
50
|
*/
|
|
43
|
-
async function resolvePluginDir(name) {
|
|
51
|
+
async function resolvePluginDir(name, allowLicensed = false) {
|
|
44
52
|
if (!NAME_PATTERN.test(String(name || ''))) throw fail(400, 'Invalid plugin name.');
|
|
45
53
|
const dir = path.join(PLUGINS_DIR, name);
|
|
46
54
|
try {
|
|
@@ -49,9 +57,42 @@ async function resolvePluginDir(name) {
|
|
|
49
57
|
} catch {
|
|
50
58
|
throw fail(404, `Plugin "${name}" not found.`);
|
|
51
59
|
}
|
|
60
|
+
|
|
61
|
+
// Every operation in this module goes through here, which is why the check
|
|
62
|
+
// lives here and not in four places that could drift apart.
|
|
63
|
+
if (!allowLicensed && await isClosedSource(dir)) {
|
|
64
|
+
throw fail(403, `"${name}" is a licensed plugin. Its source is not editable here.`);
|
|
65
|
+
}
|
|
52
66
|
return dir;
|
|
53
67
|
}
|
|
54
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Is this plugin's source off limits to the code editor?
|
|
71
|
+
*
|
|
72
|
+
* A licensed plugin is distributed to a site so it can RUN there, not so it
|
|
73
|
+
* can be read and copied - and the editor made it readable AND writable to
|
|
74
|
+
* anyone holding `plugins.develop`, which on a customer's own site is the
|
|
75
|
+
* customer. Writable is the worse half: the licence check is in that file.
|
|
76
|
+
*
|
|
77
|
+
* Declared by the plugin itself (`"closedSource": true` in plugin.json) so the
|
|
78
|
+
* flag travels with it wherever it is distributed, rather than depending on a
|
|
79
|
+
* list somewhere that a new paid plugin would have to be remembered into.
|
|
80
|
+
*
|
|
81
|
+
* A manifest that cannot be read is treated as closed. This gate failing open
|
|
82
|
+
* would publish the very thing it exists to protect.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} dir - absolute plugin directory
|
|
85
|
+
* @returns {Promise<boolean>}
|
|
86
|
+
*/
|
|
87
|
+
async function isClosedSource(dir) {
|
|
88
|
+
try {
|
|
89
|
+
const manifest = JSON.parse(await fs.readFile(path.join(dir, 'plugin.json'), 'utf8'));
|
|
90
|
+
return manifest.closedSource === true;
|
|
91
|
+
} catch {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
55
96
|
/**
|
|
56
97
|
* Resolve a relative file path inside the plugin dir, or throw.
|
|
57
98
|
* Rejects absolute paths, traversal, hidden segments and unknown extensions.
|
|
@@ -98,8 +139,8 @@ async function resolveFilePath(pluginDir, relPath) {
|
|
|
98
139
|
* @param {string} name
|
|
99
140
|
* @returns {Promise<Array<{path: string, size: number, modified: string}>>}
|
|
100
141
|
*/
|
|
101
|
-
export async function listPluginFiles(name) {
|
|
102
|
-
const pluginDir = await resolvePluginDir(name);
|
|
142
|
+
export async function listPluginFiles(name, {allowLicensed = false} = {}) {
|
|
143
|
+
const pluginDir = await resolvePluginDir(name, allowLicensed);
|
|
103
144
|
const files = [];
|
|
104
145
|
|
|
105
146
|
async function walk(dir) {
|
|
@@ -132,8 +173,8 @@ export async function listPluginFiles(name) {
|
|
|
132
173
|
* @param {string} relPath
|
|
133
174
|
* @returns {Promise<{path: string, content: string}>}
|
|
134
175
|
*/
|
|
135
|
-
export async function readPluginFile(name, relPath) {
|
|
136
|
-
const pluginDir = await resolvePluginDir(name);
|
|
176
|
+
export async function readPluginFile(name, relPath, {allowLicensed = false} = {}) {
|
|
177
|
+
const pluginDir = await resolvePluginDir(name, allowLicensed);
|
|
137
178
|
const abs = await resolveFilePath(pluginDir, relPath);
|
|
138
179
|
|
|
139
180
|
let stat;
|
|
@@ -158,8 +199,8 @@ export async function readPluginFile(name, relPath) {
|
|
|
158
199
|
* @param {string} content
|
|
159
200
|
* @returns {Promise<{path: string, restartRequired: boolean}>}
|
|
160
201
|
*/
|
|
161
|
-
export async function writePluginFile(name, relPath, content) {
|
|
162
|
-
const pluginDir = await resolvePluginDir(name);
|
|
202
|
+
export async function writePluginFile(name, relPath, content, {allowLicensed = false} = {}) {
|
|
203
|
+
const pluginDir = await resolvePluginDir(name, allowLicensed);
|
|
163
204
|
const abs = await resolveFilePath(pluginDir, relPath);
|
|
164
205
|
|
|
165
206
|
if (typeof content !== 'string') throw fail(400, 'Content must be a string.');
|
|
@@ -198,8 +239,8 @@ export async function writePluginFile(name, relPath, content) {
|
|
|
198
239
|
* @param {string} relPath
|
|
199
240
|
* @returns {Promise<{path: string}>}
|
|
200
241
|
*/
|
|
201
|
-
export async function deletePluginFile(name, relPath) {
|
|
202
|
-
const pluginDir = await resolvePluginDir(name);
|
|
242
|
+
export async function deletePluginFile(name, relPath, {allowLicensed = false} = {}) {
|
|
243
|
+
const pluginDir = await resolvePluginDir(name, allowLicensed);
|
|
203
244
|
const abs = await resolveFilePath(pluginDir, relPath);
|
|
204
245
|
const rel = path.relative(pluginDir, abs).split(path.sep).join('/');
|
|
205
246
|
|