kempo 4.6.1 → 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.
Files changed (188) hide show
  1. package/README.md +5 -0
  2. package/app-public/account/index.page.html +2 -0
  3. package/app-public/nav.fragment.html +2 -1
  4. package/app-templates/emails/email.template.html +14 -0
  5. package/app-templates/emails/reset-password.page.html +19 -0
  6. package/app-templates/emails/test.page.html +21 -0
  7. package/app-templates/emails/verify-email.page.html +18 -0
  8. package/bin/cli.js +3 -0
  9. package/dist/admin/components/Dashboard.js +2 -2
  10. package/dist/admin/components/Dashboard.js.map +1 -1
  11. package/dist/admin/components/Menus.js +103 -0
  12. package/dist/admin/components/Menus.js.map +1 -0
  13. package/dist/admin/components/tableControls/ExtensionDetails.js +1 -1
  14. package/dist/admin/components/tableControls/ExtensionDetails.js.map +1 -1
  15. package/dist/admin/content/menus/index.page.html +11 -0
  16. package/dist/admin/extensions/browse/index.page.html +4 -1
  17. package/dist/admin/extensions/index.page.html +1 -0
  18. package/dist/admin/nav.fragment.html +6 -0
  19. package/dist/kempo/api/extensions/POST.js +1 -1
  20. package/dist/kempo/api/extensions/POST.js.map +1 -1
  21. package/dist/kempo/api/extensions/known/GET.js +1 -1
  22. package/dist/kempo/api/extensions/known/GET.js.map +1 -1
  23. package/dist/kempo/api/extensions/rating/PUT.js +1 -1
  24. package/dist/kempo/api/extensions/rating/PUT.js.map +1 -1
  25. package/dist/kempo/api/fragments/DELETE.js +1 -1
  26. package/dist/kempo/api/fragments/DELETE.js.map +1 -1
  27. package/dist/kempo/api/fragments/GET.js +1 -1
  28. package/dist/kempo/api/fragments/GET.js.map +1 -1
  29. package/dist/kempo/api/fragments/POST.js +1 -1
  30. package/dist/kempo/api/fragments/POST.js.map +1 -1
  31. package/dist/kempo/api/fragments/disable/PUT.js +1 -1
  32. package/dist/kempo/api/fragments/disable/PUT.js.map +1 -1
  33. package/dist/kempo/api/fragments/enable/PUT.js +1 -1
  34. package/dist/kempo/api/fragments/enable/PUT.js.map +1 -1
  35. package/dist/kempo/api/fragments/file/GET.js +1 -1
  36. package/dist/kempo/api/fragments/file/GET.js.map +1 -1
  37. package/dist/kempo/api/fragments/file/PATCH.js +1 -1
  38. package/dist/kempo/api/fragments/file/PATCH.js.map +1 -1
  39. package/dist/kempo/api/fragments/file/PUT.js +1 -1
  40. package/dist/kempo/api/fragments/file/PUT.js.map +1 -1
  41. package/dist/kempo/api/fragments/lock/PUT.js +1 -1
  42. package/dist/kempo/api/fragments/lock/PUT.js.map +1 -1
  43. package/dist/kempo/api/globals/DELETE.js +1 -1
  44. package/dist/kempo/api/globals/DELETE.js.map +1 -1
  45. package/dist/kempo/api/globals/GET.js +1 -1
  46. package/dist/kempo/api/globals/GET.js.map +1 -1
  47. package/dist/kempo/api/globals/POST.js +1 -1
  48. package/dist/kempo/api/globals/POST.js.map +1 -1
  49. package/dist/kempo/api/globals/disable/PUT.js +1 -1
  50. package/dist/kempo/api/globals/disable/PUT.js.map +1 -1
  51. package/dist/kempo/api/globals/enable/PUT.js +1 -1
  52. package/dist/kempo/api/globals/enable/PUT.js.map +1 -1
  53. package/dist/kempo/api/globals/entry/GET.js +1 -1
  54. package/dist/kempo/api/globals/entry/GET.js.map +1 -1
  55. package/dist/kempo/api/globals/entry/PUT.js +1 -1
  56. package/dist/kempo/api/globals/entry/PUT.js.map +1 -1
  57. package/dist/kempo/api/globals/lock/PUT.js +1 -1
  58. package/dist/kempo/api/globals/lock/PUT.js.map +1 -1
  59. package/dist/kempo/api/menus/DELETE.js +2 -0
  60. package/dist/kempo/api/menus/DELETE.js.map +1 -0
  61. package/dist/kempo/api/menus/GET.js +2 -0
  62. package/dist/kempo/api/menus/GET.js.map +1 -0
  63. package/dist/kempo/api/menus/POST.js +2 -0
  64. package/dist/kempo/api/menus/POST.js.map +1 -0
  65. package/dist/kempo/api/menus/adopt/PUT.js +2 -0
  66. package/dist/kempo/api/menus/adopt/PUT.js.map +1 -0
  67. package/dist/kempo/api/menus/items/DELETE.js +2 -0
  68. package/dist/kempo/api/menus/items/DELETE.js.map +1 -0
  69. package/dist/kempo/api/menus/items/POST.js +2 -0
  70. package/dist/kempo/api/menus/items/POST.js.map +1 -0
  71. package/dist/kempo/api/menus/items/enabled/PUT.js +2 -0
  72. package/dist/kempo/api/menus/items/enabled/PUT.js.map +1 -0
  73. package/dist/kempo/api/menus/items/order/PUT.js +2 -0
  74. package/dist/kempo/api/menus/items/order/PUT.js.map +1 -0
  75. package/dist/kempo/api/menus/location/PUT.js +2 -0
  76. package/dist/kempo/api/menus/location/PUT.js.map +1 -0
  77. package/dist/kempo/api/pages/DELETE.js +1 -1
  78. package/dist/kempo/api/pages/DELETE.js.map +1 -1
  79. package/dist/kempo/api/pages/GET.js +1 -1
  80. package/dist/kempo/api/pages/GET.js.map +1 -1
  81. package/dist/kempo/api/pages/POST.js +1 -1
  82. package/dist/kempo/api/pages/POST.js.map +1 -1
  83. package/dist/kempo/api/pages/directories/GET.js +1 -1
  84. package/dist/kempo/api/pages/directories/GET.js.map +1 -1
  85. package/dist/kempo/api/pages/disable/PUT.js +1 -1
  86. package/dist/kempo/api/pages/disable/PUT.js.map +1 -1
  87. package/dist/kempo/api/pages/enable/PUT.js +1 -1
  88. package/dist/kempo/api/pages/enable/PUT.js.map +1 -1
  89. package/dist/kempo/api/pages/file/GET.js +1 -1
  90. package/dist/kempo/api/pages/file/GET.js.map +1 -1
  91. package/dist/kempo/api/pages/file/PATCH.js +1 -1
  92. package/dist/kempo/api/pages/file/PATCH.js.map +1 -1
  93. package/dist/kempo/api/pages/file/PUT.js +1 -1
  94. package/dist/kempo/api/pages/file/PUT.js.map +1 -1
  95. package/dist/kempo/api/pages/lock/PUT.js +1 -1
  96. package/dist/kempo/api/pages/lock/PUT.js.map +1 -1
  97. package/dist/kempo/api/pages/rescan/POST.js +1 -1
  98. package/dist/kempo/api/pages/rescan/POST.js.map +1 -1
  99. package/dist/kempo/api/pages/search/GET.js +1 -1
  100. package/dist/kempo/api/pages/search/GET.js.map +1 -1
  101. package/dist/kempo/api/pages/templates/GET.js +1 -1
  102. package/dist/kempo/api/pages/templates/GET.js.map +1 -1
  103. package/dist/kempo/api/templates/DELETE.js +1 -1
  104. package/dist/kempo/api/templates/DELETE.js.map +1 -1
  105. package/dist/kempo/api/templates/GET.js +1 -1
  106. package/dist/kempo/api/templates/GET.js.map +1 -1
  107. package/dist/kempo/api/templates/POST.js +1 -1
  108. package/dist/kempo/api/templates/POST.js.map +1 -1
  109. package/dist/kempo/api/templates/disable/PUT.js +1 -1
  110. package/dist/kempo/api/templates/disable/PUT.js.map +1 -1
  111. package/dist/kempo/api/templates/enable/PUT.js +1 -1
  112. package/dist/kempo/api/templates/enable/PUT.js.map +1 -1
  113. package/dist/kempo/api/templates/file/GET.js +1 -1
  114. package/dist/kempo/api/templates/file/GET.js.map +1 -1
  115. package/dist/kempo/api/templates/file/PATCH.js +1 -1
  116. package/dist/kempo/api/templates/file/PATCH.js.map +1 -1
  117. package/dist/kempo/api/templates/file/PUT.js +1 -1
  118. package/dist/kempo/api/templates/file/PUT.js.map +1 -1
  119. package/dist/kempo/api/templates/lock/PUT.js +1 -1
  120. package/dist/kempo/api/templates/lock/PUT.js.map +1 -1
  121. package/dist/kempo/components/DashboardWidget.js +4 -4
  122. package/dist/kempo/components/DashboardWidget.js.map +1 -1
  123. package/dist/kempo/icons/campaign.svg +1 -0
  124. package/dist/kempo/icons/fields.svg +1 -0
  125. package/dist/kempo/icons/open_in_new.svg +1 -0
  126. package/dist/kempo/sdk.js +1 -1
  127. package/dist/kempo/sdk.js.map +1 -1
  128. package/middleware/kempo.js +48 -12
  129. package/package.json +2 -2
  130. package/server/db/schema.js +24 -0
  131. package/server/sdk.js +21 -0
  132. package/server/utils/auth/requestPasswordReset.js +9 -3
  133. package/server/utils/auth/verifyEmail.js +10 -1
  134. package/server/utils/dashboard/coreWidgets.js +1 -0
  135. package/server/utils/dashboard/helpers.js +6 -0
  136. package/server/utils/email/renderEmailTemplate.js +63 -0
  137. package/server/utils/email/sendEmailFromTemplate.js +9 -27
  138. package/server/utils/extensions/aliases.js +71 -0
  139. package/server/utils/extensions/checkClaims.js +32 -0
  140. package/server/utils/extensions/claims.js +54 -0
  141. package/server/utils/extensions/createTablesFromSchema.js +13 -20
  142. package/server/utils/extensions/enableExtensionTree.js +8 -0
  143. package/server/utils/extensions/{getMarketplaceClient.js → getSiteMarketplaceClient.js} +1 -1
  144. package/server/utils/extensions/installExtension.js +32 -0
  145. package/server/utils/extensions/installFromCatalog.js +1 -1
  146. package/server/utils/extensions/installPackage.js +1 -1
  147. package/server/utils/extensions/{catalogClient.js → marketplaceClient.js} +4 -1
  148. package/server/utils/extensions/readKempoConfig.js +2 -2
  149. package/server/utils/extensions/scopeCache.js +14 -1
  150. package/server/utils/extensions/scopes.js +72 -0
  151. package/server/utils/extensions/uninstallExtensionTree.js +2 -1
  152. package/server/utils/extensions/updateExtension.js +35 -0
  153. package/server/utils/global-content/createGlobalContent.js +8 -18
  154. package/server/utils/global-content/deleteGlobalContent.js +1 -16
  155. package/server/utils/global-content/disableGlobalContent.js +1 -16
  156. package/server/utils/global-content/enableGlobalContent.js +1 -16
  157. package/server/utils/global-content/helpers.js +40 -6
  158. package/server/utils/global-content/listGlobalContent.js +2 -2
  159. package/server/utils/global-content/setGlobalContentLocked.js +1 -16
  160. package/server/utils/global-content/updateGlobalContent.js +1 -16
  161. package/server/utils/menus/adoptMenuItem.js +58 -0
  162. package/server/utils/menus/assignMenuLocation.js +47 -0
  163. package/server/utils/menus/deleteMenu.js +27 -0
  164. package/server/utils/menus/deleteMenuItems.js +27 -0
  165. package/server/utils/menus/findMenuLocations.js +41 -0
  166. package/server/utils/menus/findNavItems.js +74 -0
  167. package/server/utils/menus/findPages.js +73 -0
  168. package/server/utils/menus/listMenus.js +103 -0
  169. package/server/utils/menus/markup.js +55 -0
  170. package/server/utils/menus/reorderMenuItems.js +36 -0
  171. package/server/utils/menus/saveMenu.js +54 -0
  172. package/server/utils/menus/saveMenuItem.js +122 -0
  173. package/server/utils/menus/setMenuItemEnabled.js +31 -0
  174. package/server/utils/menus/store.js +90 -0
  175. package/server/utils/scheduler/helpers.js +36 -0
  176. package/server/utils/scheduler/listJobs.js +11 -0
  177. package/server/utils/scheduler/registerJobs.js +37 -0
  178. package/server/utils/scheduler/runJobs.js +90 -0
  179. package/server/utils/scheduler/scheduler.js +37 -0
  180. package/server/utils/scheduler/unregisterJobs.js +16 -0
  181. package/server/utils/settings/ensureEncryptionKey.js +132 -0
  182. package/server/utils/settings/secretCrypto.js +5 -1
  183. package/dist/kempo/icons/create_new_folder.svg +0 -1
  184. package/dist/kempo/icons/credit_card.svg +0 -1
  185. package/dist/kempo/icons/login.svg +0 -1
  186. package/dist/kempo/icons/payments.svg +0 -1
  187. package/dist/kempo/icons/toll.svg +0 -1
  188. package/dist/kempo/icons/wallet.svg +0 -1
