domma-cms 0.92.1 → 0.94.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 +5 -3
- package/admin/css/admin.css +1 -1
- package/admin/js/app.js +2 -2
- package/admin/js/lib/action-editor-arrange.js +1 -1
- package/admin/js/lib/api-tokens-arrange.js +2 -2
- package/admin/js/lib/block-editor-arrange.js +1 -1
- package/admin/js/lib/blocks-arrange.js +1 -1
- package/admin/js/lib/collection-entries-arrange.js +1 -1
- package/admin/js/lib/components-arrange.js +1 -1
- package/admin/js/lib/dashboard-arrange.js +1 -1
- package/admin/js/lib/dates.js +1 -0
- package/admin/js/lib/forms-arrange.js +1 -1
- package/admin/js/lib/media-arrange.js +1 -1
- package/admin/js/lib/notifications-arrange.js +1 -1
- package/admin/js/lib/pages-arrange.js +1 -1
- package/admin/js/lib/related.js +1 -1
- package/admin/js/lib/timeline-builder.js +2 -2
- package/admin/js/templates/action-editor.html +6 -5
- package/admin/js/templates/actions-list.html +1 -1
- package/admin/js/templates/contacts.html +1 -1
- package/admin/js/templates/docs/api-actions.html +86 -60
- package/admin/js/templates/docs/api-authentication.html +159 -123
- package/admin/js/templates/docs/api-builder.html +197 -0
- package/admin/js/templates/docs/api-collections.html +199 -259
- package/admin/js/templates/docs/api-external.html +225 -0
- package/admin/js/templates/docs/api-forms.html +268 -0
- package/admin/js/templates/docs/api-layouts.html +70 -45
- package/admin/js/templates/docs/api-media.html +57 -80
- package/admin/js/templates/docs/api-navigation.html +66 -22
- package/admin/js/templates/docs/api-pages.html +109 -129
- package/admin/js/templates/docs/api-plugins.html +123 -61
- package/admin/js/templates/docs/api-scaffold.html +185 -0
- package/admin/js/templates/docs/api-settings.html +72 -64
- package/admin/js/templates/docs/api-users.html +74 -107
- package/admin/js/templates/docs/api-views.html +68 -54
- package/admin/js/templates/docs/components-howto.html +20 -17
- package/admin/js/templates/docs/components-reference.html +13 -16
- package/admin/js/templates/docs/components-rules.html +7 -6
- package/admin/js/templates/docs/components-walkthrough.html +19 -19
- package/admin/js/templates/docs/tutorial-crud.html +71 -40
- package/admin/js/templates/docs/tutorial-forms.html +51 -35
- package/admin/js/templates/docs/tutorial-plugin.html +132 -56
- package/admin/js/templates/docs/usage-actions.html +61 -15
- package/admin/js/templates/docs/usage-collections.html +108 -0
- package/admin/js/templates/docs/usage-cta-shortcode.html +14 -3
- package/admin/js/templates/docs/usage-dconfig.html +0 -3
- package/admin/js/templates/docs/usage-editions.html +213 -0
- package/admin/js/templates/docs/usage-media.html +22 -6
- package/admin/js/templates/docs/usage-navigation.html +74 -18
- package/admin/js/templates/docs/usage-pages.html +60 -20
- package/admin/js/templates/docs/usage-plugins.html +89 -17
- package/admin/js/templates/docs/usage-shortcodes.html +123 -70
- package/admin/js/templates/docs/usage-site-settings.html +50 -18
- package/admin/js/templates/docs/usage-tools.html +73 -0
- package/admin/js/templates/docs/usage-users-roles.html +99 -20
- package/admin/js/templates/docs/usage-views.html +36 -19
- package/admin/js/templates/documentation.html +153 -32
- package/admin/js/templates/page-editor.html +0 -5
- package/admin/js/templates/plugin-guide.html +15 -0
- package/admin/js/templates/plugin-guides.html +21 -0
- package/admin/js/templates/pro-docs.html +53 -234
- package/admin/js/templates/tutorials.html +5 -4
- package/admin/js/views/actions-list.js +3 -3
- package/admin/js/views/analytics.js +5 -5
- package/admin/js/views/api-endpoint-editor.js +2 -2
- package/admin/js/views/block-editor.js +4 -4
- package/admin/js/views/blocks.js +4 -4
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/collection-entries.js +7 -7
- package/admin/js/views/component-editor.js +2 -2
- package/admin/js/views/contacts.js +22 -20
- package/admin/js/views/context-menu-editor.js +5 -5
- package/admin/js/views/doc-pages.js +1 -1
- package/admin/js/views/form-editor.js +4 -4
- package/admin/js/views/form-submissions.js +2 -2
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/media.js +3 -3
- package/admin/js/views/menu-editor.js +13 -13
- package/admin/js/views/menu-locations.js +2 -2
- package/admin/js/views/my-profile.js +1 -1
- package/admin/js/views/page-editor.js +8 -8
- package/admin/js/views/plugin-guides.js +5 -0
- package/admin/js/views/project-detail.js +2 -2
- package/admin/js/views/project-settings.js +1 -1
- package/admin/js/views/role-editor.js +4 -4
- package/admin/js/views/search.js +2 -2
- package/admin/js/views/seo.js +17 -17
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +3 -3
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +2 -2
- package/admin/js/views/view-editor.js +1 -1
- package/bin/cli.js +13 -13
- package/bin/lib/node-version.js +29 -0
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +3 -2
- package/plugins/_lib/admin/mail/reader-view.js +7 -6
- package/plugins/_lib/admin/mail/scheduling.js +4 -2
- package/plugins/_lib/admin/mail/templates.js +4 -4
- package/plugins/_lib/admin/ui/dates.js +85 -0
- package/plugins/blog/CLAUDE.md +31 -22
- package/plugins/blog/admin/views/blog.js +3 -2
- package/plugins/blog/admin/views/comments.js +2 -1
- package/plugins/blog/admin/views/post-editor.js +4 -4
- package/plugins/blog/blocks/blog-card-row.html +1 -1
- package/plugins/blog/blocks/blog-card.html +2 -2
- package/plugins/blog/blocks/blog-post-classic.html +2 -2
- package/plugins/blog/blocks/blog-post-essay.html +2 -2
- package/plugins/blog/blocks/blog-post-feature.html +2 -2
- package/plugins/blog/blocks/blog-post-minimal.html +2 -2
- package/plugins/blog/blocks/blog-post-sidebar.html +2 -2
- package/plugins/blog/blocks/blog-post-split.html +2 -2
- package/plugins/blog/docs/guide.md +205 -0
- package/plugins/blog/lib/layouts.js +3 -3
- package/plugins/blog/lib/page.js +2 -1
- package/plugins/blog/plugin.js +3 -3
- package/plugins/blog/plugin.json +4 -4
- package/plugins/blog/tests/layouts.test.js +6 -0
- package/plugins/feedback/CLAUDE.md +22 -3
- package/plugins/feedback/admin/lib/kit.js +6 -7
- package/plugins/feedback/admin/views/feedback.js +79 -10
- package/plugins/feedback/admin/views/send.js +28 -6
- package/plugins/feedback/docs/guide.md +95 -0
- package/plugins/feedback/lib/receiver.js +9 -2
- package/plugins/feedback/lib/sender.js +3 -2
- package/plugins/feedback/plugin.js +54 -6
- package/plugins/feedback/plugin.json +4 -4
- package/plugins/feedback/tests/api.test.js +74 -2
- package/plugins/free-tier.lock.json +49 -44
- package/plugins/mail-reader/CLAUDE.md +33 -18
- package/plugins/mail-reader/docs/guide.md +147 -0
- package/plugins/mail-reader/plugin.json +1 -1
- package/plugins/security/CLAUDE.md +4 -1
- package/plugins/security/admin/views/security.js +5 -5
- package/plugins/security/docs/guide.md +170 -0
- package/plugins/security/plugin.js +2 -1
- package/plugins/security/plugin.json +2 -1
- package/plugins/shopping-cart/CLAUDE.md +7 -1
- package/plugins/shopping-cart/admin/lib/kit.js +5 -2
- package/plugins/shopping-cart/admin/views/orders.js +4 -4
- package/plugins/shopping-cart/admin/views/overview.js +2 -2
- package/plugins/shopping-cart/docs/guide.md +191 -0
- package/plugins/shopping-cart/lib/render.js +2 -1
- package/plugins/shopping-cart/plugin.json +3 -3
- package/public/js/collection-browser.js +2 -2
- package/public/js/site.js +1 -1
- package/scripts/gen-instance-secret.js +3 -1
- package/scripts/setup.js +3 -1
- package/server/middleware/auth.js +2 -1
- package/server/routes/api/actions.js +47 -27
- package/server/routes/api/blocks.js +2 -1
- package/server/routes/api/collections.js +16 -52
- package/server/routes/api/contacts.js +66 -3
- package/server/routes/api/documentation.js +42 -0
- package/server/routes/api/notifications.js +3 -2
- package/server/routes/api/users.js +10 -6
- package/server/server.js +16 -1
- package/server/services/actions.js +110 -34
- package/server/services/adapterRegistry.js +169 -16
- package/server/services/adapters/FileAdapter.js +25 -0
- package/server/services/adapters/MongoAdapter.js +23 -0
- package/server/services/collections.js +104 -1
- package/server/services/connectionManager.js +12 -0
- package/server/services/dates.js +81 -0
- package/server/services/docs.js +13 -2
- package/server/services/markdown.js +75 -26
- package/server/services/notification-sources.js +6 -5
- package/server/services/passwordReset.js +2 -1
- package/server/services/permissionRegistry.js +3 -2
- package/server/services/pluginGuides.js +255 -0
- package/server/services/pluginInstaller.js +54 -13
- package/server/services/plugins.js +29 -1
- package/server/services/presetCollections.js +31 -5
- package/server/services/renderer.js +2 -2
- package/server/services/sidebarBadges.js +3 -1
- package/server/services/tools.js +4 -2
- package/server/templates/page.html +2 -2
|
@@ -20,15 +20,24 @@ import {buildTextStyle} from '../../public/js/text-style.mjs';
|
|
|
20
20
|
const __dirname_md = path.dirname(fileURLToPath(import.meta.url));
|
|
21
21
|
const BLOCKS_DIR = path.resolve(__dirname_md, '../../content/blocks');
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Tag names the pipeline owns, so a block template with one of these names is
|
|
25
|
+
* never treated as a block short tag (`[name /]`). Keep in step with the
|
|
26
|
+
* processors in parseMarkdown() when adding a shortcode.
|
|
27
|
+
*/
|
|
28
|
+
export const BUILTIN_SHORTCODES = new Set([
|
|
29
|
+
'block', 'collection', 'view', 'dconfig', 'component', 'tabs', 'tab',
|
|
25
30
|
'accordion', 'item', 'carousel', 'slide', 'countdown', 'timeline',
|
|
26
|
-
'event', 'spacer', 'center', 'icon', 'form', 'hero', 'table', 'badge',
|
|
31
|
+
'event', 'listgroup', 'spacer', 'center', 'icon', 'demo', 'embed', 'form', 'hero', 'table', 'badge',
|
|
27
32
|
'text', 'button', 'link', 'cta', 'grid', 'row', 'col', 'card', 'box',
|
|
28
|
-
'banner', 'slideover', '
|
|
33
|
+
'banner', 'slideover', 'menu',
|
|
34
|
+
// effects
|
|
35
|
+
'counter', 'celebrate', 'firework', 'fireworks', 'scribe',
|
|
29
36
|
'reveal', 'breathe', 'pulse', 'shake', 'scramble', 'ripple', 'twinkle',
|
|
30
|
-
'ticker-tape', 'butterflies', 'strobe', 'animate', 'ambient',
|
|
31
|
-
|
|
37
|
+
'ticker-tape', 'butterflies', 'strobe', 'animate', 'ambient',
|
|
38
|
+
// named celebrations - sugar for [celebrate theme="…"]
|
|
39
|
+
'christmas', 'halloween', 'st-patricks', 'valentines', 'guy-fawkes',
|
|
40
|
+
'st-andrews', 'st-davids', 'st-georges',
|
|
32
41
|
]);
|
|
33
42
|
|
|
34
43
|
// Configure marked for safe output
|
|
@@ -380,6 +389,7 @@ function renderCollectionBlocks(entries, blockTemplate, emptyMsg, ctaOpts, cols,
|
|
|
380
389
|
let btnCls = `btn btn-${ctaStyle} dm-cta-trigger`;
|
|
381
390
|
let btnData = `data-action="${escapeAttr(ctaOpts.action)}" data-entry="${escapeAttr(e.id || '')}"`;
|
|
382
391
|
if (ctaOpts.confirm) btnData += ` data-confirm="${escapeAttr(ctaOpts.confirm)}"`;
|
|
392
|
+
if (ctaOpts.scope) btnData += ` data-scope="${escapeAttr(ctaOpts.scope)}"`;
|
|
383
393
|
const iconHtml = ctaOpts.icon ? `<span data-icon="${escapeAttr(ctaOpts.icon)}"></span> ` : '';
|
|
384
394
|
html += `\n<button class="${btnCls}" ${btnData}>${iconHtml}${escapeHtmlText(ctaOpts.label || 'Run')}</button>`;
|
|
385
395
|
}
|
|
@@ -607,6 +617,7 @@ function renderCollectionCards(entries, visibleFields, titleField, columns, empt
|
|
|
607
617
|
let btnCls = `btn btn-${ctaStyle} dm-cta-trigger`;
|
|
608
618
|
let btnData = `data-action="${escapeAttr(ctaOpts.action)}" data-entry="${escapeAttr(e.id || '')}"`;
|
|
609
619
|
if (ctaOpts.confirm) btnData += ` data-confirm="${escapeAttr(ctaOpts.confirm)}"`;
|
|
620
|
+
if (ctaOpts.scope) btnData += ` data-scope="${escapeAttr(ctaOpts.scope)}"`;
|
|
610
621
|
const iconHtml = ctaOpts.icon ? `<span data-icon="${escapeAttr(ctaOpts.icon)}"></span> ` : '';
|
|
611
622
|
footer = `<div class="card-footer"><button class="${btnCls}" ${btnData}>${iconHtml}${escapeHtmlText(ctaOpts.label || 'Run')}</button></div>`;
|
|
612
623
|
}
|
|
@@ -634,6 +645,7 @@ function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaO
|
|
|
634
645
|
let btnCls = `btn btn-${ctaStyle} dm-cta-trigger`;
|
|
635
646
|
let btnData = `data-action="${escapeAttr(ctaOpts.action)}" data-entry="${escapeAttr(e.id || '')}"`;
|
|
636
647
|
if (ctaOpts.confirm) btnData += ` data-confirm="${escapeAttr(ctaOpts.confirm)}"`;
|
|
648
|
+
if (ctaOpts.scope) btnData += ` data-scope="${escapeAttr(ctaOpts.scope)}"`;
|
|
637
649
|
const iconHtml = ctaOpts.icon ? `<span data-icon="${escapeAttr(ctaOpts.icon)}"></span> ` : '';
|
|
638
650
|
ctaHtml = `<button class="${btnCls}" ${btnData}>${iconHtml}${escapeHtmlText(ctaOpts.label || 'Run')}</button>`;
|
|
639
651
|
}
|
|
@@ -1116,7 +1128,7 @@ async function processCollectionBlocks(markdown, tagSet) {
|
|
|
1116
1128
|
// a server-rendered fragment as the no-JS fallback (visible until the
|
|
1117
1129
|
// browser hydrates and replaces it).
|
|
1118
1130
|
if (isInteractiveCollection(attrs)) {
|
|
1119
|
-
const shell = await renderCollectionBrowserShell(attrs
|
|
1131
|
+
const shell = await renderCollectionBrowserShell(attrs);
|
|
1120
1132
|
result = result.replace(fullMatch, shell);
|
|
1121
1133
|
continue;
|
|
1122
1134
|
}
|
|
@@ -1128,6 +1140,25 @@ async function processCollectionBlocks(markdown, tagSet) {
|
|
|
1128
1140
|
return restore(result);
|
|
1129
1141
|
}
|
|
1130
1142
|
|
|
1143
|
+
/**
|
|
1144
|
+
* Render a `[collection scope="mine"]` for one signed-in viewer - what
|
|
1145
|
+
* `POST /api/collections/render-scope` returns. The rows are always those the
|
|
1146
|
+
* viewer created (`createdBy = userId`, set here, never by the client). An
|
|
1147
|
+
* interactive block (searchable / sortable / filterable / paginate) comes back
|
|
1148
|
+
* as a Collection Browser shell, so `transitions` works on it; anything else
|
|
1149
|
+
* as the static fragment.
|
|
1150
|
+
*
|
|
1151
|
+
* @param {Record<string, string>} attrs - Parsed shortcode attributes
|
|
1152
|
+
* @param {string} userId
|
|
1153
|
+
* @returns {Promise<string>}
|
|
1154
|
+
*/
|
|
1155
|
+
export async function renderScopedCollection(attrs, userId) {
|
|
1156
|
+
const opts = {extraFilter: {createdBy: userId}, scope: 'mine'};
|
|
1157
|
+
return isInteractiveCollection(attrs)
|
|
1158
|
+
? renderCollectionBrowserShell(attrs, opts)
|
|
1159
|
+
: renderCollectionFragment(attrs, opts);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1131
1162
|
/**
|
|
1132
1163
|
* Detect whether a `[collection]` shortcode opts into the interactive Browser.
|
|
1133
1164
|
* Any of `searchable`, `sortable`, `filterable`, or `paginate` flips it on.
|
|
@@ -1171,10 +1202,19 @@ function isTruthyAttr(v) {
|
|
|
1171
1202
|
* A static server-rendered fragment is also embedded as the no-JS fallback -
|
|
1172
1203
|
* visible until `collection-browser.js` hydrates and replaces the shell.
|
|
1173
1204
|
*
|
|
1205
|
+
* With `scope: 'mine'` (render-scope, for `[collection scope="mine"]`) the
|
|
1206
|
+
* shell is built for one viewer: `extraFilter` (`createdBy = <user id>`) is
|
|
1207
|
+
* ANDed into the seed and the fallback, and `config.scope` makes the browser
|
|
1208
|
+
* ask for `scope=mine` on every later fetch, transition list and action run,
|
|
1209
|
+
* so the server keeps the viewer to their own rows throughout.
|
|
1210
|
+
*
|
|
1174
1211
|
* @param {Record<string, string>} attrs - Parsed shortcode attributes
|
|
1212
|
+
* @param {object} [opts]
|
|
1213
|
+
* @param {Record<string, unknown>} [opts.extraFilter] - ANDed into every listing
|
|
1214
|
+
* @param {string} [opts.scope] - 'mine' for a per-viewer shell
|
|
1175
1215
|
* @returns {Promise<string>} HTML for the hydration shell
|
|
1176
1216
|
*/
|
|
1177
|
-
async function renderCollectionBrowserShell(attrs) {
|
|
1217
|
+
async function renderCollectionBrowserShell(attrs, {extraFilter = null, scope = ''} = {}) {
|
|
1178
1218
|
const slug = attrs.slug;
|
|
1179
1219
|
const pageSize = parseInt(attrs['page-size'], 10) || 12;
|
|
1180
1220
|
const declaredMode = attrs.mode === 'server' ? 'server' : 'client';
|
|
@@ -1198,6 +1238,7 @@ async function renderCollectionBrowserShell(attrs) {
|
|
|
1198
1238
|
// Build the seed listEntries() opts - push any author-baked where_* filter
|
|
1199
1239
|
// and the initial sort/order down to the adapter, just like the static path.
|
|
1200
1240
|
const seedFilter = buildShortcodeFilter(attrs);
|
|
1241
|
+
if (extraFilter) Object.assign(seedFilter, extraFilter);
|
|
1201
1242
|
const seedOpts = {
|
|
1202
1243
|
sort: attrs.sort || 'createdAt',
|
|
1203
1244
|
order: attrs.order || 'desc'
|
|
@@ -1263,6 +1304,7 @@ async function renderCollectionBrowserShell(attrs) {
|
|
|
1263
1304
|
exportable: isTruthyAttr(attrs.exportable), // adds "Export CSV" button
|
|
1264
1305
|
savedSearches: !isTruthyAttr(attrs['no-saved-searches']), // opt-out flag (defaults on)
|
|
1265
1306
|
transitions: isTruthyAttr(attrs.transitions), // render per-row transition buttons
|
|
1307
|
+
scope: scope === 'mine' ? 'mine' : '', // per-viewer shell (render-scope)
|
|
1266
1308
|
emptyMsg: attrs.empty || 'No entries found',
|
|
1267
1309
|
cta: attrs.cta ? {
|
|
1268
1310
|
action: attrs.cta,
|
|
@@ -1278,7 +1320,7 @@ async function renderCollectionBrowserShell(attrs) {
|
|
|
1278
1320
|
|
|
1279
1321
|
// No-JS fallback - render the same first-page state with the static renderer.
|
|
1280
1322
|
const fallbackAttrs = {...attrs, limit: String(pageSize)};
|
|
1281
|
-
const fallback = await renderCollectionFragment(fallbackAttrs);
|
|
1323
|
+
const fallback = await renderCollectionFragment(fallbackAttrs, {extraFilter, scope});
|
|
1282
1324
|
|
|
1283
1325
|
// Encode payloads. Schema can be modest; data can be large in client mode,
|
|
1284
1326
|
// so we base64 once each and let the browser decode.
|
|
@@ -1420,9 +1462,10 @@ async function renderViewBrowserShell(attrs, {viewConfig = null, allowed = false
|
|
|
1420
1462
|
* @param {Record<string, string>} attrs - Parsed shortcode attributes
|
|
1421
1463
|
* @param {object} [opts]
|
|
1422
1464
|
* @param {Record<string, unknown>} [opts.extraFilter] - Additional filter conditions to AND in
|
|
1465
|
+
* @param {string} [opts.scope] - 'mine': CTA buttons carry data-scope
|
|
1423
1466
|
* @returns {Promise<string>} HTML fragment ready to inject into a page
|
|
1424
1467
|
*/
|
|
1425
|
-
export async function renderCollectionFragment(attrs, { extraFilter = null } = {}) {
|
|
1468
|
+
export async function renderCollectionFragment(attrs, { extraFilter = null, scope = '' } = {}) {
|
|
1426
1469
|
const slug = attrs.slug || '';
|
|
1427
1470
|
if (!slug) return '';
|
|
1428
1471
|
|
|
@@ -1440,7 +1483,10 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
|
|
|
1440
1483
|
label: attrs['cta-label'] || 'Run',
|
|
1441
1484
|
icon: attrs['cta-icon'] || '',
|
|
1442
1485
|
style: attrs['cta-style'] || 'primary',
|
|
1443
|
-
confirm: attrs['cta-confirm'] || ''
|
|
1486
|
+
confirm: attrs['cta-confirm'] || '',
|
|
1487
|
+
// scope="mine": the button tells the server so, and a row outside the
|
|
1488
|
+
// viewer's scope is refused when the action runs (entryInScope).
|
|
1489
|
+
...(scope === 'mine' && {scope: 'mine'})
|
|
1444
1490
|
} : null;
|
|
1445
1491
|
|
|
1446
1492
|
let replacement = `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
|
|
@@ -4221,10 +4267,13 @@ export async function parseMarkdown(raw, opts = {}) {
|
|
|
4221
4267
|
const {data, content} = matter(raw);
|
|
4222
4268
|
const extensions = getSanitizeExtensions();
|
|
4223
4269
|
|
|
4224
|
-
// Pipeline
|
|
4225
|
-
|
|
4226
|
-
//
|
|
4227
|
-
// →
|
|
4270
|
+
// Pipeline (earlier stages are already HTML when later ones run; keep in step
|
|
4271
|
+
// with "Shortcode Nesting Rules" in CLAUDE.md and docs/markdown-shortcodes.md):
|
|
4272
|
+
// beforeParse → components ([component] / <dm-*>) → collection → view → block (+ short tags) → menu
|
|
4273
|
+
// → dconfig → effects → plugin shortcodes → tabs → accordion → carousel → countdown → timeline
|
|
4274
|
+
// → listgroup → spacer → center → icon → demo → embed → form → hero → table
|
|
4275
|
+
// → badge → text → button → link → cta → grid → card (+ box) → banner → slideover
|
|
4276
|
+
// → marked → sanitize → afterParse
|
|
4228
4277
|
const preprocessed = applyTransforms('markdown:beforeParse', content);
|
|
4229
4278
|
const {output: withComponents, used: usedComponents} = collectAndRewriteComponents(preprocessed);
|
|
4230
4279
|
const tagSet = new Set();
|
|
@@ -4237,19 +4286,19 @@ export async function parseMarkdown(raw, opts = {}) {
|
|
|
4237
4286
|
: {};
|
|
4238
4287
|
const withMenu = await processMenuBlocks(withStaticBlock, opts.user || null, menuCtx);
|
|
4239
4288
|
const withDconfig = processDConfigBlocks(withMenu);
|
|
4240
|
-
|
|
4241
|
-
|
|
4289
|
+
const withEffects = processEffectsBlocks(withDconfig);
|
|
4290
|
+
const withPluginShortcodes = await processPluginShortcodes(withEffects);
|
|
4242
4291
|
const withTabs = processTabsBlocks(withPluginShortcodes);
|
|
4243
4292
|
const withAccordion = processAccordionBlocks(withTabs);
|
|
4244
4293
|
const withCarousel = processCarouselBlocks(withAccordion);
|
|
4245
4294
|
const withCountdown = processCountdownBlocks(withCarousel);
|
|
4246
4295
|
const withTimeline = processTimelineBlocks(withCountdown);
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4296
|
+
const withListGroup = processListGroupBlocks(withTimeline);
|
|
4297
|
+
const withSpacer = processSpacerBlocks(withListGroup);
|
|
4298
|
+
const withCenter = processCenterBlocks(withSpacer);
|
|
4299
|
+
const withIcon = processIconBlocks(withCenter);
|
|
4300
|
+
const withDemo = processDemoBlocks(withIcon);
|
|
4301
|
+
const withEmbed = processEmbedBlocks(withDemo);
|
|
4253
4302
|
const withForm = await processFormBlocks(withEmbed, tagSet);
|
|
4254
4303
|
const withHero = processHeroBlocks(withForm);
|
|
4255
4304
|
const withTable = processTableBlocks(withHero);
|
|
@@ -4257,11 +4306,11 @@ export async function parseMarkdown(raw, opts = {}) {
|
|
|
4257
4306
|
const withText = processTextBlocks(withBadge);
|
|
4258
4307
|
const withButton = processButtonBlocks(withText);
|
|
4259
4308
|
const withLink = processLinkBlocks(withButton);
|
|
4260
|
-
|
|
4309
|
+
const withCta = processCtaBlocks(withLink);
|
|
4261
4310
|
const withGrid = processGridBlocks(withCta);
|
|
4262
4311
|
const withCard = processCardBlocks(withGrid);
|
|
4263
|
-
|
|
4264
|
-
|
|
4312
|
+
const withBanner = processBannerBlocks(withCard);
|
|
4313
|
+
const withSlideover = processSlideoverBlocks(withBanner);
|
|
4265
4314
|
const rendered = marked.parse(withSlideover);
|
|
4266
4315
|
|
|
4267
4316
|
const sanitized = sanitizeHtml(rendered, {
|
|
@@ -19,6 +19,7 @@ import fs from 'fs';
|
|
|
19
19
|
import path from 'path';
|
|
20
20
|
import {config, getConfig} from '../config.js';
|
|
21
21
|
import {notify, registerNotificationSource, resolveNotification} from './notify.js';
|
|
22
|
+
import {fmtDate, fmtDateTime} from './dates.js';
|
|
22
23
|
|
|
23
24
|
const day = (d = new Date()) => d.toISOString().slice(0, 10);
|
|
24
25
|
|
|
@@ -37,7 +38,7 @@ const day = (d = new Date()) => d.toISOString().slice(0, 10);
|
|
|
37
38
|
*/
|
|
38
39
|
export function licenceNotice(name, label, verdict, now = new Date()) {
|
|
39
40
|
const e = verdict?.entitlement || {};
|
|
40
|
-
const ends = (iso) =>
|
|
41
|
+
const ends = (iso) => fmtDate(iso, {utc: true});
|
|
41
42
|
switch (verdict?.state) {
|
|
42
43
|
case 'unlicensed':
|
|
43
44
|
return {severity: 'critical', title: `${label} is not running: no valid licence`,
|
|
@@ -48,7 +49,7 @@ export function licenceNotice(name, label, verdict, now = new Date()) {
|
|
|
48
49
|
case 'grace': {
|
|
49
50
|
const graceEnd = new Date(Date.parse(e.expiresAt) + (Number(e.gracePeriodDays) || 0) * 86_400_000);
|
|
50
51
|
return {severity: 'warning', title: `${label} licence has expired`,
|
|
51
|
-
body: `The ${label} licence ended on ${ends(e.expiresAt)}. It keeps working until ${
|
|
52
|
+
body: `The ${label} licence ended on ${ends(e.expiresAt)}. It keeps working until ${fmtDate(graceEnd, {utc: true})}, then stops. Renew it before then.`};
|
|
52
53
|
}
|
|
53
54
|
case 'support-ended':
|
|
54
55
|
return {severity: 'info', title: `${label}: support and updates have ended`,
|
|
@@ -78,9 +79,9 @@ export function tokenNotice(data, now = new Date()) {
|
|
|
78
79
|
const name = data.name || 'An API token';
|
|
79
80
|
return left < 0
|
|
80
81
|
? {severity: 'warning', title: `API token "${name}" has expired`,
|
|
81
|
-
body: `It expired on ${
|
|
82
|
+
body: `It expired on ${fmtDate(data.expiresAt, {utc: true})}. Anything still using it is being refused - issue a new one in API Tokens.`}
|
|
82
83
|
: {severity: 'warning', title: `API token "${name}" expires in ${left} day${left === 1 ? '' : 's'}`,
|
|
83
|
-
body: `It expires on ${
|
|
84
|
+
body: `It expires on ${fmtDate(data.expiresAt, {utc: true})}. Anything using it will be refused after that - extend it or issue a new one in API Tokens.`};
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
/** Sign-in failures: this many for one account inside the window is worth telling. */
|
|
@@ -242,7 +243,7 @@ export async function registerCoreSources() {
|
|
|
242
243
|
try { fs.rmSync(crashMarker(), {force: true}); } catch { /* raised once anyway: dedupe */ }
|
|
243
244
|
await raise({severity: 'critical', dedupeKey: `crash:${marker.at}`,
|
|
244
245
|
title: 'The site restarted after a crash',
|
|
245
|
-
body: `It stopped on an unexpected error at ${
|
|
246
|
+
body: `It stopped on an unexpected error at ${fmtDateTime(marker.at, {utc: true})} UTC and was started again.\n\n${marker.message}`});
|
|
246
247
|
}
|
|
247
248
|
});
|
|
248
249
|
|
|
@@ -40,6 +40,7 @@ import {config, getConfig} from '../config.js';
|
|
|
40
40
|
import {hooks} from './hooks.js';
|
|
41
41
|
import {getUserByEmail, getUserById, getUserByResetToken, setResetToken} from './users.js';
|
|
42
42
|
import * as email from './email.js';
|
|
43
|
+
import {fmtDateTime} from './dates.js';
|
|
43
44
|
|
|
44
45
|
export const ORIGINS_FILE = path.resolve(path.dirname(config.content.usersDir), 'sessions', 'origins.json');
|
|
45
46
|
const MAX_ORIGINS = 20;
|
|
@@ -482,7 +483,7 @@ export async function passwordChangedNotice({userId, by}) {
|
|
|
482
483
|
if (!mailer.isSmtpConfigured(site.smtp)) return {sent: false, reason: 'no-smtp'};
|
|
483
484
|
const how = by === 'reset' ? 'reset' : by === userId ? 'self' : 'admin';
|
|
484
485
|
const origin = await trustedOrigin('');
|
|
485
|
-
const when = new Date()
|
|
486
|
+
const when = `${fmtDateTime(new Date(), {utc: true})} UTC`;
|
|
486
487
|
const message = buildChangedEmail({name: user.name, how, when, siteTitle: site.title,
|
|
487
488
|
signInUrl: origin ? `${origin}/admin/#/login` : ''});
|
|
488
489
|
const transport = await mailer.createTransport(site.smtp);
|
|
@@ -212,7 +212,8 @@ export const REGISTRY = [
|
|
|
212
212
|
{key: 'read', label: 'View', description: 'View own contacts'},
|
|
213
213
|
{key: 'create', label: 'Create', description: 'Add contacts and groups'},
|
|
214
214
|
{key: 'update', label: 'Edit', description: 'Edit own contacts'},
|
|
215
|
-
{key: 'delete', label: 'Delete', description: 'Delete own contacts'}
|
|
215
|
+
{key: 'delete', label: 'Delete', description: 'Delete own contacts'},
|
|
216
|
+
{key: 'manageGroups', label: 'Manage groups', description: 'Rename or delete any group, not only the ones they created - groups are shared, so this changes everyone\'s contacts'}
|
|
216
217
|
]
|
|
217
218
|
},
|
|
218
219
|
{
|
|
@@ -384,7 +385,7 @@ export const RESOURCES = REGISTRY.map(r => r.key);
|
|
|
384
385
|
export const ACTIONS = ['read', 'create', 'update', 'delete'];
|
|
385
386
|
|
|
386
387
|
/** Display order for permission groups. */
|
|
387
|
-
export const GROUP_ORDER = ['Content', 'Structure', 'Data', 'Configuration', 'Plugins'];
|
|
388
|
+
export const GROUP_ORDER = ['Content', 'Structure', 'Data', 'Configuration', 'Tools', 'Plugins'];
|
|
388
389
|
|
|
389
390
|
/**
|
|
390
391
|
* Return the action keys defined for a given resource (base or plugin-contributed).
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin guides - user documentation a plugin ships for the admin's
|
|
3
|
+
* Documentation folder.
|
|
4
|
+
*
|
|
5
|
+
* A plugin puts Markdown files in `plugins/<name>/docs/*.md`. When the plugin
|
|
6
|
+
* is loaded (enabled, licensed, not superseded), each file becomes a page under
|
|
7
|
+
* Documentation > Plugins in the admin sidebar (`#/docs/plugins/<name>`), shown
|
|
8
|
+
* to users who can use the plugin: the manifest's `docs.permission`, else the
|
|
9
|
+
* permission on its first `admin.sidebar` entry that names one, else anyone
|
|
10
|
+
* signed in to the admin.
|
|
11
|
+
*
|
|
12
|
+
* Files are plain Markdown with optional frontmatter `title` (the tab label)
|
|
13
|
+
* and `order` (a number, lowest first; `guide.md` comes first by default).
|
|
14
|
+
* Shortcodes are NOT run - a guide shows `[collection ...]` examples literally,
|
|
15
|
+
* which is what a guide wants. The HTML is sanitised like any other page body.
|
|
16
|
+
*
|
|
17
|
+
* Pure helpers are exported for tests; the registry is module state filled once
|
|
18
|
+
* per boot by registerPluginGuides() (plugins load at boot only).
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'fs/promises';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
import matter from 'gray-matter';
|
|
23
|
+
import {marked} from 'marked';
|
|
24
|
+
import sanitizeHtml from 'sanitize-html';
|
|
25
|
+
import {registerSidebarItem} from './hooks.js';
|
|
26
|
+
|
|
27
|
+
/** The Documentation folder's key in the admin-sidebar menu. */
|
|
28
|
+
export const DOCS_FOLDER_KEY = 'documentation';
|
|
29
|
+
/** Identity (url) of the Plugins sub-folder and its landing page. */
|
|
30
|
+
export const GUIDES_URL = '#/docs/plugins';
|
|
31
|
+
|
|
32
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9_-]*$/i;
|
|
33
|
+
|
|
34
|
+
/** name -> {name, displayName, description, icon, version, permission, toolUrl, dir, pages: [{slug, title, order, file}]} */
|
|
35
|
+
let _guides = new Map();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Who may read a plugin's guides: `docs.permission`, else the first sidebar
|
|
39
|
+
* entry's permission, else null (anyone signed in to the admin).
|
|
40
|
+
*
|
|
41
|
+
* @param {object} manifest
|
|
42
|
+
* @returns {string|null}
|
|
43
|
+
*/
|
|
44
|
+
export function guidePermission(manifest) {
|
|
45
|
+
const explicit = manifest?.docs?.permission;
|
|
46
|
+
if (typeof explicit === 'string' && explicit.trim()) return explicit.trim();
|
|
47
|
+
const entry = (manifest?.admin?.sidebar || []).find(s => typeof s?.permission === 'string' && s.permission);
|
|
48
|
+
return entry ? entry.permission : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Does this permission list cover `resource`? Same rule as the sidebar's canAny:
|
|
53
|
+
* the bare resource or any of its actions.
|
|
54
|
+
*
|
|
55
|
+
* @param {string[]} permissions
|
|
56
|
+
* @param {string|null} resource
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
59
|
+
export function canUse(permissions, resource) {
|
|
60
|
+
if (!resource) return true;
|
|
61
|
+
if (!Array.isArray(permissions) || !permissions.length) return false;
|
|
62
|
+
if (permissions.includes(resource)) return true;
|
|
63
|
+
return ['read', 'create', 'update', 'delete'].some(a => permissions.includes(`${resource}.${a}`));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** "shipping-zones" -> "Shipping zones"; "guide" -> "Guide". */
|
|
67
|
+
export function titleFromSlug(slug) {
|
|
68
|
+
const words = String(slug || '').replace(/^\d+[-_]/, '').replace(/[-_]+/g, ' ').trim();
|
|
69
|
+
return words ? words.charAt(0).toUpperCase() + words.slice(1) : 'Guide';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Parse one guide file's source into its page meta.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} slug - file name without `.md`
|
|
76
|
+
* @param {string} source
|
|
77
|
+
* @returns {{slug: string, title: string, order: number}}
|
|
78
|
+
*/
|
|
79
|
+
export function pageMeta(slug, source) {
|
|
80
|
+
let data = {};
|
|
81
|
+
try { data = matter(source).data || {}; } catch { data = {}; }
|
|
82
|
+
const title = typeof data.title === 'string' && data.title.trim() ? data.title.trim() : titleFromSlug(slug);
|
|
83
|
+
const order = Number.isFinite(Number(data.order)) && data.order !== null && data.order !== ''
|
|
84
|
+
? Number(data.order)
|
|
85
|
+
: (slug === 'guide' ? 0 : 100);
|
|
86
|
+
return {slug, title, order};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Pages sorted by order, then slug. */
|
|
90
|
+
export function sortPages(pages) {
|
|
91
|
+
return [...pages].sort((a, b) => (a.order - b.order) || a.slug.localeCompare(b.slug));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const SANITIZE = {
|
|
95
|
+
allowedTags: [...sanitizeHtml.defaults.allowedTags, 'img', 'h1', 'h2', 'del', 'details', 'summary'],
|
|
96
|
+
allowedAttributes: {
|
|
97
|
+
a: ['href', 'title', 'target', 'rel'],
|
|
98
|
+
img: ['src', 'alt', 'title', 'width', 'height'],
|
|
99
|
+
code: ['class'],
|
|
100
|
+
table: ['class'],
|
|
101
|
+
th: ['align', 'style'],
|
|
102
|
+
td: ['align', 'style'],
|
|
103
|
+
ol: ['start']
|
|
104
|
+
},
|
|
105
|
+
allowedStyles: {th: {'text-align': [/^(left|right|center)$/]}, td: {'text-align': [/^(left|right|center)$/]}},
|
|
106
|
+
allowedSchemes: ['http', 'https', 'mailto'],
|
|
107
|
+
transformTags: {
|
|
108
|
+
// Links leaving the admin open in a new tab; hash links stay in the SPA.
|
|
109
|
+
a: (tagName, attribs) => {
|
|
110
|
+
const href = attribs.href || '';
|
|
111
|
+
if (/^https?:\/\//i.test(href)) return {tagName, attribs: {...attribs, target: '_blank', rel: 'noopener noreferrer'}};
|
|
112
|
+
return {tagName, attribs};
|
|
113
|
+
},
|
|
114
|
+
table: () => ({tagName: 'table', attribs: {class: 'table table-sm'}})
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Markdown -> sanitised HTML. Frontmatter is dropped; shortcodes are not run.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} source
|
|
122
|
+
* @returns {string}
|
|
123
|
+
*/
|
|
124
|
+
export function renderGuide(source) {
|
|
125
|
+
let body = source;
|
|
126
|
+
try { body = matter(source).content; } catch { /* no frontmatter */ }
|
|
127
|
+
return sanitizeHtml(marked.parse(body, {gfm: true, async: false}), SANITIZE);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Read a plugin's `docs/` folder into page metas. Missing folder = no pages.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} pluginDir
|
|
134
|
+
* @returns {Promise<Array<{slug, title, order, file}>>}
|
|
135
|
+
*/
|
|
136
|
+
export async function readGuidePages(pluginDir) {
|
|
137
|
+
const dir = path.join(pluginDir, 'docs');
|
|
138
|
+
let names;
|
|
139
|
+
try { names = await fs.readdir(dir); } catch { return []; }
|
|
140
|
+
const pages = [];
|
|
141
|
+
for (const name of names) {
|
|
142
|
+
if (!name.toLowerCase().endsWith('.md')) continue;
|
|
143
|
+
const slug = name.slice(0, -3);
|
|
144
|
+
if (!SLUG_RE.test(slug)) continue;
|
|
145
|
+
const file = path.join(dir, name);
|
|
146
|
+
try {
|
|
147
|
+
const source = await fs.readFile(file, 'utf8');
|
|
148
|
+
pages.push({...pageMeta(slug, source), file});
|
|
149
|
+
} catch { /* unreadable - skip */ }
|
|
150
|
+
}
|
|
151
|
+
return sortPages(pages);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The sidebar node for Documentation > Plugins: a folder of one link per
|
|
156
|
+
* plugin, each gated by that plugin's permission (the renderer drops the ones
|
|
157
|
+
* a user cannot use).
|
|
158
|
+
*
|
|
159
|
+
* @param {Array<object>} guides - registry values
|
|
160
|
+
* @returns {object|null}
|
|
161
|
+
*/
|
|
162
|
+
export function guidesSidebarItem(guides) {
|
|
163
|
+
if (!guides.length) return null;
|
|
164
|
+
return {
|
|
165
|
+
source: 'Built in',
|
|
166
|
+
text: 'Plugins',
|
|
167
|
+
url: GUIDES_URL,
|
|
168
|
+
icon: 'package',
|
|
169
|
+
items: [...guides]
|
|
170
|
+
.sort((a, b) => a.displayName.localeCompare(b.displayName))
|
|
171
|
+
.map(g => ({
|
|
172
|
+
text: g.displayName,
|
|
173
|
+
url: `${GUIDES_URL}/${g.name}`,
|
|
174
|
+
icon: g.icon || 'package',
|
|
175
|
+
...(g.permission && {permission: g.permission})
|
|
176
|
+
}))
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Collect the guides of the plugins that loaded and add the sidebar folder.
|
|
182
|
+
* Called once at boot, after registerPlugins() has loaded them.
|
|
183
|
+
*
|
|
184
|
+
* @param {object[]} manifests - manifests of LOADED plugins
|
|
185
|
+
* @param {string} pluginsDir
|
|
186
|
+
* @param {{register?: Function}} [opts] - sidebar registration (tests pass a stub)
|
|
187
|
+
* @returns {Promise<number>} how many plugins have guides
|
|
188
|
+
*/
|
|
189
|
+
export async function registerPluginGuides(manifests, pluginsDir, {register = registerSidebarItem} = {}) {
|
|
190
|
+
const next = new Map();
|
|
191
|
+
for (const manifest of manifests || []) {
|
|
192
|
+
if (!manifest?.name) continue;
|
|
193
|
+
const dir = path.join(pluginsDir, manifest.name);
|
|
194
|
+
const pages = await readGuidePages(dir);
|
|
195
|
+
if (!pages.length) continue;
|
|
196
|
+
next.set(manifest.name, {
|
|
197
|
+
name: manifest.name,
|
|
198
|
+
displayName: manifest.displayName || manifest.name,
|
|
199
|
+
description: manifest.description || '',
|
|
200
|
+
icon: manifest.icon || 'package',
|
|
201
|
+
version: manifest.version || '',
|
|
202
|
+
tier: manifest.tier || '',
|
|
203
|
+
permission: guidePermission(manifest),
|
|
204
|
+
toolUrl: (manifest.admin?.sidebar || []).find(s => s?.url)?.url || null,
|
|
205
|
+
pages
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
_guides = next;
|
|
209
|
+
const item = guidesSidebarItem([...next.values()]);
|
|
210
|
+
if (item) register({folder: DOCS_FOLDER_KEY, item});
|
|
211
|
+
return next.size;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Public shape of a guide (no file paths). */
|
|
215
|
+
function publicGuide(g) {
|
|
216
|
+
const {pages, ...rest} = g;
|
|
217
|
+
return {...rest, pages: pages.map(({slug, title}) => ({slug, title}))};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Every guide this permission list may read.
|
|
222
|
+
*
|
|
223
|
+
* @param {string[]} permissions
|
|
224
|
+
* @returns {object[]}
|
|
225
|
+
*/
|
|
226
|
+
export function listGuides(permissions) {
|
|
227
|
+
return [...(_guides.values())]
|
|
228
|
+
.filter(g => canUse(permissions, g.permission))
|
|
229
|
+
.sort((a, b) => a.displayName.localeCompare(b.displayName))
|
|
230
|
+
.map(publicGuide);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* One guide with one page rendered, or a reason it cannot be shown.
|
|
235
|
+
*
|
|
236
|
+
* @param {string} name
|
|
237
|
+
* @param {string|undefined} pageSlug - defaults to the first page
|
|
238
|
+
* @param {string[]} permissions
|
|
239
|
+
* @returns {Promise<{status: 200|403|404, guide?: object}>}
|
|
240
|
+
*/
|
|
241
|
+
export async function getGuide(name, pageSlug, permissions) {
|
|
242
|
+
const g = _guides.get(name);
|
|
243
|
+
if (!g) return {status: 404};
|
|
244
|
+
if (!canUse(permissions, g.permission)) return {status: 403};
|
|
245
|
+
const page = pageSlug ? g.pages.find(p => p.slug === pageSlug) : g.pages[0];
|
|
246
|
+
if (!page) return {status: 404};
|
|
247
|
+
let source;
|
|
248
|
+
try { source = await fs.readFile(page.file, 'utf8'); } catch { return {status: 404}; }
|
|
249
|
+
return {status: 200, guide: {...publicGuide(g), page: {slug: page.slug, title: page.title, html: renderGuide(source)}}};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Test seam: replace the registry. */
|
|
253
|
+
export function _setGuidesForTest(list) {
|
|
254
|
+
_guides = new Map((list || []).map(g => [g.name, g]));
|
|
255
|
+
}
|