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
package/server/services/roles.js
CHANGED
|
@@ -74,7 +74,7 @@ const SEED_ENTRIES = [
|
|
|
74
74
|
'pages', 'media', 'blocks', 'components', 'navigation', 'menus',
|
|
75
75
|
'projects', 'layouts', 'collections', 'views', 'actions',
|
|
76
76
|
'users', 'settings', 'notifications', 'plugins',
|
|
77
|
-
'api-tokens', 'api-endpoints'
|
|
77
|
+
'api-tokens', 'api-endpoints', 'context-menus', 'theme'
|
|
78
78
|
],
|
|
79
79
|
badgeClass: 'badge-warning'
|
|
80
80
|
},
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search migration - one-shot lift of the retired `site-search` plugin.
|
|
3
|
+
*
|
|
4
|
+
* Search used to be a bundled plugin that the loader force-enabled as a core
|
|
5
|
+
* feature. It is now part of the CMS, which leaves three traces on an existing
|
|
6
|
+
* install:
|
|
7
|
+
*
|
|
8
|
+
* 1. its settings, stored inside config/plugins.json,
|
|
9
|
+
* 2. its plugins.json entry, which would otherwise offer a toggle for a
|
|
10
|
+
* feature that is no longer a plugin,
|
|
11
|
+
* 3. plugins/site-search/ itself - the updater replaces the plugins it ships
|
|
12
|
+
* and adds new ones, but never removes one that has gone away, so the old
|
|
13
|
+
* directory sits there and a click in the admin would run a SECOND copy of
|
|
14
|
+
* the search UI on every public page.
|
|
15
|
+
*
|
|
16
|
+
* Idempotent: with no plugins.json entry and no plugin directory there is
|
|
17
|
+
* nothing to do, which is the state of every fresh install.
|
|
18
|
+
*/
|
|
19
|
+
import fs from 'fs';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import {normaliseSearchSettings} from './search.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Run the migration.
|
|
25
|
+
*
|
|
26
|
+
* Paths are resolved per call rather than at import: the same process can run
|
|
27
|
+
* this against more than one install (the tests do), and a path frozen at load
|
|
28
|
+
* time would point at the first one forever.
|
|
29
|
+
*
|
|
30
|
+
* @returns {string[]} What it changed, for the startup log (empty = nothing to do)
|
|
31
|
+
*/
|
|
32
|
+
export function migrateSiteSearchPlugin() {
|
|
33
|
+
const CONFIG_DIR = path.resolve('config');
|
|
34
|
+
const SEARCH_CONFIG = path.join(CONFIG_DIR, 'search.json');
|
|
35
|
+
const PLUGINS_CONFIG = path.join(CONFIG_DIR, 'plugins.json');
|
|
36
|
+
const RETIRED_PLUGIN_DIR = path.resolve('plugins', 'site-search');
|
|
37
|
+
|
|
38
|
+
const changes = [];
|
|
39
|
+
let plugins = null;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
plugins = JSON.parse(fs.readFileSync(PLUGINS_CONFIG, 'utf8'));
|
|
43
|
+
} catch {
|
|
44
|
+
// No plugins.json, or a malformed one. Neither is this migration's
|
|
45
|
+
// business - a broken file is reported loudly elsewhere.
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const stored = plugins?.['site-search'];
|
|
49
|
+
|
|
50
|
+
// 1. Settings. Only ever written when the file is absent: a site that has
|
|
51
|
+
// already saved search settings has the newer answer.
|
|
52
|
+
if (!fs.existsSync(SEARCH_CONFIG)) {
|
|
53
|
+
const settings = normaliseSearchSettings(stored?.settings || {});
|
|
54
|
+
fs.writeFileSync(SEARCH_CONFIG, JSON.stringify(settings, null, 2) + '\n');
|
|
55
|
+
changes.push(stored ? 'lifted settings out of plugins.json' : 'wrote default settings');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 2. The plugins.json entry.
|
|
59
|
+
if (plugins && stored) {
|
|
60
|
+
delete plugins['site-search'];
|
|
61
|
+
fs.writeFileSync(PLUGINS_CONFIG, JSON.stringify(plugins, null, 2) + '\n');
|
|
62
|
+
changes.push('removed the plugins.json entry');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 3. The plugin directory - but only if it is the bundled one. A directory
|
|
66
|
+
// of that name carrying anything else is somebody's own work, and this
|
|
67
|
+
// is no place to decide it should go.
|
|
68
|
+
if (fs.existsSync(RETIRED_PLUGIN_DIR)) {
|
|
69
|
+
let isBundled = false;
|
|
70
|
+
try {
|
|
71
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(RETIRED_PLUGIN_DIR, 'plugin.json'), 'utf8'));
|
|
72
|
+
isBundled = manifest.name === 'site-search' && manifest.author === 'Darryl Waterhouse';
|
|
73
|
+
} catch { /* no manifest - leave it alone */ }
|
|
74
|
+
|
|
75
|
+
if (isBundled) {
|
|
76
|
+
fs.rmSync(RETIRED_PLUGIN_DIR, {recursive: true, force: true});
|
|
77
|
+
changes.push('removed plugins/site-search/');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return changes;
|
|
82
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search Service
|
|
3
|
+
*
|
|
4
|
+
* Full-text search over the site's own pages: the index (such as it is), the
|
|
5
|
+
* scoring, and the settings behind the public search UI.
|
|
6
|
+
*
|
|
7
|
+
* This was the `site-search` plugin until it was brought into the CMS. Nothing
|
|
8
|
+
* about the algorithm changed in the move; what changed is that the settings
|
|
9
|
+
* now live in `config/search.json` rather than inside plugins.json, and the
|
|
10
|
+
* public page carries them inline instead of fetching them.
|
|
11
|
+
*/
|
|
12
|
+
import {getConfig, saveConfig} from '../config.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Settings, and the shape of them.
|
|
16
|
+
*
|
|
17
|
+
* Every key here is PUBLIC - the renderer embeds the resolved settings in the
|
|
18
|
+
* page source so the search UI needs no round trip before it can draw itself.
|
|
19
|
+
* Nothing sensitive belongs in this object.
|
|
20
|
+
*/
|
|
21
|
+
export const SEARCH_DEFAULTS = Object.freeze({
|
|
22
|
+
enabled: true,
|
|
23
|
+
placeholder: 'Search pages...',
|
|
24
|
+
keyboardShortcut: true,
|
|
25
|
+
maxResults: 10,
|
|
26
|
+
minQueryLength: 2,
|
|
27
|
+
debounceMs: 300,
|
|
28
|
+
displayMode: 'navbar',
|
|
29
|
+
position: 'bottom-right',
|
|
30
|
+
offsetX: 32,
|
|
31
|
+
offsetY: 32,
|
|
32
|
+
iconOnly: true,
|
|
33
|
+
iconSize: 52,
|
|
34
|
+
iconRadius: 26
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* How big the floating trigger may be, in pixels.
|
|
39
|
+
*
|
|
40
|
+
* Below the floor it stops being a comfortable tap target (the usual advice is
|
|
41
|
+
* ~44px, and 32 is already small for a thumb); above the ceiling it competes
|
|
42
|
+
* with the page rather than sitting beside it. 52 is what the button has
|
|
43
|
+
* always been, so a site that never touches this sees no change.
|
|
44
|
+
*/
|
|
45
|
+
const ICON_SIZE_MIN = 32;
|
|
46
|
+
const ICON_SIZE_MAX = 96;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The radius ceiling is HALF THE SIZE, not a constant: at half its width a
|
|
50
|
+
* square is already a circle, and anything beyond renders identically while
|
|
51
|
+
* storing a number the UI cannot represent. So the two settings are coupled -
|
|
52
|
+
* the size has to be resolved first and the radius clamped against it.
|
|
53
|
+
*
|
|
54
|
+
* @param {number} size
|
|
55
|
+
* @returns {number}
|
|
56
|
+
*/
|
|
57
|
+
function maxIconRadius(size) {
|
|
58
|
+
return Math.floor(size / 2);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const DISPLAY_MODES = new Set(['navbar', 'floating']);
|
|
62
|
+
const POSITIONS = new Set([
|
|
63
|
+
'top-left', 'top-right', 'left-center', 'right-center', 'bottom-left', 'bottom-right'
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/** Clamp a number into a range, falling back when it is not one. */
|
|
67
|
+
function num(value, fallback, min, max) {
|
|
68
|
+
const n = typeof value === 'number' ? value : parseInt(value, 10);
|
|
69
|
+
if (!Number.isFinite(n)) return fallback;
|
|
70
|
+
return Math.min(max, Math.max(min, Math.round(n)));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Coerce a stored or submitted settings object into the known shape.
|
|
75
|
+
*
|
|
76
|
+
* Whitelisted rather than merged: these settings are served to every visitor
|
|
77
|
+
* inside the page, so an unknown key someone PUT once should not end up in the
|
|
78
|
+
* source of every page forever.
|
|
79
|
+
*
|
|
80
|
+
* @param {object} [raw]
|
|
81
|
+
* @returns {object}
|
|
82
|
+
*/
|
|
83
|
+
export function normaliseSearchSettings(raw = {}) {
|
|
84
|
+
const placeholder = typeof raw.placeholder === 'string' && raw.placeholder.trim()
|
|
85
|
+
? raw.placeholder.trim().slice(0, 120)
|
|
86
|
+
: SEARCH_DEFAULTS.placeholder;
|
|
87
|
+
|
|
88
|
+
// Resolved before the return, because the radius is clamped against it.
|
|
89
|
+
const iconSize = num(raw.iconSize, SEARCH_DEFAULTS.iconSize, ICON_SIZE_MIN, ICON_SIZE_MAX);
|
|
90
|
+
// `num` returns its fallback UNCLAMPED, so the default 26 has to be capped
|
|
91
|
+
// here too: a site that shrinks the button without touching the radius
|
|
92
|
+
// would otherwise store 26 against a ceiling of 16 - invisible on the page,
|
|
93
|
+
// because a browser floors border-radius at half the box anyway, but the
|
|
94
|
+
// admin slider cannot represent it and would silently rewrite it on save.
|
|
95
|
+
const maxRadius = maxIconRadius(iconSize);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
enabled: raw.enabled !== false,
|
|
99
|
+
placeholder,
|
|
100
|
+
keyboardShortcut: raw.keyboardShortcut !== false,
|
|
101
|
+
maxResults: num(raw.maxResults, SEARCH_DEFAULTS.maxResults, 1, 50),
|
|
102
|
+
minQueryLength: num(raw.minQueryLength, SEARCH_DEFAULTS.minQueryLength, 1, 5),
|
|
103
|
+
debounceMs: num(raw.debounceMs, SEARCH_DEFAULTS.debounceMs, 100, 1000),
|
|
104
|
+
displayMode: DISPLAY_MODES.has(raw.displayMode) ? raw.displayMode : SEARCH_DEFAULTS.displayMode,
|
|
105
|
+
position: POSITIONS.has(raw.position) ? raw.position : SEARCH_DEFAULTS.position,
|
|
106
|
+
offsetX: num(raw.offsetX, SEARCH_DEFAULTS.offsetX, 0, 500),
|
|
107
|
+
offsetY: num(raw.offsetY, SEARCH_DEFAULTS.offsetY, 0, 500),
|
|
108
|
+
iconOnly: raw.iconOnly !== false,
|
|
109
|
+
iconSize,
|
|
110
|
+
iconRadius: Math.min(maxRadius, num(raw.iconRadius, SEARCH_DEFAULTS.iconRadius, 0, maxRadius))
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Current search settings, defaults filled in.
|
|
116
|
+
*
|
|
117
|
+
* `config/search.json` is optional: a site that has never opened the Search
|
|
118
|
+
* screen has no file, and the defaults are the answer.
|
|
119
|
+
*
|
|
120
|
+
* @returns {object}
|
|
121
|
+
*/
|
|
122
|
+
export function getSearchSettings() {
|
|
123
|
+
try {
|
|
124
|
+
return normaliseSearchSettings(getConfig('search'));
|
|
125
|
+
} catch {
|
|
126
|
+
return {...SEARCH_DEFAULTS};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Save search settings, returning what was actually stored.
|
|
132
|
+
*
|
|
133
|
+
* @param {object} patch - Partial or complete settings
|
|
134
|
+
* @returns {object}
|
|
135
|
+
*/
|
|
136
|
+
export function saveSearchSettings(patch) {
|
|
137
|
+
const next = normaliseSearchSettings({...getSearchSettings(), ...(patch || {})});
|
|
138
|
+
saveConfig('search', next);
|
|
139
|
+
return next;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Indexing and scoring
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Named entities for accented Latin letters, built from the HTML pattern
|
|
148
|
+
* (`eacute` = e + acute). Generated rather than typed out: the list is long,
|
|
149
|
+
* every entry is mechanical, and a typo in one would silently corrupt a word.
|
|
150
|
+
*/
|
|
151
|
+
const ACCENTED = (() => {
|
|
152
|
+
const map = {};
|
|
153
|
+
const accents = {
|
|
154
|
+
grave: '\u0300', acute: '\u0301', circ: '\u0302',
|
|
155
|
+
tilde: '\u0303', uml: '\u0308', ring: '\u030A', cedil: '\u0327'
|
|
156
|
+
};
|
|
157
|
+
for (const letter of 'aeiouyncAEIOUYNC') {
|
|
158
|
+
for (const [name, mark] of Object.entries(accents)) {
|
|
159
|
+
// Compose and keep it only if the pair is a real character.
|
|
160
|
+
const composed = (letter + mark).normalize('NFC');
|
|
161
|
+
if (composed.length === 1) map[letter + name] = composed;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return Object.assign(map, {
|
|
165
|
+
szlig: '\u00DF', aelig: '\u00E6', AElig: '\u00C6',
|
|
166
|
+
oslash: '\u00F8', Oslash: '\u00D8'
|
|
167
|
+
});
|
|
168
|
+
})();
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Attribute values worth indexing. A `[card title="…"]` or
|
|
172
|
+
* `[section-label label="…"]` is often the only place a heading exists, and
|
|
173
|
+
* stripping the tag takes it with it.
|
|
174
|
+
*/
|
|
175
|
+
const TEXTUAL_ATTRS = /\b(?:title|subtitle|label|text|alt|caption|heading)\s*=\s*"([^"]*)"/gi;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Reduce page source to plain prose for indexing and snippets.
|
|
179
|
+
*
|
|
180
|
+
* ORDER IS THE WHOLE JOB. The previous version stripped shortcodes first with
|
|
181
|
+
* `\[[a-z][^\]]*\][\s\S]*?\[\/[a-z]+\]`, which is non-greedy to the FIRST
|
|
182
|
+
* closing tag - so on a page opening with `[hero]…[grid][col]…[/col]` it
|
|
183
|
+
* deleted everything from `[hero` to the first `[/col]` and kept going. On a
|
|
184
|
+
* real 6,400-character page that left **226 characters, 4% of the body**, most
|
|
185
|
+
* of it orphaned `[/col]` `[/grid]` closers that no rule ever removed (they
|
|
186
|
+
* start with `/`, and every pattern required `[` followed by a letter).
|
|
187
|
+
*
|
|
188
|
+
* Both reported symptoms were that one bug: snippets reading
|
|
189
|
+
* "[/hero] [/listgroup] [/col] [/col]", and pages that plainly discuss a term
|
|
190
|
+
* scoring zero because the text had been thrown away before it was searched.
|
|
191
|
+
*
|
|
192
|
+
* So: take the code out first, then links (their `[text]` looks exactly like a
|
|
193
|
+
* shortcode and must be read as a link before anything eats it), then drop
|
|
194
|
+
* shortcode TAGS while keeping the prose between them.
|
|
195
|
+
*
|
|
196
|
+
* @param {string} content
|
|
197
|
+
* @returns {string}
|
|
198
|
+
*/
|
|
199
|
+
export function stripMarkdown(content) {
|
|
200
|
+
if (!content) return '';
|
|
201
|
+
return content
|
|
202
|
+
// Fenced code first - it can contain anything, including shortcodes
|
|
203
|
+
// written as examples, which are not this page's prose.
|
|
204
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
205
|
+
// Images carry no prose; links do, so keep the text and drop the URL.
|
|
206
|
+
// Both must run BEFORE shortcodes: `[see the docs](/docs)` starts with
|
|
207
|
+
// `[` + a letter, so a shortcode pattern would eat the link text.
|
|
208
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
|
209
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
210
|
+
// [dconfig] holds JSON, not prose.
|
|
211
|
+
.replace(/\[dconfig\b[^\]]*\][\s\S]*?\[\/dconfig\]/gi, ' ')
|
|
212
|
+
// Promote the readable attributes before the tags carrying them go.
|
|
213
|
+
.replace(/\[[a-z][a-z0-9-]*[^\]]*\]/gi, (tag) => {
|
|
214
|
+
const parts = [];
|
|
215
|
+
for (const m of tag.matchAll(TEXTUAL_ATTRS)) parts.push(m[1]);
|
|
216
|
+
return parts.length ? ' ' + parts.join(' ') + ' ' : ' ';
|
|
217
|
+
})
|
|
218
|
+
// Closing tags. These start with `/`, which is why nothing caught them.
|
|
219
|
+
.replace(/\[\/[a-z][a-z0-9-]*\]/gi, ' ')
|
|
220
|
+
// Raw <pre>/<code> blocks, CONTENT INCLUDED. Stripping only the tags
|
|
221
|
+
// left the code body behind as prose, which is how a search for "forms"
|
|
222
|
+
// answered with `fastify.cms.on('form:submitted', async (data) => {`.
|
|
223
|
+
// Code is an illustration on the page, not what the page is about.
|
|
224
|
+
.replace(/<pre\b[\s\S]*?<\/pre>/gi, ' ')
|
|
225
|
+
.replace(/<code\b[\s\S]*?<\/code>/gi, ' ')
|
|
226
|
+
// Remove HTML tags
|
|
227
|
+
.replace(/<[^>]+>/g, ' ')
|
|
228
|
+
// Remove headings (keep text)
|
|
229
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
230
|
+
// Remove bold / italic
|
|
231
|
+
.replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1')
|
|
232
|
+
.replace(/_{1,3}([^_]+)_{1,3}/g, '$1')
|
|
233
|
+
// Inline code
|
|
234
|
+
.replace(/`[^`]+`/g, ' ')
|
|
235
|
+
// Remove blockquotes marker
|
|
236
|
+
.replace(/^>\s+/gm, '')
|
|
237
|
+
// Entities are markup too. A snippet reading "data => {" is the
|
|
238
|
+
// index showing its working; decode so both the match and the snippet
|
|
239
|
+
// see the characters a reader would.
|
|
240
|
+
//
|
|
241
|
+
// Matched generically rather than from a list of names. Enumerating
|
|
242
|
+
// them left `→` in a snippet the moment a page used an arrow, and
|
|
243
|
+
// the next page would have found the next gap. Anything named that is
|
|
244
|
+
// not a character we care about becomes a space, which is the right
|
|
245
|
+
// answer for the decorative ones (arrows, bullets, dashes) that make up
|
|
246
|
+
// most of the tail.
|
|
247
|
+
.replace(/&(#\d{1,6}|#x[0-9a-f]{1,5}|[a-z][a-z0-9]{1,10});/gi, (_, ref) => {
|
|
248
|
+
const named = {
|
|
249
|
+
nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', apos: "'",
|
|
250
|
+
hellip: '…', mdash: '-', ndash: '-',
|
|
251
|
+
rsquo: "'", lsquo: "'", ldquo: '"', rdquo: '"',
|
|
252
|
+
// Accented letters are prose, not decoration. Turning
|
|
253
|
+
// `Café` into "Caf é" splits a word in half and loses
|
|
254
|
+
// the match for anyone searching "café".
|
|
255
|
+
...ACCENTED
|
|
256
|
+
};
|
|
257
|
+
if (ref[0] === '#') {
|
|
258
|
+
const code = ref[1] === 'x' || ref[1] === 'X'
|
|
259
|
+
? parseInt(ref.slice(2), 16)
|
|
260
|
+
: parseInt(ref.slice(1), 10);
|
|
261
|
+
// Control characters and anything unparseable read as a break.
|
|
262
|
+
if (!Number.isFinite(code) || code < 32) return ' ';
|
|
263
|
+
try { return String.fromCodePoint(code); } catch { return ' '; }
|
|
264
|
+
}
|
|
265
|
+
// Case matters: `É` is E-acute, `é` is e-acute.
|
|
266
|
+
// Look the exact name up first and only then fall back, or every
|
|
267
|
+
// capitalised accent arrives lowercase and "Ecole" becomes "ecole".
|
|
268
|
+
return named[ref] ?? named[ref.toLowerCase()] ?? ' ';
|
|
269
|
+
})
|
|
270
|
+
// Collapse whitespace
|
|
271
|
+
.replace(/\s+/g, ' ')
|
|
272
|
+
.trim();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Build a snippet of ~length chars around the first occurrence of term in content.
|
|
277
|
+
*
|
|
278
|
+
* @param {string} rawContent - raw markdown content
|
|
279
|
+
* @param {string} term
|
|
280
|
+
* @param {number} length
|
|
281
|
+
* @returns {string}
|
|
282
|
+
*/
|
|
283
|
+
function buildSnippet(rawContent, term, length = 120) {
|
|
284
|
+
const text = stripMarkdown(rawContent);
|
|
285
|
+
if (!text) return '';
|
|
286
|
+
|
|
287
|
+
const lower = text.toLowerCase();
|
|
288
|
+
const idx = lower.indexOf(term.toLowerCase());
|
|
289
|
+
|
|
290
|
+
if (idx === -1) {
|
|
291
|
+
return text.length > length ? text.slice(0, length) + '…' : text;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const half = Math.floor(length / 2);
|
|
295
|
+
const start = Math.max(0, idx - half);
|
|
296
|
+
const end = Math.min(text.length, start + length);
|
|
297
|
+
const snippet = text.slice(start, end);
|
|
298
|
+
|
|
299
|
+
return (start > 0 ? '…' : '') + snippet + (end < text.length ? '…' : '');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Score a page against a search query using weighted field matching.
|
|
304
|
+
*
|
|
305
|
+
* @param {object} page
|
|
306
|
+
* @param {string} query
|
|
307
|
+
* @param {string[]} words - query split into words
|
|
308
|
+
* @returns {number}
|
|
309
|
+
*/
|
|
310
|
+
export function scorePage(page, query, words) {
|
|
311
|
+
let score = 0;
|
|
312
|
+
const title = (page.title || '').toLowerCase();
|
|
313
|
+
const description = (page.description || '').toLowerCase();
|
|
314
|
+
// Coerced, not trusted. A tag list is `Array.isArray` true and still full
|
|
315
|
+
// of nulls - `tags:\n - null` is what a page ends up with when something
|
|
316
|
+
// writes an empty tag - and `null.toLowerCase()` threw, which took the
|
|
317
|
+
// WHOLE search down: one malformed page and every query on the site
|
|
318
|
+
// answered 500. Anything that is not a usable string is simply not a tag.
|
|
319
|
+
const tags = (Array.isArray(page.tags) ? page.tags : [])
|
|
320
|
+
.filter(tag => typeof tag === 'string' && tag.trim())
|
|
321
|
+
.map(tag => tag.toLowerCase());
|
|
322
|
+
const stripped = stripMarkdown(page.content || '').toLowerCase();
|
|
323
|
+
const q = query.toLowerCase();
|
|
324
|
+
|
|
325
|
+
// Title scoring
|
|
326
|
+
if (title === q) {
|
|
327
|
+
score += 100;
|
|
328
|
+
} else if (title.includes(q)) {
|
|
329
|
+
score += 60;
|
|
330
|
+
} else if (words.every(w => title.includes(w))) {
|
|
331
|
+
score += 40;
|
|
332
|
+
} else if (words.some(w => title.includes(w))) {
|
|
333
|
+
score += 20;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Tags scoring
|
|
337
|
+
if (tags.includes(q)) {
|
|
338
|
+
score += 30;
|
|
339
|
+
} else if (tags.some(t => t.includes(q))) {
|
|
340
|
+
score += 20;
|
|
341
|
+
} else if (words.every(w => tags.some(t => t.includes(w)))) {
|
|
342
|
+
score += 15;
|
|
343
|
+
} else if (words.some(w => tags.some(t => t.includes(w)))) {
|
|
344
|
+
score += 8;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Description scoring
|
|
348
|
+
if (description.includes(q)) {
|
|
349
|
+
score += 25;
|
|
350
|
+
} else if (words.some(w => description.includes(w))) {
|
|
351
|
+
score += 10;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Content scoring
|
|
355
|
+
if (stripped.includes(q)) {
|
|
356
|
+
score += 10;
|
|
357
|
+
} else if (words.every(w => stripped.includes(w))) {
|
|
358
|
+
score += 5;
|
|
359
|
+
} else if (words.some(w => stripped.includes(w))) {
|
|
360
|
+
score += 2;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return score;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Search a set of pages.
|
|
368
|
+
*
|
|
369
|
+
* Extracted from the route so it can be tested without standing a server up -
|
|
370
|
+
* and because the thing worth testing is not the routing but this: that a
|
|
371
|
+
* malformed page is skipped rather than fatal. The null-tag crash answered
|
|
372
|
+
* 500 to every query on an entire site, which reads to everyone as "search
|
|
373
|
+
* finds nothing".
|
|
374
|
+
*
|
|
375
|
+
* @param {object[]} pages
|
|
376
|
+
* @param {string} query
|
|
377
|
+
* @param {{maxResults?: number, onError?: Function}} [options]
|
|
378
|
+
* @returns {object[]}
|
|
379
|
+
*/
|
|
380
|
+
export function searchPages(pages, query, {maxResults = 10, onError = () => {}} = {}) {
|
|
381
|
+
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
382
|
+
const results = [];
|
|
383
|
+
|
|
384
|
+
for (const page of pages) {
|
|
385
|
+
// Drafts and private pages are nobody's business out here.
|
|
386
|
+
if (page.status === 'draft') continue;
|
|
387
|
+
if (page.visibility === 'private') continue;
|
|
388
|
+
|
|
389
|
+
// One page must never be able to fail the whole search. The null-tag
|
|
390
|
+
// crash was exactly this shape and the next malformed field will be
|
|
391
|
+
// too - one result missing is a far better answer than no results.
|
|
392
|
+
let score = 0;
|
|
393
|
+
try {
|
|
394
|
+
score = scorePage(page, query, words);
|
|
395
|
+
} catch (err) {
|
|
396
|
+
onError(err, page);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (score === 0) continue;
|
|
400
|
+
|
|
401
|
+
results.push({
|
|
402
|
+
title: page.title || 'Untitled',
|
|
403
|
+
url: page.urlPath,
|
|
404
|
+
description: page.description || '',
|
|
405
|
+
tags: Array.isArray(page.tags) ? page.tags : [],
|
|
406
|
+
snippet: buildSnippet(page.content || '', query),
|
|
407
|
+
score
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
results.sort((a, b) => b.score - a.score);
|
|
412
|
+
return results.slice(0, maxResults);
|
|
413
|
+
}
|
|
@@ -55,6 +55,7 @@ const SEED_ITEMS = [
|
|
|
55
55
|
{text: 'Users', url: '#/users', icon: 'users', permission: 'users'},
|
|
56
56
|
{text: 'Site Settings', url: '#/settings', icon: 'settings', permission: 'settings'},
|
|
57
57
|
{text: 'Effects', url: '#/effects', icon: 'sparkles', permission: 'settings'},
|
|
58
|
+
{text: 'Search', url: '#/search', icon: 'search', permission: 'settings'},
|
|
58
59
|
{text: 'Layouts', url: '#/layouts', icon: 'layout', permission: 'layouts'},
|
|
59
60
|
{text: 'Plugins', url: '#/plugins', icon: 'package', permission: 'plugins'},
|
|
60
61
|
{text: 'API Tokens', url: '#/api-tokens', icon: 'key', permission: 'api-tokens'},
|