@@ -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
+ };
@@ -0,0 +1,103 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { basename, dirname } from 'path';
3
+ import scanDir from '../fs/scanDir.js';
4
+ import { parseEntries } from '../global-content/helpers.js';
5
+ import findMenuLocations from './findMenuLocations.js';
6
+ import findNavItems from './findNavItems.js';
7
+ import findPages from './findPages.js';
8
+ import { readStores, isMenuDefinition, isMenuItem } from './store.js';
9
+ import { parseLink } from './markup.js';
10
+
11
+ /*
12
+ What an extension adds to a location from its own public/*.global.html. It is shown so the person
13
+ editing the menus sees the whole of what a location will contain, but it cannot be changed here:
14
+ it belongs to the extension.
15
+ */
16
+ const readExtensionEntries = async dirs => {
17
+ const found = [];
18
+ for(const dir of dirs){
19
+ const owner = basename(dirname(dir));
20
+ const files = await scanDir(dir, dir, async (fullPath, entry) =>
21
+ !entry.isDirectory() && entry.name.endsWith('.global.html') ? fullPath : null);
22
+ for(const file of files){
23
+ for(const entry of parseEntries(await readFile(file, 'utf-8'))) found.push({ ...entry, owner });
24
+ }
25
+ }
26
+ return found;
27
+ };
28
+
29
+ const toItem = (entry, disabled, available = new Map()) => {
30
+ const link = entry.navItem ? null : parseLink(entry.markup);
31
+ return {
32
+ id: entry.id,
33
+ label: link?.label ?? entry.name,
34
+ href: link?.href ?? '',
35
+ icon: link?.icon ?? '',
36
+ newTab: link?.newTab ?? false,
37
+ editable: link !== null,
38
+ navItem: entry.navItem,
39
+ available: entry.navItem ? available.has(entry.navItem) : true,
40
+ priority: entry.priority,
41
+ owner: entry.owner,
42
+ disabled
43
+ };
44
+ };
45
+
46
+ /*
47
+ The site's menus and the places in its theme that can show one.
48
+
49
+ A menu is `{ id, name, locations, items }`, its items in the order they render (highest priority
50
+ first). A location is `{ location, label, declared, menuId, extensionItems, candidates }`:
51
+ `menuId` is the menu shown there (or null), `extensionItems` what extensions put there themselves,
52
+ and `candidates` the plain links already placed there, which can be moved into a menu but are never
53
+ converted on their own. `declared` is false for a place the theme no longer has.
54
+
55
+ `pages` is every page a link can go to, the site's and those its extensions serve under their scopes
56
+ (see findPages.js), as { url, title, owner }.
57
+
58
+ `extensionDirs` overrides where extensions' content is read from, and `extensions` where their pages are.
59
+ */
60
+ export default async ({ rootDir, extensionDirs, extensions }) => {
61
+ if(!rootDir){
62
+ return [{ code: 400, msg: 'Root directory is required' }, null];
63
+ }
64
+
65
+ const [, declared] = await findMenuLocations({ rootDir });
66
+ const { active, disabled } = await readStores(rootDir);
67
+ const dirs = extensionDirs ?? await (await import('../extensions/contentDirs.js')).extensionPublicDirs();
68
+ const fromExtensions = await readExtensionEntries(dirs);
69
+ const navItems = (await findNavItems({ extensionDirs: dirs }))[1];
70
+ const available = new Map(navItems.map(item => [item.key, item]));
71
+
72
+ const menus = active.filter(isMenuDefinition).map(definition => ({
73
+ id: definition.id,
74
+ name: definition.name,
75
+ locations: definition.menuLocations,
76
+ items: [
77
+ ...active.filter(e => isMenuItem(e) && e.menu === definition.id).map(e => toItem(e, false, available)),
78
+ ...disabled.filter(e => isMenuItem(e) && e.menu === definition.id).map(e => toItem(e, true, available))
79
+ ].sort((a, b) => b.priority - a.priority)
80
+ }));
81
+
82
+ const locations = declared.map(({ location, label }) => ({ location, label, declared: true }));
83
+ for(const menu of menus){
84
+ for(const location of menu.locations){
85
+ if(!locations.some(l => l.location === location)) locations.push({ location, label: location, declared: false });
86
+ }
87
+ }
88
+
89
+ for(const place of locations){
90
+ place.menuId = menus.find(m => m.locations.includes(place.location))?.id ?? null;
91
+ place.extensionItems = [
92
+ ...active.filter(e => !e.menu && e.owner !== 'custom' && e.location === place.location),
93
+ ...fromExtensions.filter(e => e.location === place.location)
94
+ ].map(e => toItem(e, false)).sort((a, b) => b.priority - a.priority);
95
+ place.candidates = active
96
+ .filter(e => !e.menu && e.owner === 'custom' && e.location === place.location && parseLink(e.markup))
97
+ .map(e => toItem(e, false));
98
+ }
99
+
100
+ const [, pages] = await findPages({ rootDir, extensions });
101
+
102
+ return [null, { menus, locations, navItems: navItems.map(({ markup, ...item }) => item), pages: pages ?? [] }];
103
+ };
@@ -0,0 +1,55 @@
1
+ const escapeHtml = value => String(value)
2
+ .replace(/&/g, '&amp;')
3
+ .replace(/</g, '&lt;')
4
+ .replace(/>/g, '&gt;')
5
+ .replace(/"/g, '&quot;');
6
+
7
+ const unescapeHtml = value => String(value)
8
+ .replace(/&quot;/g, '"')
9
+ .replace(/&lt;/g, '<')
10
+ .replace(/&gt;/g, '>')
11
+ .replace(/&amp;/g, '&');
12
+
13
+ const UNSAFE_HREF = /^\s*(javascript|data|vbscript):/i;
14
+ const ICON_NAME = /^[\w-]+$/;
15
+
16
+ /*
17
+ A menu item is stored as nothing more than one link, so the entry stays plain global content that
18
+ any template renders without knowing about menus. This reads that link back into its fields, and
19
+ returns null for markup that is anything more than a link with an optional icon.
20
+ */
21
+ export const parseLink = markup => {
22
+ const match = /^<a\s((?:[^>"']|"[^"]*"|'[^']*')*)>([\s\S]*)<\/a>$/.exec(String(markup || '').trim());
23
+ if(!match || /<a[\s>]/.test(match[2])) return null;
24
+
25
+ const attrs = {};
26
+ const attrRegex = /(\w[\w-]*)=(?:"([^"]*)"|'([^']*)')/g;
27
+ let a;
28
+ while((a = attrRegex.exec(match[1])) !== null) attrs[a[1]] = a[2] ?? a[3];
29
+ if(attrs.href === undefined) return null;
30
+
31
+ const icon = /^\s*<k-icon\s+name="([\w-]+)"\s*><\/k-icon>/.exec(match[2]);
32
+ const rest = icon ? match[2].slice(icon[0].length) : match[2];
33
+ if(/</.test(rest)) return null;
34
+
35
+ return {
36
+ label: unescapeHtml(rest.trim()),
37
+ href: unescapeHtml(attrs.href),
38
+ icon: icon ? icon[1] : '',
39
+ newTab: attrs.target === '_blank'
40
+ };
41
+ };
42
+
43
+ /*
44
+ Returns [error, markup], with the same error shape as the rest of the server utils.
45
+ */
46
+ export const buildLink = ({ label, href, icon, newTab }) => {
47
+ if(!String(label || '').trim()) return [{ code: 400, msg: 'Label is required' }, null];
48
+ if(!String(href || '').trim()) return [{ code: 400, msg: 'Link is required' }, null];
49
+ if(UNSAFE_HREF.test(href)) return [{ code: 400, msg: 'That link is not allowed' }, null];
50
+ if(icon && !ICON_NAME.test(icon)) return [{ code: 400, msg: 'Icon names use letters, numbers, dashes and underscores' }, null];
51
+
52
+ const target = newTab ? ' target="_blank" rel="noopener"' : '';
53
+ const iconMarkup = icon ? `<k-icon name="${icon}"></k-icon> ` : '';
54
+ return [null, `<a href="${escapeHtml(href.trim())}"${target}>${iconMarkup}${escapeHtml(label.trim())}</a>`];
55
+ };
@@ -0,0 +1,36 @@
1
+ import { readStores, writeStores, isMenuItem } from './store.js';
2
+ import triggerHook from '../hooks/triggerHook.js';
3
+
4
+ /*
5
+ `ids` are a menu's items in the order they should now render. They take over the priorities those
6
+ same items already held, so the priorities stay in the range they were in. Priorities that were
7
+ equal are separated by one each, because equal ones have no order to keep.
8
+ */
9
+ export default async ({ rootDir, menuId, ids, extensionDirs }) => {
10
+ if(!rootDir){
11
+ return [{ code: 400, msg: 'Root directory is required' }, null];
12
+ }
13
+ if(!menuId || !Array.isArray(ids) || !ids.length){
14
+ return [{ code: 400, msg: 'A menu and the item order are required' }, null];
15
+ }
16
+
17
+ const stores = await readStores(rootDir);
18
+ const items = ids.map(id => [...stores.active, ...stores.disabled].find(e => e.id === id && isMenuItem(e) && e.menu === menuId));
19
+ if(items.includes(undefined) || new Set(ids).size !== ids.length){
20
+ return [{ code: 404, msg: 'Menu item not found' }, null];
21
+ }
22
+
23
+ const slots = items.map(e => e.priority).sort((a, b) => b - a);
24
+ for(let i = 1; i < slots.length; i++) slots[i] = Math.min(slots[i], slots[i - 1] - 1);
25
+
26
+ const now = new Date().toISOString();
27
+ items.forEach((entry, i) => {
28
+ if(entry.priority === slots[i]) return;
29
+ entry.priority = slots[i];
30
+ entry.updatedAt = now;
31
+ });
32
+ await writeStores(rootDir, stores, extensionDirs);
33
+ await triggerHook('globalContent:updated', { id: ids[0], updatedAt: now });
34
+
35
+ return [null, { ids }];
36
+ };
@@ -0,0 +1,54 @@
1
+ import crypto from 'crypto';
2
+ import { readStores, writeStores, menuHome, isMenuDefinition } from './store.js';
3
+ import triggerHook from '../hooks/triggerHook.js';
4
+
5
+ /*
6
+ Creates a menu, or renames one when `id` is given. A new menu is not shown anywhere until it is
7
+ assigned to a location.
8
+ */
9
+ export default async ({ rootDir, id, name, author, extensionDirs }) => {
10
+ if(!rootDir){
11
+ return [{ code: 400, msg: 'Root directory is required' }, null];
12
+ }
13
+ if(!String(name || '').trim()){
14
+ return [{ code: 400, msg: 'Name is required' }, null];
15
+ }
16
+
17
+ const stores = await readStores(rootDir);
18
+ const now = new Date().toISOString();
19
+
20
+ if(id){
21
+ const definition = stores.active.find(e => e.id === id && isMenuDefinition(e));
22
+ if(!definition){
23
+ return [{ code: 404, msg: 'Menu not found' }, null];
24
+ }
25
+ definition.name = name.trim();
26
+ definition.updatedAt = now;
27
+ await writeStores(rootDir, stores, extensionDirs);
28
+ await triggerHook('globalContent:updated', { id, updatedAt: now });
29
+ return [null, { id }];
30
+ }
31
+
32
+ const menuId = crypto.randomUUID();
33
+ stores.active.push({
34
+ id: menuId,
35
+ name: name.trim(),
36
+ owner: 'custom',
37
+ author: author || '',
38
+ locked: false,
39
+ menu: menuId,
40
+ menuLocations: [],
41
+ menuCopyOf: '',
42
+ navItem: '',
43
+ extra: {},
44
+ location: menuHome(menuId),
45
+ priority: 0,
46
+ createdAt: now,
47
+ updatedAt: now,
48
+ markup: ''
49
+ });
50
+ await writeStores(rootDir, stores, extensionDirs);
51
+ await triggerHook('globalContent:created', { id: menuId, name: name.trim(), location: menuHome(menuId) });
52
+
53
+ return [null, { id: menuId }];
54
+ };
@@ -0,0 +1,122 @@
1
+ import crypto from 'crypto';
2
+ import { buildLink } from './markup.js';
3
+ import { readStores, writeStores, menuHome, isMenuItem, isMenuDefinition } from './store.js';
4
+ import findMenuLocations from './findMenuLocations.js';
5
+ import findNavItems from './findNavItems.js';
6
+ import triggerHook from '../hooks/triggerHook.js';
7
+
8
+ /*
9
+ Adds an item to a menu, or changes one when `id` is given. A new item goes to the end of the menu: its
10
+ priority is one below the lowest in that menu (items render highest first).
11
+
12
+ An item is a link (`label`, `href`, ...) or one an extension supplies, named by `navItem`
13
+ (`<extension>:<id>`, from findNavItems), which takes its label and markup from the extension.
14
+
15
+ A new item can name the `menuId` to join, or just the `location` it should show in. For a location,
16
+ it joins the menu shown there; when there is none, one is made, named after the location, and shown
17
+ there. That is what lets an item be added to "Main menu" without making a menu first.
18
+ */
19
+ export default async ({ rootDir, id, menuId, location, navItem, label, href, icon, newTab, author, extensionDirs }) => {
20
+ if(!rootDir){
21
+ return [{ code: 400, msg: 'Root directory is required' }, null];
22
+ }
23
+
24
+ let name;
25
+ let markup;
26
+ if(navItem){
27
+ const supplied = (await findNavItems({ extensionDirs }))[1].find(item => item.key === navItem);
28
+ if(!supplied){
29
+ return [{ code: 404, msg: 'That item is not available. Its extension may be disabled or removed.' }, null];
30
+ }
31
+ name = supplied.label;
32
+ markup = supplied.markup;
33
+ } else {
34
+ const [linkError, linkMarkup] = buildLink({ label, href, icon, newTab });
35
+ if(linkError){
36
+ return [linkError, null];
37
+ }
38
+ name = label.trim();
39
+ markup = linkMarkup;
40
+ }
41
+
42
+ const stores = await readStores(rootDir);
43
+ const now = new Date().toISOString();
44
+
45
+ if(id){
46
+ const entry = [...stores.active, ...stores.disabled].find(e => e.id === id && isMenuItem(e));
47
+ if(!entry){
48
+ return [{ code: 404, msg: 'Menu item not found' }, null];
49
+ }
50
+ if(entry.navItem){
51
+ return [{ code: 400, msg: 'An item from an extension cannot be edited. Delete it and add it again to change it.' }, null];
52
+ }
53
+ if(entry.locked){
54
+ return [{ code: 403, msg: 'This item is locked and cannot be edited' }, null];
55
+ }
56
+ entry.name = name;
57
+ entry.markup = markup;
58
+ entry.updatedAt = now;
59
+ await writeStores(rootDir, stores, extensionDirs);
60
+ await triggerHook('globalContent:updated', { id, updatedAt: now });
61
+ return [null, { id, updatedAt: now }];
62
+ }
63
+
64
+ let target = menuId
65
+ ? stores.active.find(e => e.id === menuId && isMenuDefinition(e))
66
+ : stores.active.find(e => isMenuDefinition(e) && e.menuLocations.includes(location));
67
+
68
+ if(!target && !menuId && location){
69
+ const [, declared] = await findMenuLocations({ rootDir });
70
+ const place = declared.find(l => l.location === location);
71
+ if(!place){
72
+ return [{ code: 400, msg: 'That place is not a menu in the theme' }, null];
73
+ }
74
+ const newId = crypto.randomUUID();
75
+ target = {
76
+ id: newId,
77
+ name: place.label,
78
+ owner: 'custom',
79
+ author: author || '',
80
+ locked: false,
81
+ menu: newId,
82
+ menuLocations: [location],
83
+ menuCopyOf: '',
84
+ navItem: '',
85
+ extra: {},
86
+ location: menuHome(newId),
87
+ priority: 0,
88
+ createdAt: now,
89
+ updatedAt: now,
90
+ markup: ''
91
+ };
92
+ stores.active.push(target);
93
+ }
94
+
95
+ if(!target){
96
+ return [{ code: 404, msg: 'Menu not found' }, null];
97
+ }
98
+
99
+ const siblings = [...stores.active, ...stores.disabled].filter(e => isMenuItem(e) && e.menu === target.id);
100
+ const entry = {
101
+ id: crypto.randomUUID(),
102
+ name,
103
+ owner: 'custom',
104
+ author: author || '',
105
+ locked: false,
106
+ menu: target.id,
107
+ menuLocations: null,
108
+ menuCopyOf: '',
109
+ navItem: navItem || '',
110
+ extra: {},
111
+ location: menuHome(target.id),
112
+ priority: siblings.length ? Math.min(...siblings.map(e => e.priority)) - 1 : 100,
113
+ createdAt: now,
114
+ updatedAt: now,
115
+ markup
116
+ };
117
+ stores.active.push(entry);
118
+ await writeStores(rootDir, stores, extensionDirs);
119
+ await triggerHook('globalContent:created', { id: entry.id, name: entry.name, location: entry.location });
120
+
121
+ return [null, { id: entry.id, menuId: target.id, priority: entry.priority }];
122
+ };
@@ -0,0 +1,31 @@
1
+ import { readStores, writeStores, isMenuItem } from './store.js';
2
+ import triggerHook from '../hooks/triggerHook.js';
3
+
4
+ /*
5
+ Hides an item from its menu without deleting it, or shows it again.
6
+ */
7
+ export default async ({ rootDir, id, enabled, extensionDirs }) => {
8
+ if(!rootDir){
9
+ return [{ code: 400, msg: 'Root directory is required' }, null];
10
+ }
11
+ if(!id || typeof enabled !== 'boolean'){
12
+ return [{ code: 400, msg: 'An item and whether it is enabled are required' }, null];
13
+ }
14
+
15
+ const stores = await readStores(rootDir);
16
+ const [from, to] = enabled ? [stores.disabled, stores.active] : [stores.active, stores.disabled];
17
+ const index = from.findIndex(e => e.id === id && isMenuItem(e));
18
+ if(index === -1){
19
+ const already = to.some(e => e.id === id && isMenuItem(e));
20
+ return already ? [null, { id, enabled }] : [{ code: 404, msg: 'Menu item not found' }, null];
21
+ }
22
+ if(from[index].locked){
23
+ return [{ code: 403, msg: 'A locked item cannot be hidden' }, null];
24
+ }
25
+
26
+ to.push(...from.splice(index, 1));
27
+ await writeStores(rootDir, stores, extensionDirs);
28
+ await triggerHook('globalContent:updated', { id, updatedAt: new Date().toISOString() });
29
+
30
+ return [null, { id, enabled }];
31
+ };