kempo 4.6.2 → 4.7.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/README.md +5 -0
- package/app-public/account/index.page.html +2 -0
- package/app-public/nav.fragment.html +2 -1
- package/app-templates/emails/email.template.html +14 -0
- package/app-templates/emails/reset-password.page.html +19 -0
- package/app-templates/emails/test.page.html +21 -0
- package/app-templates/emails/verify-email.page.html +18 -0
- package/bin/cli.js +3 -0
- package/dist/admin/components/Dashboard.js +2 -2
- package/dist/admin/components/Dashboard.js.map +1 -1
- package/dist/admin/components/Menus.js +103 -0
- package/dist/admin/components/Menus.js.map +1 -0
- package/dist/admin/content/menus/index.page.html +11 -0
- package/dist/admin/nav.fragment.html +6 -0
- package/dist/kempo/api/fragments/DELETE.js +1 -1
- package/dist/kempo/api/fragments/DELETE.js.map +1 -1
- package/dist/kempo/api/fragments/GET.js +1 -1
- package/dist/kempo/api/fragments/GET.js.map +1 -1
- package/dist/kempo/api/fragments/POST.js +1 -1
- package/dist/kempo/api/fragments/POST.js.map +1 -1
- package/dist/kempo/api/fragments/disable/PUT.js +1 -1
- package/dist/kempo/api/fragments/disable/PUT.js.map +1 -1
- package/dist/kempo/api/fragments/enable/PUT.js +1 -1
- package/dist/kempo/api/fragments/enable/PUT.js.map +1 -1
- package/dist/kempo/api/fragments/file/GET.js +1 -1
- package/dist/kempo/api/fragments/file/GET.js.map +1 -1
- package/dist/kempo/api/fragments/file/PATCH.js +1 -1
- package/dist/kempo/api/fragments/file/PATCH.js.map +1 -1
- package/dist/kempo/api/fragments/file/PUT.js +1 -1
- package/dist/kempo/api/fragments/file/PUT.js.map +1 -1
- package/dist/kempo/api/fragments/lock/PUT.js +1 -1
- package/dist/kempo/api/fragments/lock/PUT.js.map +1 -1
- package/dist/kempo/api/globals/DELETE.js +1 -1
- package/dist/kempo/api/globals/DELETE.js.map +1 -1
- package/dist/kempo/api/globals/GET.js +1 -1
- package/dist/kempo/api/globals/GET.js.map +1 -1
- package/dist/kempo/api/globals/POST.js +1 -1
- package/dist/kempo/api/globals/POST.js.map +1 -1
- package/dist/kempo/api/globals/disable/PUT.js +1 -1
- package/dist/kempo/api/globals/disable/PUT.js.map +1 -1
- package/dist/kempo/api/globals/enable/PUT.js +1 -1
- package/dist/kempo/api/globals/enable/PUT.js.map +1 -1
- package/dist/kempo/api/globals/entry/GET.js +1 -1
- package/dist/kempo/api/globals/entry/GET.js.map +1 -1
- package/dist/kempo/api/globals/entry/PUT.js +1 -1
- package/dist/kempo/api/globals/entry/PUT.js.map +1 -1
- package/dist/kempo/api/globals/lock/PUT.js +1 -1
- package/dist/kempo/api/globals/lock/PUT.js.map +1 -1
- package/dist/kempo/api/menus/DELETE.js +2 -0
- package/dist/kempo/api/menus/DELETE.js.map +1 -0
- package/dist/kempo/api/menus/GET.js +2 -0
- package/dist/kempo/api/menus/GET.js.map +1 -0
- package/dist/kempo/api/menus/POST.js +2 -0
- package/dist/kempo/api/menus/POST.js.map +1 -0
- package/dist/kempo/api/menus/adopt/PUT.js +2 -0
- package/dist/kempo/api/menus/adopt/PUT.js.map +1 -0
- package/dist/kempo/api/menus/items/DELETE.js +2 -0
- package/dist/kempo/api/menus/items/DELETE.js.map +1 -0
- package/dist/kempo/api/menus/items/POST.js +2 -0
- package/dist/kempo/api/menus/items/POST.js.map +1 -0
- package/dist/kempo/api/menus/items/enabled/PUT.js +2 -0
- package/dist/kempo/api/menus/items/enabled/PUT.js.map +1 -0
- package/dist/kempo/api/menus/items/order/PUT.js +2 -0
- package/dist/kempo/api/menus/items/order/PUT.js.map +1 -0
- package/dist/kempo/api/menus/location/PUT.js +2 -0
- package/dist/kempo/api/menus/location/PUT.js.map +1 -0
- package/dist/kempo/api/pages/DELETE.js +1 -1
- package/dist/kempo/api/pages/DELETE.js.map +1 -1
- package/dist/kempo/api/pages/GET.js +1 -1
- package/dist/kempo/api/pages/GET.js.map +1 -1
- package/dist/kempo/api/pages/POST.js +1 -1
- package/dist/kempo/api/pages/POST.js.map +1 -1
- package/dist/kempo/api/pages/directories/GET.js +1 -1
- package/dist/kempo/api/pages/directories/GET.js.map +1 -1
- package/dist/kempo/api/pages/disable/PUT.js +1 -1
- package/dist/kempo/api/pages/disable/PUT.js.map +1 -1
- package/dist/kempo/api/pages/enable/PUT.js +1 -1
- package/dist/kempo/api/pages/enable/PUT.js.map +1 -1
- package/dist/kempo/api/pages/file/GET.js +1 -1
- package/dist/kempo/api/pages/file/GET.js.map +1 -1
- package/dist/kempo/api/pages/file/PATCH.js +1 -1
- package/dist/kempo/api/pages/file/PATCH.js.map +1 -1
- package/dist/kempo/api/pages/file/PUT.js +1 -1
- package/dist/kempo/api/pages/file/PUT.js.map +1 -1
- package/dist/kempo/api/pages/lock/PUT.js +1 -1
- package/dist/kempo/api/pages/lock/PUT.js.map +1 -1
- package/dist/kempo/api/pages/rescan/POST.js +1 -1
- package/dist/kempo/api/pages/rescan/POST.js.map +1 -1
- package/dist/kempo/api/pages/search/GET.js +1 -1
- package/dist/kempo/api/pages/search/GET.js.map +1 -1
- package/dist/kempo/api/pages/templates/GET.js +1 -1
- package/dist/kempo/api/pages/templates/GET.js.map +1 -1
- package/dist/kempo/api/templates/DELETE.js +1 -1
- package/dist/kempo/api/templates/DELETE.js.map +1 -1
- package/dist/kempo/api/templates/GET.js +1 -1
- package/dist/kempo/api/templates/GET.js.map +1 -1
- package/dist/kempo/api/templates/POST.js +1 -1
- package/dist/kempo/api/templates/POST.js.map +1 -1
- package/dist/kempo/api/templates/disable/PUT.js +1 -1
- package/dist/kempo/api/templates/disable/PUT.js.map +1 -1
- package/dist/kempo/api/templates/enable/PUT.js +1 -1
- package/dist/kempo/api/templates/enable/PUT.js.map +1 -1
- package/dist/kempo/api/templates/file/GET.js +1 -1
- package/dist/kempo/api/templates/file/GET.js.map +1 -1
- package/dist/kempo/api/templates/file/PATCH.js +1 -1
- package/dist/kempo/api/templates/file/PATCH.js.map +1 -1
- package/dist/kempo/api/templates/file/PUT.js +1 -1
- package/dist/kempo/api/templates/file/PUT.js.map +1 -1
- package/dist/kempo/api/templates/lock/PUT.js +1 -1
- package/dist/kempo/api/templates/lock/PUT.js.map +1 -1
- package/dist/kempo/components/DashboardWidget.js +4 -4
- package/dist/kempo/components/DashboardWidget.js.map +1 -1
- package/dist/kempo/icons/campaign.svg +1 -0
- package/dist/kempo/icons/fields.svg +1 -0
- package/dist/kempo/icons/open_in_new.svg +1 -0
- package/dist/kempo/sdk.js +1 -1
- package/dist/kempo/sdk.js.map +1 -1
- package/middleware/kempo.js +48 -12
- package/package.json +2 -2
- package/server/db/schema.js +24 -0
- package/server/sdk.js +21 -0
- package/server/utils/auth/requestPasswordReset.js +9 -3
- package/server/utils/auth/verifyEmail.js +10 -1
- package/server/utils/dashboard/coreWidgets.js +1 -0
- package/server/utils/dashboard/helpers.js +6 -0
- package/server/utils/email/renderEmailTemplate.js +63 -0
- package/server/utils/email/sendEmailFromTemplate.js +9 -27
- package/server/utils/extensions/aliases.js +71 -0
- package/server/utils/extensions/checkClaims.js +32 -0
- package/server/utils/extensions/claims.js +54 -0
- package/server/utils/extensions/createTablesFromSchema.js +13 -20
- package/server/utils/extensions/enableExtensionTree.js +8 -0
- package/server/utils/extensions/installExtension.js +32 -0
- package/server/utils/extensions/readKempoConfig.js +2 -2
- package/server/utils/extensions/scopeCache.js +14 -1
- package/server/utils/extensions/scopes.js +72 -0
- package/server/utils/extensions/uninstallExtensionTree.js +2 -1
- package/server/utils/extensions/updateExtension.js +35 -0
- package/server/utils/global-content/createGlobalContent.js +8 -18
- package/server/utils/global-content/deleteGlobalContent.js +1 -16
- package/server/utils/global-content/disableGlobalContent.js +1 -16
- package/server/utils/global-content/enableGlobalContent.js +1 -16
- package/server/utils/global-content/helpers.js +40 -6
- package/server/utils/global-content/listGlobalContent.js +2 -2
- package/server/utils/global-content/setGlobalContentLocked.js +1 -16
- package/server/utils/global-content/updateGlobalContent.js +1 -16
- package/server/utils/menus/adoptMenuItem.js +58 -0
- package/server/utils/menus/assignMenuLocation.js +47 -0
- package/server/utils/menus/deleteMenu.js +27 -0
- package/server/utils/menus/deleteMenuItems.js +27 -0
- package/server/utils/menus/findMenuLocations.js +41 -0
- package/server/utils/menus/findNavItems.js +74 -0
- package/server/utils/menus/findPages.js +73 -0
- package/server/utils/menus/listMenus.js +103 -0
- package/server/utils/menus/markup.js +55 -0
- package/server/utils/menus/reorderMenuItems.js +36 -0
- package/server/utils/menus/saveMenu.js +54 -0
- package/server/utils/menus/saveMenuItem.js +122 -0
- package/server/utils/menus/setMenuItemEnabled.js +31 -0
- package/server/utils/menus/store.js +90 -0
- package/server/utils/scheduler/helpers.js +36 -0
- package/server/utils/scheduler/listJobs.js +11 -0
- package/server/utils/scheduler/registerJobs.js +37 -0
- package/server/utils/scheduler/runJobs.js +90 -0
- package/server/utils/scheduler/scheduler.js +37 -0
- package/server/utils/scheduler/unregisterJobs.js +16 -0
- package/server/utils/settings/ensureEncryptionKey.js +132 -0
- package/server/utils/settings/secretCrypto.js +5 -1
- package/dist/kempo/icons/create_new_folder.svg +0 -1
- package/dist/kempo/icons/credit_card.svg +0 -1
- package/dist/kempo/icons/login.svg +0 -1
- package/dist/kempo/icons/payments.svg +0 -1
- package/dist/kempo/icons/toll.svg +0 -1
- package/dist/kempo/icons/wallet.svg +0 -1
|
@@ -1,24 +1,9 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'fs/promises';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
|
-
import { GLOBAL_FILE, parseEntries } from './helpers.js';
|
|
4
|
+
import { GLOBAL_FILE, parseEntries, serializeEntries } from './helpers.js';
|
|
5
5
|
import triggerHook from '../hooks/triggerHook.js';
|
|
6
6
|
|
|
7
|
-
const serializeEntries = entries => entries.map(e => {
|
|
8
|
-
const attrs = [
|
|
9
|
-
`id="${e.id}"`,
|
|
10
|
-
`name="${e.name}"`,
|
|
11
|
-
`owner="${e.owner}"`,
|
|
12
|
-
`author="${e.author}"`,
|
|
13
|
-
`created="${e.createdAt}"`,
|
|
14
|
-
`updated="${e.updatedAt}"`,
|
|
15
|
-
`locked="${e.locked}"`,
|
|
16
|
-
`location="${e.location}"`,
|
|
17
|
-
`priority="${e.priority}"`
|
|
18
|
-
].join(' ');
|
|
19
|
-
return `<content ${attrs}>\n${e.markup}\n</content>`;
|
|
20
|
-
}).join('\n');
|
|
21
|
-
|
|
22
7
|
export default async ({ rootDir, ids }) => {
|
|
23
8
|
if(!rootDir){
|
|
24
9
|
return [{ code: 400, msg: 'Root directory is required' }, null];
|
|
@@ -1,22 +1,7 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'fs/promises';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
|
-
import { GLOBAL_FILE, GLOBAL_DISABLED_FILE, parseEntries } from './helpers.js';
|
|
5
|
-
|
|
6
|
-
const serializeEntries = entries => entries.map(e => {
|
|
7
|
-
const attrs = [
|
|
8
|
-
`id="${e.id}"`,
|
|
9
|
-
`name="${e.name}"`,
|
|
10
|
-
`owner="${e.owner}"`,
|
|
11
|
-
`author="${e.author}"`,
|
|
12
|
-
`created="${e.createdAt}"`,
|
|
13
|
-
`updated="${e.updatedAt}"`,
|
|
14
|
-
`locked="${e.locked}"`,
|
|
15
|
-
`location="${e.location}"`,
|
|
16
|
-
`priority="${e.priority}"`
|
|
17
|
-
].join(' ');
|
|
18
|
-
return `<content ${attrs}>\n${e.markup}\n</content>`;
|
|
19
|
-
}).join('\n');
|
|
4
|
+
import { GLOBAL_FILE, GLOBAL_DISABLED_FILE, parseEntries, serializeEntries } from './helpers.js';
|
|
20
5
|
|
|
21
6
|
export default async ({ rootDir, id }) => {
|
|
22
7
|
if(!rootDir){
|
|
@@ -1,22 +1,7 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'fs/promises';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
|
-
import { GLOBAL_FILE, GLOBAL_DISABLED_FILE, parseEntries } from './helpers.js';
|
|
5
|
-
|
|
6
|
-
const serializeEntries = entries => entries.map(e => {
|
|
7
|
-
const attrs = [
|
|
8
|
-
`id="${e.id}"`,
|
|
9
|
-
`name="${e.name}"`,
|
|
10
|
-
`owner="${e.owner}"`,
|
|
11
|
-
`author="${e.author}"`,
|
|
12
|
-
`created="${e.createdAt}"`,
|
|
13
|
-
`updated="${e.updatedAt}"`,
|
|
14
|
-
`locked="${e.locked}"`,
|
|
15
|
-
`location="${e.location}"`,
|
|
16
|
-
`priority="${e.priority}"`
|
|
17
|
-
].join(' ');
|
|
18
|
-
return `<content ${attrs}>\n${e.markup}\n</content>`;
|
|
19
|
-
}).join('\n');
|
|
4
|
+
import { GLOBAL_FILE, GLOBAL_DISABLED_FILE, parseEntries, serializeEntries } from './helpers.js';
|
|
20
5
|
|
|
21
6
|
export default async ({ rootDir, id }) => {
|
|
22
7
|
if(!rootDir){
|
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
import { readFile } from 'fs/promises';
|
|
2
|
-
import { join } from 'path';
|
|
3
|
-
import { existsSync } from 'fs';
|
|
4
|
-
|
|
5
1
|
const GLOBAL_FILE = 'kempo-global.global.html';
|
|
6
2
|
const GLOBAL_DISABLED_FILE = 'kempo-global.global-disabled.html';
|
|
7
3
|
|
|
4
|
+
/*
|
|
5
|
+
A quote or angle bracket inside an attribute would end the tag early, so they are written as
|
|
6
|
+
entities and read back.
|
|
7
|
+
*/
|
|
8
|
+
const escapeAttr = value => String(value ?? '').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
9
|
+
const unescapeAttr = value => value.replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
10
|
+
|
|
11
|
+
const KNOWN_ATTRIBUTES = ['id', 'name', 'owner', 'author', 'locked', 'location', 'priority', 'created', 'updated', 'menu', 'menu-locations', 'menu-copy', 'menu-nav-item'];
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
Attributes this code does not know about are kept in `extra` and written back unchanged, so a
|
|
15
|
+
marker added by hand or by a newer version survives a save instead of being silently dropped.
|
|
16
|
+
*/
|
|
8
17
|
const parseEntries = raw => {
|
|
9
18
|
const entries = [];
|
|
10
19
|
const regex = /<content\s([^>]*)>([\s\S]*?)<\/content>/g;
|
|
@@ -13,21 +22,46 @@ const parseEntries = raw => {
|
|
|
13
22
|
const attrs = {};
|
|
14
23
|
const attrRegex = /(\w[\w-]*)="([^"]*)"/g;
|
|
15
24
|
let a;
|
|
16
|
-
while((a = attrRegex.exec(match[1])) !== null) attrs[a[1]] = a[2];
|
|
25
|
+
while((a = attrRegex.exec(match[1])) !== null) attrs[a[1]] = unescapeAttr(a[2]);
|
|
17
26
|
entries.push({
|
|
18
27
|
id: attrs.id || '',
|
|
19
28
|
name: attrs.name || '',
|
|
20
29
|
owner: attrs.owner || 'custom',
|
|
21
30
|
author: attrs.author || '',
|
|
22
31
|
locked: attrs.locked === 'true',
|
|
32
|
+
menu: attrs.menu || '',
|
|
33
|
+
menuLocations: attrs['menu-locations'] === undefined ? null : attrs['menu-locations'].split(',').map(l => l.trim()).filter(Boolean),
|
|
34
|
+
menuCopyOf: attrs['menu-copy'] || '',
|
|
35
|
+
navItem: attrs['menu-nav-item'] || '',
|
|
23
36
|
location: attrs.location || 'default',
|
|
24
37
|
priority: parseInt(attrs.priority || '0', 10),
|
|
25
38
|
createdAt: attrs.created || '',
|
|
26
39
|
updatedAt: attrs.updated || '',
|
|
40
|
+
extra: Object.fromEntries(Object.entries(attrs).filter(([key]) => !KNOWN_ATTRIBUTES.includes(key))),
|
|
27
41
|
markup: match[2].trim()
|
|
28
42
|
});
|
|
29
43
|
}
|
|
30
44
|
return entries;
|
|
31
45
|
};
|
|
32
46
|
|
|
33
|
-
|
|
47
|
+
const serializeEntries = entries => entries.map(e => {
|
|
48
|
+
const attrs = [
|
|
49
|
+
`id="${e.id}"`,
|
|
50
|
+
`name="${escapeAttr(e.name)}"`,
|
|
51
|
+
`owner="${e.owner}"`,
|
|
52
|
+
`author="${escapeAttr(e.author)}"`,
|
|
53
|
+
`created="${e.createdAt}"`,
|
|
54
|
+
`updated="${e.updatedAt}"`,
|
|
55
|
+
`locked="${e.locked}"`,
|
|
56
|
+
`location="${e.location}"`,
|
|
57
|
+
`priority="${e.priority}"`,
|
|
58
|
+
...(e.menu ? [`menu="${e.menu}"`] : []),
|
|
59
|
+
...(e.menuLocations ? [`menu-locations="${e.menuLocations.join(',')}"`] : []),
|
|
60
|
+
...(e.menuCopyOf ? [`menu-copy="${e.menuCopyOf}"`] : []),
|
|
61
|
+
...(e.navItem ? [`menu-nav-item="${e.navItem}"`] : []),
|
|
62
|
+
...Object.entries(e.extra || {}).map(([key, value]) => `${key}="${escapeAttr(value)}"`)
|
|
63
|
+
].join(' ');
|
|
64
|
+
return `<content ${attrs}>\n${e.markup}\n</content>`;
|
|
65
|
+
}).join('\n');
|
|
66
|
+
|
|
67
|
+
export { GLOBAL_FILE, GLOBAL_DISABLED_FILE, parseEntries, serializeEntries };
|
|
@@ -3,7 +3,7 @@ import { join } from 'path';
|
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
4
|
import { GLOBAL_FILE, GLOBAL_DISABLED_FILE, parseEntries } from './helpers.js';
|
|
5
5
|
|
|
6
|
-
export default async ({ rootDir }) => {
|
|
6
|
+
export default async ({ rootDir, includeMenu = true }) => {
|
|
7
7
|
if(!rootDir){
|
|
8
8
|
return [{ code: 400, msg: 'Root directory is required' }, null];
|
|
9
9
|
}
|
|
@@ -19,6 +19,6 @@ export default async ({ rootDir }) => {
|
|
|
19
19
|
? parseEntries(await readFile(disabledPath, 'utf-8')).map(e => ({ ...e, disabled: true }))
|
|
20
20
|
: [];
|
|
21
21
|
|
|
22
|
-
const entries = [...activeEntries, ...disabledEntries];
|
|
22
|
+
const entries = [...activeEntries, ...disabledEntries].filter(e => includeMenu || !e.menu);
|
|
23
23
|
return [null, { entries, total: entries.length }];
|
|
24
24
|
};
|
|
@@ -1,22 +1,7 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'fs/promises';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
|
-
import { GLOBAL_FILE, parseEntries } from './helpers.js';
|
|
5
|
-
|
|
6
|
-
const serializeEntries = entries => entries.map(e => {
|
|
7
|
-
const attrs = [
|
|
8
|
-
`id="${e.id}"`,
|
|
9
|
-
`name="${e.name}"`,
|
|
10
|
-
`owner="${e.owner}"`,
|
|
11
|
-
`author="${e.author}"`,
|
|
12
|
-
`created="${e.createdAt}"`,
|
|
13
|
-
`updated="${e.updatedAt}"`,
|
|
14
|
-
`locked="${e.locked}"`,
|
|
15
|
-
`location="${e.location}"`,
|
|
16
|
-
`priority="${e.priority}"`
|
|
17
|
-
].join(' ');
|
|
18
|
-
return `<content ${attrs}>\n${e.markup}\n</content>`;
|
|
19
|
-
}).join('\n');
|
|
4
|
+
import { GLOBAL_FILE, parseEntries, serializeEntries } from './helpers.js';
|
|
20
5
|
|
|
21
6
|
export default async ({ rootDir, id, locked }) => {
|
|
22
7
|
if(!rootDir) return [{ code: 400, msg: 'Root directory is required' }, null];
|
|
@@ -1,24 +1,9 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'fs/promises';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
|
-
import { GLOBAL_FILE, parseEntries } from './helpers.js';
|
|
4
|
+
import { GLOBAL_FILE, parseEntries, serializeEntries } from './helpers.js';
|
|
5
5
|
import triggerHook from '../hooks/triggerHook.js';
|
|
6
6
|
|
|
7
|
-
const serializeEntries = entries => entries.map(e => {
|
|
8
|
-
const attrs = [
|
|
9
|
-
`id="${e.id}"`,
|
|
10
|
-
`name="${e.name}"`,
|
|
11
|
-
`owner="${e.owner}"`,
|
|
12
|
-
`author="${e.author}"`,
|
|
13
|
-
`created="${e.createdAt}"`,
|
|
14
|
-
`updated="${e.updatedAt}"`,
|
|
15
|
-
`locked="${e.locked}"`,
|
|
16
|
-
`location="${e.location}"`,
|
|
17
|
-
`priority="${e.priority}"`
|
|
18
|
-
].join(' ');
|
|
19
|
-
return `<content ${attrs}>\n${e.markup}\n</content>`;
|
|
20
|
-
}).join('\n');
|
|
21
|
-
|
|
22
7
|
export default async ({ rootDir, id, name, location, priority, markup }) => {
|
|
23
8
|
if(!rootDir){
|
|
24
9
|
return [{ code: 400, msg: 'Root directory is required' }, null];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readStores, writeStores, menuHome, isMenuDefinition } from './store.js';
|
|
2
|
+
import { parseLink } from './markup.js';
|
|
3
|
+
import saveMenu from './saveMenu.js';
|
|
4
|
+
import triggerHook from '../hooks/triggerHook.js';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
Moves an existing single link into a menu so it is managed on the Menus page. Only a single link
|
|
8
|
+
qualifies: anything else would lose what it is made of when the page rewrites it.
|
|
9
|
+
|
|
10
|
+
Without a `menuId` it goes into the menu already shown in the link's location; if there is none, a
|
|
11
|
+
menu named `menuName` is made and shown there, so the link stays exactly where it was on the site.
|
|
12
|
+
*/
|
|
13
|
+
export default async ({ rootDir, id, menuId, menuName, extensionDirs }) => {
|
|
14
|
+
if(!rootDir){
|
|
15
|
+
return [{ code: 400, msg: 'Root directory is required' }, null];
|
|
16
|
+
}
|
|
17
|
+
if(!id){
|
|
18
|
+
return [{ code: 400, msg: 'Global content ID is required' }, null];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let stores = await readStores(rootDir);
|
|
22
|
+
const entry = stores.active.find(e => e.id === id && !e.menu);
|
|
23
|
+
if(!entry){
|
|
24
|
+
return [{ code: 404, msg: 'Global content not found' }, null];
|
|
25
|
+
}
|
|
26
|
+
if(entry.owner !== 'custom'){
|
|
27
|
+
return [{ code: 403, msg: 'This belongs to an extension' }, null];
|
|
28
|
+
}
|
|
29
|
+
if(!parseLink(entry.markup)){
|
|
30
|
+
return [{ code: 400, msg: 'Only a single link can be managed as a menu item' }, null];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let target = menuId
|
|
34
|
+
? stores.active.find(e => e.id === menuId && isMenuDefinition(e))
|
|
35
|
+
: stores.active.find(e => isMenuDefinition(e) && e.menuLocations.includes(entry.location));
|
|
36
|
+
if(menuId && !target){
|
|
37
|
+
return [{ code: 404, msg: 'Menu not found' }, null];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if(!target){
|
|
41
|
+
const [error, created] = await saveMenu({ rootDir, name: menuName || entry.location, extensionDirs });
|
|
42
|
+
if(error){
|
|
43
|
+
return [error, null];
|
|
44
|
+
}
|
|
45
|
+
stores = await readStores(rootDir);
|
|
46
|
+
target = stores.active.find(e => e.id === created.id);
|
|
47
|
+
target.menuLocations = [entry.location];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const live = stores.active.find(e => e.id === id);
|
|
51
|
+
live.menu = target.id;
|
|
52
|
+
live.location = menuHome(target.id);
|
|
53
|
+
live.updatedAt = new Date().toISOString();
|
|
54
|
+
await writeStores(rootDir, stores, extensionDirs);
|
|
55
|
+
await triggerHook('globalContent:updated', { id, updatedAt: live.updatedAt });
|
|
56
|
+
|
|
57
|
+
return [null, { id, menuId: target.id }];
|
|
58
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readStores, writeStores, isMenuDefinition } from './store.js';
|
|
2
|
+
import findMenuLocations from './findMenuLocations.js';
|
|
3
|
+
import triggerHook from '../hooks/triggerHook.js';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Chooses the menu shown in a location, or none when `menuId` is empty. A location shows one menu, so
|
|
7
|
+
choosing one takes the location from whichever menu had it; a menu can be chosen for several.
|
|
8
|
+
Only a place the theme marks as a menu can be newly assigned, so a typo cannot silently show nothing.
|
|
9
|
+
*/
|
|
10
|
+
export default async ({ rootDir, location, menuId, extensionDirs }) => {
|
|
11
|
+
if(!rootDir){
|
|
12
|
+
return [{ code: 400, msg: 'Root directory is required' }, null];
|
|
13
|
+
}
|
|
14
|
+
if(!location){
|
|
15
|
+
return [{ code: 400, msg: 'Location is required' }, null];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const stores = await readStores(rootDir);
|
|
19
|
+
const definitions = stores.active.filter(isMenuDefinition);
|
|
20
|
+
const target = menuId ? definitions.find(d => d.id === menuId) : null;
|
|
21
|
+
if(menuId && !target){
|
|
22
|
+
return [{ code: 404, msg: 'Menu not found' }, null];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if(target){
|
|
26
|
+
const [, declared] = await findMenuLocations({ rootDir });
|
|
27
|
+
if(!target.menuLocations.includes(location) && !declared.some(l => l.location === location)){
|
|
28
|
+
return [{ code: 400, msg: 'That place is not a menu in the theme' }, null];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const now = new Date().toISOString();
|
|
33
|
+
for(const definition of definitions){
|
|
34
|
+
if(!definition.menuLocations.includes(location)) continue;
|
|
35
|
+
definition.menuLocations = definition.menuLocations.filter(l => l !== location);
|
|
36
|
+
definition.updatedAt = now;
|
|
37
|
+
}
|
|
38
|
+
if(target){
|
|
39
|
+
target.menuLocations = [...target.menuLocations, location];
|
|
40
|
+
target.updatedAt = now;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await writeStores(rootDir, stores, extensionDirs);
|
|
44
|
+
await triggerHook('globalContent:updated', { id: target?.id ?? '', updatedAt: now });
|
|
45
|
+
|
|
46
|
+
return [null, { location, menuId: target?.id ?? null }];
|
|
47
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { readStores, writeStores } from './store.js';
|
|
2
|
+
import triggerHook from '../hooks/triggerHook.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Deletes a menu and all of its items. Where it was shown is left without a menu.
|
|
6
|
+
*/
|
|
7
|
+
export default async ({ rootDir, id, extensionDirs }) => {
|
|
8
|
+
if(!rootDir){
|
|
9
|
+
return [{ code: 400, msg: 'Root directory is required' }, null];
|
|
10
|
+
}
|
|
11
|
+
if(!id){
|
|
12
|
+
return [{ code: 400, msg: 'Menu ID is required' }, null];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const stores = await readStores(rootDir);
|
|
16
|
+
if(!stores.active.some(e => e.id === id && e.menuLocations !== null)){
|
|
17
|
+
return [{ code: 404, msg: 'Menu not found' }, null];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const removed = [...stores.active, ...stores.disabled].filter(e => e.menu === id).map(e => e.id);
|
|
21
|
+
stores.active = stores.active.filter(e => e.menu !== id);
|
|
22
|
+
stores.disabled = stores.disabled.filter(e => e.menu !== id);
|
|
23
|
+
await writeStores(rootDir, stores, extensionDirs);
|
|
24
|
+
for(const removedId of removed) await triggerHook('globalContent:deleted', { id: removedId });
|
|
25
|
+
|
|
26
|
+
return [null, { deleted: removed.length - 1 }];
|
|
27
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { readStores, writeStores, isMenuItem } from './store.js';
|
|
2
|
+
import triggerHook from '../hooks/triggerHook.js';
|
|
3
|
+
|
|
4
|
+
export default async ({ rootDir, ids, extensionDirs }) => {
|
|
5
|
+
if(!rootDir){
|
|
6
|
+
return [{ code: 400, msg: 'Root directory is required' }, null];
|
|
7
|
+
}
|
|
8
|
+
if(!Array.isArray(ids) || !ids.length){
|
|
9
|
+
return [{ code: 400, msg: 'No IDs specified' }, null];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const stores = await readStores(rootDir);
|
|
13
|
+
const found = [...stores.active, ...stores.disabled].filter(e => ids.includes(e.id) && isMenuItem(e));
|
|
14
|
+
if(found.length !== new Set(ids).size){
|
|
15
|
+
return [{ code: 404, msg: 'Menu item not found' }, null];
|
|
16
|
+
}
|
|
17
|
+
if(found.some(e => e.locked)){
|
|
18
|
+
return [{ code: 403, msg: 'A locked item cannot be deleted' }, null];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
stores.active = stores.active.filter(e => !ids.includes(e.id));
|
|
22
|
+
stores.disabled = stores.disabled.filter(e => !ids.includes(e.id));
|
|
23
|
+
await writeStores(rootDir, stores, extensionDirs);
|
|
24
|
+
for(const id of ids) await triggerHook('globalContent:deleted', { id });
|
|
25
|
+
|
|
26
|
+
return [null, { deleted: ids.length }];
|
|
27
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises';
|
|
2
|
+
import scanDir from '../fs/scanDir.js';
|
|
3
|
+
|
|
4
|
+
const LOCATION_TAG = /<location((?:[^>"']|"[^"]*"|'[^']*')*?)(?:\s*\/>|>[\s\S]*?<\/location>)/g;
|
|
5
|
+
const SOURCE_FILE = /\.(template|fragment|page)\.html$/;
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
A location is a menu when the template, fragment or page that holds it says so:
|
|
9
|
+
`<location name="main-nav-links" label="Main menu" menu="true" />`. Looking in the site's own files
|
|
10
|
+
means the Menus page lists exactly the slots this site's theme has, and a theme with no menu slot
|
|
11
|
+
simply has no menus.
|
|
12
|
+
*/
|
|
13
|
+
export default async ({ rootDir }) => {
|
|
14
|
+
if(!rootDir){
|
|
15
|
+
return [{ code: 400, msg: 'Root directory is required' }, null];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const found = await scanDir(rootDir, rootDir, async (fullPath, entry) => {
|
|
19
|
+
if(entry.isDirectory() || !SOURCE_FILE.test(entry.name)) return null;
|
|
20
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
21
|
+
const locations = [];
|
|
22
|
+
let match;
|
|
23
|
+
LOCATION_TAG.lastIndex = 0;
|
|
24
|
+
while((match = LOCATION_TAG.exec(content)) !== null){
|
|
25
|
+
const attr = name => {
|
|
26
|
+
const value = new RegExp(`(?:^|\\s)${name}=(?:"([^"]*)"|'([^']*)')`).exec(match[1]);
|
|
27
|
+
return value ? (value[1] ?? value[2]) : undefined;
|
|
28
|
+
};
|
|
29
|
+
if(attr('menu') !== 'true') continue;
|
|
30
|
+
locations.push({ location: attr('name') ?? 'default', label: attr('label') ?? '' });
|
|
31
|
+
}
|
|
32
|
+
return locations;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const byName = new Map();
|
|
36
|
+
for(const { location, label } of found){
|
|
37
|
+
if(!byName.has(location) || (!byName.get(location).label && label)) byName.set(location, { location, label: label || location });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return [null, [...byName.values()]];
|
|
41
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises';
|
|
2
|
+
import { basename, dirname } from 'path';
|
|
3
|
+
import scanDir from '../fs/scanDir.js';
|
|
4
|
+
|
|
5
|
+
const WRAPPER = /^\s*(?:<!--[\s\S]*?-->\s*)*<menu-item((?:[^>"']|"[^"]*"|'[^']*')*)>([\s\S]*)<\/menu-item>\s*$/;
|
|
6
|
+
const ATTRIBUTE = /(\w[\w-]*)=(?:"([^"]*)"|'([^']*)')/g;
|
|
7
|
+
const ID = /^[\w-]+$/;
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
What kempo itself offers, so it is there on every site and needs no extension. The spacer takes up
|
|
11
|
+
the space left in a menu that is laid out in a row, which pushes the items after it to the far side.
|
|
12
|
+
*/
|
|
13
|
+
const BUILT_IN = [{
|
|
14
|
+
key: 'kempo:spacer',
|
|
15
|
+
extension: 'kempo',
|
|
16
|
+
id: 'spacer',
|
|
17
|
+
label: 'Spacer',
|
|
18
|
+
description: 'A flexible gap that pushes the items after it to the far side of the menu, such as the account link to the right. For a menu laid out in a row.',
|
|
19
|
+
icon: '',
|
|
20
|
+
markup: '<div class="flex"></div>'
|
|
21
|
+
}];
|
|
22
|
+
|
|
23
|
+
/*
|
|
24
|
+
The items enabled extensions offer for a site's menus. An extension ships a `*.menu-item.html` file
|
|
25
|
+
anywhere in its `public/` folder:
|
|
26
|
+
|
|
27
|
+
<menu-item id="cart" label="Cart" description="The shopping cart." icon-src="/commerce/icons/shopping_cart.svg">
|
|
28
|
+
<k-commerce-cart-button></k-commerce-cart-button>
|
|
29
|
+
</menu-item>
|
|
30
|
+
|
|
31
|
+
`icon` is the name of an icon in the shared sets; `icon-src` is the path to an icon file the extension ships
|
|
32
|
+
(`/commerce/icons/shopping_cart.svg`), and wins when both are given.
|
|
33
|
+
|
|
34
|
+
Kempo's own items (a spacer) come first, always.
|
|
35
|
+
|
|
36
|
+
Its content is what is rendered where an admin puts the item. It may include a <script> for the
|
|
37
|
+
element it uses. An item is named `<extension>:<id>`, which is what a menu stores. A file with no
|
|
38
|
+
<menu-item> wrapper, or without an id and a label, is skipped rather than breaking the others.
|
|
39
|
+
|
|
40
|
+
`extensionDirs` overrides where extensions are read from, as for the other menu functions; each is
|
|
41
|
+
an extension's public folder, so its package name is the folder above it.
|
|
42
|
+
*/
|
|
43
|
+
export default async ({ extensionDirs } = {}) => {
|
|
44
|
+
const dirs = extensionDirs ?? await (await import('../extensions/contentDirs.js')).extensionPublicDirs();
|
|
45
|
+
const items = BUILT_IN.map(item => ({ ...item }));
|
|
46
|
+
|
|
47
|
+
for(const dir of dirs){
|
|
48
|
+
const extension = basename(dirname(dir));
|
|
49
|
+
const files = await scanDir(dir, dir, async (fullPath, entry) =>
|
|
50
|
+
!entry.isDirectory() && entry.name.endsWith('.menu-item.html') ? fullPath : null);
|
|
51
|
+
|
|
52
|
+
for(const file of files.sort()){
|
|
53
|
+
const match = WRAPPER.exec(await readFile(file, 'utf-8'));
|
|
54
|
+
if(!match) continue;
|
|
55
|
+
const attrs = {};
|
|
56
|
+
let a;
|
|
57
|
+
ATTRIBUTE.lastIndex = 0;
|
|
58
|
+
while((a = ATTRIBUTE.exec(match[1])) !== null) attrs[a[1]] = a[2] ?? a[3];
|
|
59
|
+
if(!ID.test(attrs.id || '') || !String(attrs.label || '').trim()) continue;
|
|
60
|
+
items.push({
|
|
61
|
+
key: `${extension}:${attrs.id}`,
|
|
62
|
+
extension,
|
|
63
|
+
id: attrs.id,
|
|
64
|
+
label: attrs.label.trim(),
|
|
65
|
+
description: attrs.description || '',
|
|
66
|
+
icon: attrs.icon || '',
|
|
67
|
+
iconSrc: attrs['icon-src'] || '',
|
|
68
|
+
markup: match[2].trim()
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return [null, items];
|
|
74
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises';
|
|
2
|
+
import { join, relative } from 'path';
|
|
3
|
+
import scanDir from '../fs/scanDir.js';
|
|
4
|
+
import parseFrontmatter from '../fs/parseFrontmatter.js';
|
|
5
|
+
import listPages from '../pages/listPages.js';
|
|
6
|
+
import { normalizeScopes } from '../extensions/scopes.js';
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
The enabled extensions that serve pages, as [{ name, root, kempo }]. Read from disk each time, as the
|
|
10
|
+
router does, so what is listed is what is answering.
|
|
11
|
+
*/
|
|
12
|
+
const enabledExtensions = async () => {
|
|
13
|
+
const [{ getEnabledExtensions }, { default: readKempoConfig }] = await Promise.all([
|
|
14
|
+
import('../extensions/scopeCache.js'),
|
|
15
|
+
import('../extensions/readKempoConfig.js'),
|
|
16
|
+
]);
|
|
17
|
+
const found = [];
|
|
18
|
+
for(const { name } of await getEnabledExtensions()){
|
|
19
|
+
const root = join(process.cwd(), 'node_modules', name);
|
|
20
|
+
try {
|
|
21
|
+
found.push({ name, root, kempo: (await readKempoConfig(root)).kempo });
|
|
22
|
+
} catch { /* not readable: nothing is served from it */ }
|
|
23
|
+
}
|
|
24
|
+
return found;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/* The pages an extension serves under one of its scopes, as /scope/path/. */
|
|
28
|
+
const pagesOfScope = async (root, { scope, dir }) => {
|
|
29
|
+
const base = join(root, dir);
|
|
30
|
+
return scanDir(base, base, async (fullPath, entry) => {
|
|
31
|
+
if(entry.isDirectory() || !entry.name.endsWith('.page.html')) return null;
|
|
32
|
+
const path = relative(base, fullPath).replace(/\\/g, '/').replace(/\.page\.html$/, '').replace(/(^|\/)index$/, '$1');
|
|
33
|
+
if(path.includes('[')) return null;
|
|
34
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
35
|
+
const meta = parseFrontmatter(content);
|
|
36
|
+
if(meta.hidden === 'true') return null;
|
|
37
|
+
return {
|
|
38
|
+
url: `/${scope}/${path}`.replace(/\/\/+/g, '/'),
|
|
39
|
+
title: meta.title || content.match(/<page\s[^>]*title="([^"]*)"/)?.[1] || '',
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/*
|
|
45
|
+
Every page a link can go to: the site's own, then those its extensions serve under their scopes
|
|
46
|
+
(/products/, /categories/, /commerce/cart/). A page with a [param] in its address has no one address and
|
|
47
|
+
is left out, and so are hidden and disabled pages. The site wins when both have an address.
|
|
48
|
+
|
|
49
|
+
`extensions` replaces what is read from disk, as [{ name, root, kempo }], for tests.
|
|
50
|
+
*/
|
|
51
|
+
export default async ({ rootDir, extensions }) => {
|
|
52
|
+
if(!rootDir){
|
|
53
|
+
return [{ code: 400, msg: 'Root directory is required' }, null];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const [, site] = await listPages({ rootDir, showHidden: false });
|
|
57
|
+
const pages = (site?.pages ?? [])
|
|
58
|
+
.filter(page => !page.disabled && !page.url.includes('['))
|
|
59
|
+
.map(page => ({ url: page.url, title: page.title || page.name || page.url, owner: page.owner }));
|
|
60
|
+
|
|
61
|
+
const seen = new Set(pages.map(page => page.url));
|
|
62
|
+
for(const { name, root, kempo } of extensions ?? await enabledExtensions()){
|
|
63
|
+
for(const scope of normalizeScopes(kempo)[1]){
|
|
64
|
+
for(const page of (await pagesOfScope(root, scope)).sort((a, b) => a.url.localeCompare(b.url))){
|
|
65
|
+
if(seen.has(page.url)) continue;
|
|
66
|
+
seen.add(page.url);
|
|
67
|
+
pages.push({ url: page.url, title: page.title || page.url, owner: name });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return [null, pages];
|
|
73
|
+
};
|