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
|
@@ -24,6 +24,7 @@ export const normalizeWidget = ({
|
|
|
24
24
|
title,
|
|
25
25
|
description = null,
|
|
26
26
|
icon = null,
|
|
27
|
+
iconSrc = null,
|
|
27
28
|
element,
|
|
28
29
|
module,
|
|
29
30
|
defaultWidth = 2,
|
|
@@ -57,6 +58,10 @@ export const normalizeWidget = ({
|
|
|
57
58
|
return [{ code: 400, msg: 'Icon must be an icon name' }, null];
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
if(iconSrc !== null && !(isLocalPath(iconSrc) && iconSrc.length <= 200)){
|
|
62
|
+
return [{ code: 400, msg: 'iconSrc must be the path to an icon file on this site, beginning with a single "/"' }, null];
|
|
63
|
+
}
|
|
64
|
+
|
|
60
65
|
if(typeof element !== 'string' || !ELEMENT_PATTERN.test(element)){
|
|
61
66
|
return [{ code: 400, msg: 'Element must be a custom element tag name such as "my-ext-widget"' }, null];
|
|
62
67
|
}
|
|
@@ -128,6 +133,7 @@ export const normalizeWidget = ({
|
|
|
128
133
|
title: title.trim(),
|
|
129
134
|
description,
|
|
130
135
|
icon,
|
|
136
|
+
iconSrc,
|
|
131
137
|
element,
|
|
132
138
|
module,
|
|
133
139
|
defaultWidth,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
import { renderPage, renderExternalPage } from 'kempo-server/templating';
|
|
5
|
+
import shake from 'kempo-css/shake';
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
Renders an email template to HTML, without sending it.
|
|
11
|
+
|
|
12
|
+
A plain name ("verify-email") is looked up in the site's `templates/emails`, then in kempo's own.
|
|
13
|
+
|
|
14
|
+
A name with an owner ("my-extension/order-paid") is an extension's template. The site may override
|
|
15
|
+
it at `templates/emails/my-extension/order-paid.page.html`; otherwise it is the extension's own
|
|
16
|
+
`emails/order-paid.page.html`. Either way the layout (`<page template="email">`) is the site's
|
|
17
|
+
`templates/emails/email.template.html` when it has one, and kempo's otherwise, so every email a site
|
|
18
|
+
sends looks the same.
|
|
19
|
+
|
|
20
|
+
Returns [error, html].
|
|
21
|
+
*/
|
|
22
|
+
export default async ({ template, data = {} } = {}) => {
|
|
23
|
+
if(!template){
|
|
24
|
+
return [{ code: 400, msg: 'Template name is required' }, null];
|
|
25
|
+
}
|
|
26
|
+
if(!/^[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)?$/.test(template)){
|
|
27
|
+
return [{ code: 400, msg: `Invalid email template name: ${template}` }, null];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const consumerDir = join(process.cwd(), 'templates/emails');
|
|
31
|
+
const moduleDir = join(__dirname, '../../../app-templates/emails');
|
|
32
|
+
const [owner, name] = template.includes('/') ? template.split('/') : [null, template];
|
|
33
|
+
|
|
34
|
+
let pagePath;
|
|
35
|
+
let rootDir;
|
|
36
|
+
if(owner){
|
|
37
|
+
const override = join(consumerDir, owner, `${name}.page.html`);
|
|
38
|
+
pagePath = existsSync(override) ? override : join(process.cwd(), 'node_modules', owner, 'emails', `${name}.page.html`);
|
|
39
|
+
rootDir = existsSync(join(consumerDir, 'email.template.html')) ? consumerDir : moduleDir;
|
|
40
|
+
} else {
|
|
41
|
+
rootDir = existsSync(join(consumerDir, `${name}.page.html`)) ? consumerDir : moduleDir;
|
|
42
|
+
pagePath = join(rootDir, `${name}.page.html`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if(!existsSync(pagePath)){
|
|
46
|
+
return [{ code: 404, msg: `Email template not found: ${template}` }, null];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const themePath = join(process.cwd(), 'public/styles.css');
|
|
50
|
+
const themeOpts = existsSync(themePath) ? { theme: themePath } : {};
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
let html = owner
|
|
54
|
+
? await renderExternalPage(pagePath, rootDir, rootDir, {}, data)
|
|
55
|
+
: await renderPage(pagePath, rootDir, {}, data);
|
|
56
|
+
const css = shake(html, { ...themeOpts, colorMode: 'light' });
|
|
57
|
+
html = html.replace('</head>', `<style>${css}</style></head>`);
|
|
58
|
+
return [null, html];
|
|
59
|
+
} catch(error){
|
|
60
|
+
console.error('Email render error:', error);
|
|
61
|
+
return [{ code: 500, msg: 'Failed to render the email template' }, null];
|
|
62
|
+
}
|
|
63
|
+
};
|
|
@@ -1,13 +1,10 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { fileURLToPath } from 'url';
|
|
3
|
-
import { dirname, join } from 'path';
|
|
4
|
-
import { renderPage } from 'kempo-server/templating';
|
|
5
|
-
import shake from 'kempo-css/shake';
|
|
1
|
+
import renderEmailTemplate from './renderEmailTemplate.js';
|
|
6
2
|
import sendEmail from './sendEmail.js';
|
|
7
3
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
/*
|
|
5
|
+
`template` is a plain name for kempo's and the site's templates, or "owner/name" for an extension's;
|
|
6
|
+
see renderEmailTemplate.js.
|
|
7
|
+
*/
|
|
11
8
|
export default async ({ to, subject, template, data = {} }) => {
|
|
12
9
|
if(!to){
|
|
13
10
|
return [{ code: 400, msg: 'Recipient email is required' }, null];
|
|
@@ -21,25 +18,10 @@ export default async ({ to, subject, template, data = {} }) => {
|
|
|
21
18
|
return [{ code: 400, msg: 'Template name is required' }, null];
|
|
22
19
|
}
|
|
23
20
|
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const pagePath = join(rootDir, `${template}.page.html`);
|
|
28
|
-
|
|
29
|
-
if(!existsSync(pagePath)){
|
|
30
|
-
return [{ code: 404, msg: `Email template not found: ${template}` }, null];
|
|
21
|
+
const [error, html] = await renderEmailTemplate({ template, data });
|
|
22
|
+
if(error){
|
|
23
|
+
return [error, null];
|
|
31
24
|
}
|
|
32
25
|
|
|
33
|
-
|
|
34
|
-
const themeOpts = existsSync(themePath) ? { theme: themePath } : {};
|
|
35
|
-
|
|
36
|
-
try {
|
|
37
|
-
let html = await renderPage(pagePath, rootDir, {}, data);
|
|
38
|
-
const css = shake(html, { ...themeOpts, colorMode: 'light' });
|
|
39
|
-
html = html.replace('</head>', `<style>${css}</style></head>`);
|
|
40
|
-
return await sendEmail({ to, subject, html });
|
|
41
|
-
} catch(error){
|
|
42
|
-
console.error('Email render error:', error);
|
|
43
|
-
return [{ code: 500, msg: 'Failed to send email from template' }, null];
|
|
44
|
-
}
|
|
26
|
+
return await sendEmail({ to, subject, html });
|
|
45
27
|
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { stat } from 'fs/promises';
|
|
3
|
+
import { scopeNames } from './scopes.js';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Aliases let an extension answer on a short, familiar address instead of only under its public scope:
|
|
7
|
+
an extension with the scope `commerce` can declare `"aliases": { "/cart": "/commerce/cart" }` and its
|
|
8
|
+
cart page is then reachable at /cart/ too. The address stays as typed (it is a rewrite, not a redirect).
|
|
9
|
+
|
|
10
|
+
An alias must point inside one of the extension's own scopes, so an extension can only ever give a
|
|
11
|
+
second address to its own pages. It never shadows a page the site has of its own at that address, and
|
|
12
|
+
it cannot take over anything under /admin or /api. The alias address is a claim, like a scope, and no
|
|
13
|
+
other enabled extension may claim it (see claims.js).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const RESERVED = /^\/(admin|api|kempo|kempo-css|kempo-ui)(\/|$)/;
|
|
17
|
+
|
|
18
|
+
const trim = value => String(value).replace(/\/+$/, '');
|
|
19
|
+
|
|
20
|
+
/*
|
|
21
|
+
Checks the `aliases` an extension declares: { "/from": "/scope/to" }. `scope` is the extension's
|
|
22
|
+
scope, or the list of its scopes. Returns [error, aliases].
|
|
23
|
+
*/
|
|
24
|
+
export const normalizeAliases = (aliases, scope) => {
|
|
25
|
+
const scopes = [scope].flat().filter(Boolean);
|
|
26
|
+
if(aliases === undefined || aliases === null) return [null, {}];
|
|
27
|
+
if(typeof aliases !== 'object' || Array.isArray(aliases)) return [{ code: 400, msg: 'aliases must be an object of address to address' }, null];
|
|
28
|
+
|
|
29
|
+
const result = {};
|
|
30
|
+
for(const [from, to] of Object.entries(aliases)){
|
|
31
|
+
const source = trim(from);
|
|
32
|
+
const target = trim(to);
|
|
33
|
+
if(!source.startsWith('/') || source === '' || RESERVED.test(source)) return [{ code: 400, msg: `Alias "${from}" must be an address such as /cart, outside /admin and /api` }, null];
|
|
34
|
+
if(!scopes.some(name => target === `/${name}` || target.startsWith(`/${name}/`))) return [{ code: 400, msg: `Alias "${from}" must point inside the extension's own scope (/${scopes.join(', /') || '…'}/…)` }, null];
|
|
35
|
+
result[source] = target;
|
|
36
|
+
}
|
|
37
|
+
return [null, result];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/* True when the site itself has a page, directory or file at this address, which always wins over an alias. */
|
|
41
|
+
const siteHasPath = async (publicDir, address) => {
|
|
42
|
+
const base = join(publicDir, ...address.split('/').filter(Boolean));
|
|
43
|
+
const candidates = [base, `${base}.page.html`, `${base}.html`, join(base, 'index.page.html'), join(base, 'index.html'), join(base, 'index.js'), join(base, 'GET.js')];
|
|
44
|
+
for(const candidate of candidates){
|
|
45
|
+
try {
|
|
46
|
+
const info = await stat(candidate);
|
|
47
|
+
if(candidate === base ? info.isFile() : true) return true;
|
|
48
|
+
} catch { /* not there */ }
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/*
|
|
54
|
+
The address to serve for `url`: the alias target when an enabled extension declares one for it and
|
|
55
|
+
the site has no page there, otherwise `url` unchanged. `loaded` is [{ kempo }] for the enabled extensions.
|
|
56
|
+
*/
|
|
57
|
+
export const resolveAlias = async (url, loaded, publicDir) => {
|
|
58
|
+
const clean = trim(url) || '/';
|
|
59
|
+
for(const { kempo } of loaded){
|
|
60
|
+
const scopes = scopeNames(kempo);
|
|
61
|
+
if(!scopes.length || !kempo.aliases) continue;
|
|
62
|
+
const [error, aliases] = normalizeAliases(kempo.aliases, scopes);
|
|
63
|
+
if(error) continue;
|
|
64
|
+
for(const [from, to] of Object.entries(aliases)){
|
|
65
|
+
if(clean !== from && !clean.startsWith(`${from}/`)) continue;
|
|
66
|
+
if(await siteHasPath(publicDir, from)) return url;
|
|
67
|
+
return to + url.slice(from.length);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return url;
|
|
71
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import readKempoConfig from './readKempoConfig.js';
|
|
3
|
+
import { getEnabledExtensions } from './scopeCache.js';
|
|
4
|
+
import { findClaimConflicts, describeConflict } from './claims.js';
|
|
5
|
+
|
|
6
|
+
const configOf = async name => {
|
|
7
|
+
try {
|
|
8
|
+
return { name, kempo: (await readKempoConfig(join(process.cwd(), 'node_modules', name))).kempo };
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
Refuses to put an extension on the site when another one that is on already uses an address it
|
|
16
|
+
claims. "names" are the extensions about to be switched on (installed, or enabled together with what
|
|
17
|
+
they require) and "verb" is what is being done ("install", "enable" or "update"). Returns [error] with
|
|
18
|
+
a 409 and the whole list as "conflicts", or [null].
|
|
19
|
+
|
|
20
|
+
An extension whose package cannot be read is skipped, as it cannot be routed to anyway. "kempo" gives
|
|
21
|
+
the config of a name to check instead of reading it, for one that has just changed.
|
|
22
|
+
*/
|
|
23
|
+
export default async ({ names, verb, kempo = {} }) => {
|
|
24
|
+
const candidates = (await Promise.all(names.map(async name => kempo[name] ? { name, kempo: kempo[name] } : configOf(name)))).filter(Boolean);
|
|
25
|
+
const enabled = (await getEnabledExtensions()).map(row => row.name).filter(name => !names.includes(name));
|
|
26
|
+
const others = (await Promise.all(enabled.map(configOf))).filter(Boolean);
|
|
27
|
+
|
|
28
|
+
const conflicts = findClaimConflicts(candidates, others);
|
|
29
|
+
if(!conflicts.length) return [null];
|
|
30
|
+
|
|
31
|
+
return [{ code: 409, msg: describeConflict(conflicts[0], verb), conflicts }];
|
|
32
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { scopeNames } from './scopes.js';
|
|
2
|
+
import { normalizeAliases } from './aliases.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
An address an extension answers on is a *claim*, and the router can give an address to only one
|
|
6
|
+
extension. Whichever one it reaches first gets every request for it, and the other's pages, scripts
|
|
7
|
+
and API routes under it answer 404 without any error, so two extensions may not claim the same
|
|
8
|
+
address or one inside the other.
|
|
9
|
+
|
|
10
|
+
Every claim is a path: each of the extension's scopes (/products) and each of its alias addresses
|
|
11
|
+
(/cart). An alias that is invalid is left out here, since it is refused on its own.
|
|
12
|
+
*/
|
|
13
|
+
export const claimsOf = kempo => {
|
|
14
|
+
const names = scopeNames(kempo);
|
|
15
|
+
const [, aliases] = normalizeAliases(kempo?.aliases, names);
|
|
16
|
+
return [...names.map(scope => `/${scope}`), ...Object.keys(aliases ?? {})];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/* Two claims collide when they are the same address, or one is inside the other. */
|
|
20
|
+
export const overlaps = (a, b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
The collisions between extensions. "candidates" is what is about to be installed or switched on and
|
|
24
|
+
"others" what is on already, both as [{ name, kempo }]. Candidates are checked against each other as
|
|
25
|
+
well. Returns [{ name, claim, other, otherClaim }], one per colliding pair of claims. An extension
|
|
26
|
+
never collides with itself, so one that claims a scope and an alias in another of its own is fine.
|
|
27
|
+
*/
|
|
28
|
+
export const findClaimConflicts = (candidates, others) => {
|
|
29
|
+
const withClaims = list => list.map(({ name, kempo }) => ({ name, claims: claimsOf(kempo) }));
|
|
30
|
+
const mine = withClaims(candidates);
|
|
31
|
+
const theirs = withClaims(others);
|
|
32
|
+
const conflicts = [];
|
|
33
|
+
|
|
34
|
+
const compare = (left, right) => {
|
|
35
|
+
for(const claim of left.claims){
|
|
36
|
+
for(const otherClaim of right.claims){
|
|
37
|
+
if(overlaps(claim, otherClaim)) conflicts.push({ name: left.name, claim, other: right.name, otherClaim });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
mine.forEach((left, index) => {
|
|
43
|
+
theirs.filter(right => right.name !== left.name).forEach(right => compare(left, right));
|
|
44
|
+
mine.slice(index + 1).forEach(right => compare(left, right));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return conflicts;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/* The sentence a person is shown, naming who holds the address and what to do about it. */
|
|
51
|
+
export const describeConflict = ({ name, claim, other, otherClaim }, verb) => {
|
|
52
|
+
const what = claim === otherClaim ? otherClaim : `${otherClaim}, which overlaps ${claim}`;
|
|
53
|
+
return `Cannot ${verb} ${name}: ${other} already uses ${what}. Disable or uninstall ${other} first.`;
|
|
54
|
+
};
|
|
@@ -2,24 +2,18 @@ import { getTableConfig } from 'drizzle-orm/pg-core';
|
|
|
2
2
|
import { sql } from 'drizzle-orm';
|
|
3
3
|
import db from '../../db/index.js';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
PgTimestamp: 'timestamp',
|
|
18
|
-
PgDate: 'date',
|
|
19
|
-
PgTime: 'time',
|
|
20
|
-
PgJsonb: 'jsonb',
|
|
21
|
-
PgJson: 'json',
|
|
22
|
-
PgUUID: 'uuid',
|
|
5
|
+
/*
|
|
6
|
+
A column's SQL type is whatever Drizzle says it is (`bigserial`, `varchar(255)`, `timestamp`, `text[]`), never a
|
|
7
|
+
guess. A hand-kept map of types once sent `bigserial` to `text` with no sequence, which made a table that could
|
|
8
|
+
not take an insert. An enum has a type of its own that does not exist here yet, so it is stored as text.
|
|
9
|
+
*/
|
|
10
|
+
const sqlTypeOf = column => {
|
|
11
|
+
if(column.columnType === 'PgEnumColumn') return 'text';
|
|
12
|
+
try {
|
|
13
|
+
return column.getSQLType();
|
|
14
|
+
} catch {
|
|
15
|
+
return 'text';
|
|
16
|
+
}
|
|
23
17
|
};
|
|
24
18
|
|
|
25
19
|
const serializeDefault = (value) => {
|
|
@@ -33,8 +27,7 @@ const tableToSql = (table) => {
|
|
|
33
27
|
const config = getTableConfig(table);
|
|
34
28
|
const primaryKeys = config.columns.filter(c => c.primary).map(c => c.name);
|
|
35
29
|
const cols = config.columns.map(c => {
|
|
36
|
-
|
|
37
|
-
let def = `"${c.name}" ${type}`;
|
|
30
|
+
let def = `"${c.name}" ${sqlTypeOf(c)}`;
|
|
38
31
|
if(c.notNull) def += ' NOT NULL';
|
|
39
32
|
if(c.hasDefault && c.default !== undefined) def += ` DEFAULT ${serializeDefault(c.default)}`;
|
|
40
33
|
if(c.primary && primaryKeys.length === 1) def += ' PRIMARY KEY';
|
|
@@ -3,6 +3,7 @@ import { extension } from '../../db/schema.js';
|
|
|
3
3
|
import { eq, inArray } from 'drizzle-orm';
|
|
4
4
|
import { invalidateScopeCache } from './scopeCache.js';
|
|
5
5
|
import getRequirementTree from './getRequirementTree.js';
|
|
6
|
+
import checkClaims from './checkClaims.js';
|
|
6
7
|
|
|
7
8
|
/*
|
|
8
9
|
Switches an extension on. It cannot run without what it requires, so any of that which is installed
|
|
@@ -40,6 +41,13 @@ export default async ({ name, cascade = false }) => {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
const names = [name, ...disabled.map(requirement => requirement.name)];
|
|
44
|
+
|
|
45
|
+
/* Switching on what claims an address another enabled extension already has would silently break one of them. */
|
|
46
|
+
const [claimsError] = await checkClaims({ names, verb: 'enable' });
|
|
47
|
+
if(claimsError){
|
|
48
|
+
return [claimsError, null];
|
|
49
|
+
}
|
|
50
|
+
|
|
43
51
|
await db.update(extension)
|
|
44
52
|
.set({ enabled: true, updatedAt: new Date() })
|
|
45
53
|
.where(inArray(extension.name, names));
|
|
@@ -14,6 +14,11 @@ import createTablesFromSchema from './createTablesFromSchema.js';
|
|
|
14
14
|
import readKempoConfig from './readKempoConfig.js';
|
|
15
15
|
import { invalidateScopeCache } from './scopeCache.js';
|
|
16
16
|
import { getUnmetDependencies } from './dependencies.js';
|
|
17
|
+
import registerJobs from '../scheduler/registerJobs.js';
|
|
18
|
+
import { normalizeJobs } from '../scheduler/helpers.js';
|
|
19
|
+
import { normalizeAliases } from './aliases.js';
|
|
20
|
+
import { normalizeScopes, scopeNames } from './scopes.js';
|
|
21
|
+
import checkClaims from './checkClaims.js';
|
|
17
22
|
|
|
18
23
|
export default async ({ name }) => {
|
|
19
24
|
if(!name){
|
|
@@ -48,6 +53,22 @@ export default async ({ name }) => {
|
|
|
48
53
|
return [{ code: 409, msg: `Requires ${unmet.join(', ')} to be installed and enabled first` }, null];
|
|
49
54
|
}
|
|
50
55
|
|
|
56
|
+
const [jobsError] = normalizeJobs(kempoConfig.jobs);
|
|
57
|
+
if(jobsError) return [jobsError, null];
|
|
58
|
+
|
|
59
|
+
const [scopesError] = normalizeScopes(kempoConfig);
|
|
60
|
+
if(scopesError) return [scopesError, null];
|
|
61
|
+
|
|
62
|
+
const [aliasesError] = normalizeAliases(kempoConfig.aliases, scopeNames(kempoConfig));
|
|
63
|
+
if(aliasesError) return [aliasesError, null];
|
|
64
|
+
|
|
65
|
+
/*
|
|
66
|
+
An address can belong to one extension only. Installing a second one that claims it would leave
|
|
67
|
+
it with no public pages and no error, so it is refused here and the first has to be disabled.
|
|
68
|
+
*/
|
|
69
|
+
const [claimsError] = await checkClaims({ names: [name], verb: 'install', kempo: { [name]: kempoConfig } });
|
|
70
|
+
if(claimsError) return [claimsError, null];
|
|
71
|
+
|
|
51
72
|
/*
|
|
52
73
|
Create tables from declarative schema
|
|
53
74
|
*/
|
|
@@ -131,6 +152,17 @@ export default async ({ name }) => {
|
|
|
131
152
|
}
|
|
132
153
|
}
|
|
133
154
|
|
|
155
|
+
/*
|
|
156
|
+
Register declarative scheduled jobs. Like a widget, a job that cannot be registered (a site that has
|
|
157
|
+
not applied the scheduledJob table yet) is reported and skipped rather than failing the install.
|
|
158
|
+
*/
|
|
159
|
+
if((kempoConfig.jobs || []).length){
|
|
160
|
+
const [registerError] = await registerJobs({ owner: name, jobs: kempoConfig.jobs });
|
|
161
|
+
if(registerError){
|
|
162
|
+
console.warn(`[${name}] scheduled jobs were not registered: ${registerError.msg}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
134
166
|
const now = new Date();
|
|
135
167
|
const entry = {
|
|
136
168
|
name,
|
|
@@ -2,8 +2,8 @@ import { join } from 'path';
|
|
|
2
2
|
import { readFile } from 'fs/promises';
|
|
3
3
|
|
|
4
4
|
/*
|
|
5
|
-
What an installed extension declares to kempo: its `public-scope`, `docs`, `git`, `schema`, `hooks`,
|
|
6
|
-
`permissions`, `settings`, `groups`, `dependencies` and `
|
|
5
|
+
What an installed extension declares to kempo: its `public-scope` and `public-scopes`, `docs`, `git`, `schema`, `hooks`,
|
|
6
|
+
`permissions`, `settings`, `groups`, `dependencies`, `widgets`, `jobs` and `aliases`.
|
|
7
7
|
|
|
8
8
|
It lives in the extension's kempo-config.json, which is the one place it is written. package.json's own
|
|
9
9
|
`kempo` field is still read, as the fallback for an older extension that put everything there, and
|
|
@@ -3,7 +3,20 @@ import { extension } from '../../db/schema.js';
|
|
|
3
3
|
|
|
4
4
|
let cache = null;
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
const listeners = new Set();
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
For what has to follow the set of enabled extensions, such as menus holding an extension's items.
|
|
10
|
+
Called after every install, enable, disable and uninstall; a listener's failure never reaches them.
|
|
11
|
+
*/
|
|
12
|
+
export const onExtensionsChanged = listener => listeners.add(listener);
|
|
13
|
+
|
|
14
|
+
export const invalidateScopeCache = () => {
|
|
15
|
+
cache = null;
|
|
16
|
+
for(const listener of listeners){
|
|
17
|
+
Promise.resolve().then(listener).catch(error => console.error('[kempo] An extension change listener failed:', error.message));
|
|
18
|
+
}
|
|
19
|
+
};
|
|
7
20
|
|
|
8
21
|
/* One read of the extension table serves both questions, until an install/enable/disable/uninstall. */
|
|
9
22
|
const load = async () => {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The addresses an extension answers on, read from its kempo-config.json.
|
|
3
|
+
|
|
4
|
+
An extension serves a folder of its package at an address under its scope: with the scope `products`,
|
|
5
|
+
`public/index.page.html` is `/products/` and `public/api/products/GET.js` is `/products/api/products`.
|
|
6
|
+
It can claim more than one scope, each serving its own folder:
|
|
7
|
+
|
|
8
|
+
"public-scope": "products", // shorthand for "products": "public"
|
|
9
|
+
"public-scopes": { "categories": "public-categories" } // /categories/ serves public-categories/
|
|
10
|
+
|
|
11
|
+
`aliases` give pages under a scope a second, shorter address (see aliases.js). Every scope and every
|
|
12
|
+
alias address is a *claim*: no two enabled extensions may claim the same address, or one inside the
|
|
13
|
+
other, because the router can only give an address to one of them (see claims.js).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const NAME = /^[a-z0-9][a-z0-9_-]*$/;
|
|
17
|
+
|
|
18
|
+
/* What kempo itself serves at the top level of the site. */
|
|
19
|
+
export const RESERVED_SCOPES = ['admin', 'api', 'kempo', 'kempo-css', 'kempo-ui', 'monaco-editor'];
|
|
20
|
+
|
|
21
|
+
const cleanDir = dir => String(dir).replace(/\\/g, '/').replace(/^(\.\/)+/, '').replace(/\/+$/, '');
|
|
22
|
+
|
|
23
|
+
const badDir = dir => !dir || dir.startsWith('/') || /^[a-z]:/i.test(dir) || dir.split('/').some(part => part === '..' || part === '');
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
The scopes an extension declares, each with the folder of its package it serves: [error, [{ scope, dir }]].
|
|
27
|
+
An extension with no scope gets an empty list. A bad name, a reserved one, a folder that leaves the
|
|
28
|
+
package, or the same scope pointed at two folders is an error.
|
|
29
|
+
*/
|
|
30
|
+
export const normalizeScopes = kempo => {
|
|
31
|
+
const found = new Map();
|
|
32
|
+
|
|
33
|
+
const add = (scope, folder) => {
|
|
34
|
+
if(typeof scope !== 'string' || !NAME.test(scope)){
|
|
35
|
+
return { code: 400, msg: `Scope "${scope}" must be lowercase letters, numbers, dashes or underscores, such as "products"` };
|
|
36
|
+
}
|
|
37
|
+
if(RESERVED_SCOPES.includes(scope)){
|
|
38
|
+
return { code: 400, msg: `Scope "${scope}" is used by kempo itself` };
|
|
39
|
+
}
|
|
40
|
+
const dir = cleanDir(folder);
|
|
41
|
+
if(typeof folder !== 'string' || badDir(dir)){
|
|
42
|
+
return { code: 400, msg: `The folder for scope "${scope}" must be a folder inside the package, such as "public"` };
|
|
43
|
+
}
|
|
44
|
+
if(found.has(scope) && found.get(scope) !== dir){
|
|
45
|
+
return { code: 400, msg: `Scope "${scope}" is given two different folders` };
|
|
46
|
+
}
|
|
47
|
+
found.set(scope, dir);
|
|
48
|
+
return null;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const single = kempo?.['public-scope'];
|
|
52
|
+
if(single !== undefined && single !== null && single !== ''){
|
|
53
|
+
const error = add(single, 'public');
|
|
54
|
+
if(error) return [error, null];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const several = kempo?.['public-scopes'];
|
|
58
|
+
if(several !== undefined && several !== null){
|
|
59
|
+
if(typeof several !== 'object' || Array.isArray(several)){
|
|
60
|
+
return [{ code: 400, msg: 'public-scopes must be an object of scope to folder' }, null];
|
|
61
|
+
}
|
|
62
|
+
for(const [scope, folder] of Object.entries(several)){
|
|
63
|
+
const error = add(scope, folder);
|
|
64
|
+
if(error) return [error, null];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return [null, [...found].map(([scope, dir]) => ({ scope, dir }))];
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/* Just the scope names, for what only needs to know them (an alias must point inside one). */
|
|
72
|
+
export const scopeNames = kempo => (normalizeScopes(kempo)[1] ?? []).map(({ scope }) => scope);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import db from '../../db/index.js';
|
|
2
|
-
import { extension, hook, permission, setting, group, groupPermission, userGroup, dashboardWidget } from '../../db/schema.js';
|
|
2
|
+
import { extension, hook, permission, setting, group, groupPermission, userGroup, dashboardWidget, scheduledJob } from '../../db/schema.js';
|
|
3
3
|
import { eq, inArray, like } from 'drizzle-orm';
|
|
4
4
|
import { join } from 'path';
|
|
5
5
|
import { pathToFileURL } from 'url';
|
|
@@ -105,6 +105,7 @@ const uninstallOne = async ({ name, purgeData = false }) => {
|
|
|
105
105
|
try {
|
|
106
106
|
await db.delete(hook).where(eq(hook.owner, name));
|
|
107
107
|
await db.delete(dashboardWidget).where(eq(dashboardWidget.owner, name));
|
|
108
|
+
await db.delete(scheduledJob).where(eq(scheduledJob.owner, name)).catch(() => {});
|
|
108
109
|
await db.delete(extension).where(eq(extension.name, name));
|
|
109
110
|
} catch(error) {
|
|
110
111
|
return [{ code: 500, msg: 'Failed to remove extension' }, null];
|
|
@@ -9,10 +9,14 @@ import triggerHook, { clearHandlerCache } from '../hooks/triggerHook.js';
|
|
|
9
9
|
import createHook from '../hooks/createHook.js';
|
|
10
10
|
import registerDashboardWidget from '../dashboard/registerDashboardWidget.js';
|
|
11
11
|
import unregisterDashboardWidget from '../dashboard/unregisterDashboardWidget.js';
|
|
12
|
+
import registerJobs from '../scheduler/registerJobs.js';
|
|
12
13
|
import setSetting from '../settings/setSetting.js';
|
|
13
14
|
import declaredValue from '../settings/declaredValue.js';
|
|
14
15
|
import createTablesFromSchema from './createTablesFromSchema.js';
|
|
15
16
|
import readKempoConfig from './readKempoConfig.js';
|
|
17
|
+
import { normalizeAliases } from './aliases.js';
|
|
18
|
+
import { normalizeScopes, scopeNames } from './scopes.js';
|
|
19
|
+
import checkClaims from './checkClaims.js';
|
|
16
20
|
|
|
17
21
|
const requireFromProject = createRequire(join(process.cwd(), 'package.json'));
|
|
18
22
|
|
|
@@ -56,6 +60,27 @@ export default async ({ name, version = 'latest' }) => {
|
|
|
56
60
|
return [{ code: 404, msg: `Updated package "${name}" not found` }, null];
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
/*
|
|
64
|
+
The new version may claim addresses the old one did not. If it is on and another extension has one
|
|
65
|
+
of them, put the old version back before anything else changes: leaving the new one would break
|
|
66
|
+
one of the two, silently.
|
|
67
|
+
*/
|
|
68
|
+
const [scopesError] = normalizeScopes(newKempo);
|
|
69
|
+
const [aliasesError] = scopesError ? [null] : normalizeAliases(newKempo.aliases, scopeNames(newKempo));
|
|
70
|
+
const [claimsError] = scopesError || aliasesError || !oldRecord.enabled
|
|
71
|
+
? [null]
|
|
72
|
+
: await checkClaims({ names: [name], verb: 'update', kempo: { [name]: newKempo } });
|
|
73
|
+
const refusal = scopesError || aliasesError || claimsError;
|
|
74
|
+
if(refusal){
|
|
75
|
+
let msg = refusal.msg;
|
|
76
|
+
try {
|
|
77
|
+
if(oldVersion) execSync(`npm install ${name}@${oldVersion}`, { cwd: process.cwd(), stdio: 'pipe' });
|
|
78
|
+
} catch {
|
|
79
|
+
msg += ` The previous version could not be put back automatically: run npm install ${name}@${oldVersion}.`;
|
|
80
|
+
}
|
|
81
|
+
return [{ ...refusal, msg }, null];
|
|
82
|
+
}
|
|
83
|
+
|
|
59
84
|
/*
|
|
60
85
|
Create any new tables from schema
|
|
61
86
|
*/
|
|
@@ -161,6 +186,16 @@ export default async ({ name, version = 'latest' }) => {
|
|
|
161
186
|
}
|
|
162
187
|
}
|
|
163
188
|
|
|
189
|
+
/*
|
|
190
|
+
Sync scheduled jobs: the schedule becomes exactly what is declared now
|
|
191
|
+
*/
|
|
192
|
+
if((newKempo.jobs || []).length || (oldKempo.jobs || []).length){
|
|
193
|
+
const [jobsError] = await registerJobs({ owner: name, jobs: newKempo.jobs || [] });
|
|
194
|
+
if(jobsError){
|
|
195
|
+
console.warn(`[${name}] scheduled jobs were not registered: ${jobsError.msg}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
164
199
|
const now = new Date();
|
|
165
200
|
try {
|
|
166
201
|
await db.update(extension).set({
|
|
@@ -2,25 +2,10 @@ import { readFile, writeFile } from 'fs/promises';
|
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { existsSync } from 'fs';
|
|
4
4
|
import crypto from 'crypto';
|
|
5
|
-
import { GLOBAL_FILE, parseEntries } from './helpers.js';
|
|
5
|
+
import { GLOBAL_FILE, parseEntries, serializeEntries } from './helpers.js';
|
|
6
6
|
import triggerHook from '../hooks/triggerHook.js';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
const attrs = [
|
|
10
|
-
`id="${e.id}"`,
|
|
11
|
-
`name="${e.name}"`,
|
|
12
|
-
`owner="${e.owner}"`,
|
|
13
|
-
`author="${e.author}"`,
|
|
14
|
-
`created="${e.createdAt}"`,
|
|
15
|
-
`updated="${e.updatedAt}"`,
|
|
16
|
-
`locked="${e.locked}"`,
|
|
17
|
-
`location="${e.location}"`,
|
|
18
|
-
`priority="${e.priority}"`
|
|
19
|
-
].join(' ');
|
|
20
|
-
return `<content ${attrs}>\n${e.markup}\n</content>`;
|
|
21
|
-
}).join('\n');
|
|
22
|
-
|
|
23
|
-
export default async ({ rootDir, name, location, priority, author, owner = 'custom' }) => {
|
|
8
|
+
export default async ({ rootDir, name, location, priority, author, owner = 'custom', markup = '' }) => {
|
|
24
9
|
if(!rootDir){
|
|
25
10
|
return [{ code: 400, msg: 'Root directory is required' }, null];
|
|
26
11
|
}
|
|
@@ -45,11 +30,16 @@ export default async ({ rootDir, name, location, priority, author, owner = 'cust
|
|
|
45
30
|
owner,
|
|
46
31
|
author: author || '',
|
|
47
32
|
locked: false,
|
|
33
|
+
menu: '',
|
|
34
|
+
menuLocations: null,
|
|
35
|
+
menuCopyOf: '',
|
|
36
|
+
navItem: '',
|
|
37
|
+
extra: {},
|
|
48
38
|
location,
|
|
49
39
|
priority: parseInt(priority || '0', 10),
|
|
50
40
|
createdAt: now,
|
|
51
41
|
updatedAt: now,
|
|
52
|
-
markup
|
|
42
|
+
markup
|
|
53
43
|
};
|
|
54
44
|
|
|
55
45
|
entries.push(entry);
|