domma-cms 0.44.0 → 0.45.1

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 (254) hide show
  1. package/CLAUDE.md +53 -53
  2. package/README.md +27 -27
  3. package/admin/css/admin.css +1 -1
  4. package/admin/dist/domma/domma-tools.css +3 -3
  5. package/admin/dist/domma/domma-tools.min.js +3 -3
  6. package/admin/index.html +2 -2
  7. package/admin/js/app.js +1 -1
  8. package/admin/js/lib/card-builder.js +2 -2
  9. package/admin/js/lib/crud-tutorial.js +1 -1
  10. package/admin/js/lib/effect-defs.js +1 -1
  11. package/admin/js/lib/effects-builder.js +1 -1
  12. package/admin/js/lib/image-editor.js +1 -1
  13. package/admin/js/lib/markdown-toolbar.js +2 -2
  14. package/admin/js/lib/project-context.js +1 -1
  15. package/admin/js/lib/project-quick-create.js +1 -1
  16. package/admin/js/lib/scribe-composer.js +1 -1
  17. package/admin/js/lib/server-restart.js +1 -1
  18. package/admin/js/lib/shortcode-context-menu.js +2 -2
  19. package/admin/js/lib/simple-editor.js +1 -1
  20. package/admin/js/lib/themes.js +1 -1
  21. package/admin/js/lib/timeline-builder.js +1 -1
  22. package/admin/js/templates/action-editor.html +8 -8
  23. package/admin/js/templates/api-endpoint-editor.html +7 -7
  24. package/admin/js/templates/api-endpoints.html +1 -1
  25. package/admin/js/templates/api-reference.html +1 -1
  26. package/admin/js/templates/block-editor.html +6 -6
  27. package/admin/js/templates/collection-editor.html +1 -1
  28. package/admin/js/templates/component-editor.html +2 -2
  29. package/admin/js/templates/dashboard/cache.html +3 -3
  30. package/admin/js/templates/dashboard/journeys.html +3 -3
  31. package/admin/js/templates/dashboard/kpi-strip.html +3 -3
  32. package/admin/js/templates/dashboard/top-pages.html +1 -1
  33. package/admin/js/templates/dashboard/traffic-chart.html +1 -1
  34. package/admin/js/templates/docs/api-actions.html +1 -1
  35. package/admin/js/templates/docs/api-collections.html +4 -4
  36. package/admin/js/templates/docs/api-pages.html +2 -2
  37. package/admin/js/templates/docs/api-plugins.html +1 -1
  38. package/admin/js/templates/docs/api-settings.html +2 -2
  39. package/admin/js/templates/docs/api-users.html +1 -1
  40. package/admin/js/templates/docs/components-howto.html +4 -4
  41. package/admin/js/templates/docs/components-reference.html +21 -21
  42. package/admin/js/templates/docs/components-rules.html +14 -14
  43. package/admin/js/templates/docs/components-walkthrough.html +13 -13
  44. package/admin/js/templates/docs/tutorial-crud.html +49 -49
  45. package/admin/js/templates/docs/tutorial-forms.html +4 -4
  46. package/admin/js/templates/docs/tutorial-plugin.html +10 -10
  47. package/admin/js/templates/docs/usage-actions.html +6 -6
  48. package/admin/js/templates/docs/usage-cta-shortcode.html +9 -9
  49. package/admin/js/templates/docs/usage-dconfig.html +4 -4
  50. package/admin/js/templates/docs/usage-navigation.html +1 -1
  51. package/admin/js/templates/docs/usage-pages.html +1 -1
  52. package/admin/js/templates/docs/usage-shortcodes.html +24 -24
  53. package/admin/js/templates/docs/usage-users-roles.html +3 -3
  54. package/admin/js/templates/docs/usage-views.html +4 -4
  55. package/admin/js/templates/documentation.html +1 -1
  56. package/admin/js/templates/effects.html +752 -752
  57. package/admin/js/templates/form-editor.html +5 -5
  58. package/admin/js/templates/forms.html +17 -17
  59. package/admin/js/templates/login.html +5 -5
  60. package/admin/js/templates/menu-editor.html +10 -10
  61. package/admin/js/templates/my-profile.html +17 -17
  62. package/admin/js/templates/navigation.html +11 -11
  63. package/admin/js/templates/notifications.html +1 -1
  64. package/admin/js/templates/page-editor.html +4 -4
  65. package/admin/js/templates/pro-docs.html +14 -14
  66. package/admin/js/templates/role-editor.html +70 -70
  67. package/admin/js/templates/roles.html +10 -10
  68. package/admin/js/templates/settings.html +10 -10
  69. package/admin/js/templates/tutorials.html +3 -3
  70. package/admin/js/templates/view-editor.html +15 -15
  71. package/admin/js/views/action-editor.js +1 -1
  72. package/admin/js/views/actions-list.js +1 -1
  73. package/admin/js/views/api-endpoint-editor.js +2 -2
  74. package/admin/js/views/api-endpoints.js +2 -2
  75. package/admin/js/views/api-tokens.js +3 -3
  76. package/admin/js/views/blocks.js +2 -2
  77. package/admin/js/views/collection-editor.js +1 -1
  78. package/admin/js/views/collection-entries.js +1 -1
  79. package/admin/js/views/collections.js +1 -1
  80. package/admin/js/views/component-editor.js +1 -1
  81. package/admin/js/views/components.js +4 -4
  82. package/admin/js/views/dashboard/widgets/cache.js +1 -1
  83. package/admin/js/views/dashboard/widgets/journeys.js +1 -1
  84. package/admin/js/views/dashboard/widgets/kpi-strip.js +1 -1
  85. package/admin/js/views/form-editor.js +7 -7
  86. package/admin/js/views/form-submissions.js +1 -1
  87. package/admin/js/views/forms.js +1 -1
  88. package/admin/js/views/layouts.js +1 -1
  89. package/admin/js/views/login.js +2 -2
  90. package/admin/js/views/menu-editor.js +3 -3
  91. package/admin/js/views/menu-locations.js +1 -1
  92. package/admin/js/views/menus.js +3 -3
  93. package/admin/js/views/navigation.js +8 -8
  94. package/admin/js/views/page-editor.js +40 -40
  95. package/admin/js/views/pages.js +3 -3
  96. package/admin/js/views/plugin-code.js +2 -2
  97. package/admin/js/views/plugins.js +1 -1
  98. package/admin/js/views/project-settings.js +1 -1
  99. package/admin/js/views/projects.js +1 -1
  100. package/admin/js/views/roles.js +1 -1
  101. package/admin/js/views/user-editor.js +1 -1
  102. package/admin/js/views/users.js +3 -3
  103. package/admin/js/views/view-editor.js +1 -1
  104. package/admin/js/views/view-preview.js +1 -1
  105. package/admin/js/views/views-list.js +1 -1
  106. package/bin/cli.js +8 -8
  107. package/bin/lib/config-merge.js +44 -44
  108. package/bin/update.js +16 -16
  109. package/config/plugins.json +1 -1
  110. package/package.json +2 -2
  111. package/plugins/_template/admin/views/index.js +1 -1
  112. package/plugins/_template/config.js +1 -1
  113. package/plugins/_template/plugin.js +1 -1
  114. package/plugins/analytics/admin/templates/analytics.html +3 -3
  115. package/plugins/analytics/admin/views/analytics.js +1 -1
  116. package/plugins/analytics/config.js +1 -1
  117. package/plugins/analytics/plugin.js +4 -4
  118. package/plugins/analytics/plugin.json +1 -1
  119. package/plugins/analytics/public/inject-body.html +2 -2
  120. package/plugins/analytics/public/inject-head.html +1 -1
  121. package/plugins/blog/admin/templates/blog.html +4 -4
  122. package/plugins/blog/admin/views/blog.js +3 -3
  123. package/plugins/blog/admin/views/categories.js +2 -2
  124. package/plugins/blog/admin/views/comments.js +4 -4
  125. package/plugins/blog/admin/views/post-editor.js +1 -1
  126. package/plugins/blog/plugin.js +2 -2
  127. package/plugins/blog/plugin.json +4 -4
  128. package/plugins/contacts/admin/views/contacts.js +8 -8
  129. package/plugins/contacts/plugin.js +4 -4
  130. package/plugins/contacts/plugin.json +1 -1
  131. package/plugins/invoice/admin/templates/editor.html +6 -6
  132. package/plugins/invoice/admin/templates/index.html +4 -4
  133. package/plugins/invoice/admin/views/editor.js +3 -3
  134. package/plugins/invoice/admin/views/index.js +4 -4
  135. package/plugins/invoice/admin/views/issuers.js +1 -1
  136. package/plugins/invoice/admin/views/party-view.js +3 -3
  137. package/plugins/invoice/admin/views/receivers.js +1 -1
  138. package/plugins/invoice/config.js +1 -1
  139. package/plugins/invoice/plugin.js +1 -1
  140. package/plugins/invoice/plugin.json +4 -4
  141. package/plugins/notes/admin/views/notes.js +4 -4
  142. package/plugins/notes/plugin.js +1 -1
  143. package/plugins/notes/plugin.json +1 -1
  144. package/plugins/site-search/admin/templates/site-search.html +3 -3
  145. package/plugins/site-search/admin/views/site-search.js +1 -1
  146. package/plugins/site-search/config.js +1 -1
  147. package/plugins/site-search/plugin.js +4 -4
  148. package/plugins/site-search/plugin.json +1 -1
  149. package/plugins/surveys/admin/templates/results.html +1 -1
  150. package/plugins/surveys/admin/templates/survey-editor.html +4 -4
  151. package/plugins/surveys/admin/views/audience.js +6 -6
  152. package/plugins/surveys/admin/views/results.js +3 -3
  153. package/plugins/surveys/admin/views/survey-editor.js +1 -1
  154. package/plugins/surveys/admin/views/surveys.js +2 -2
  155. package/plugins/surveys/plugin.js +1 -1
  156. package/plugins/surveys/plugin.json +4 -4
  157. package/plugins/theme-switcher/admin/views/theme-switcher.js +1 -1
  158. package/plugins/theme-switcher/plugin.json +3 -3
  159. package/plugins/theme-switcher/public/inject-body.html +28 -28
  160. package/plugins/theme-switcher/public/inject-head.html +1 -1
  161. package/plugins/todo/plugin.js +1 -1
  162. package/public/css/forms.css +1 -1
  163. package/public/js/collection-browser.js +1 -1
  164. package/public/js/collection-context.js +1 -1
  165. package/public/js/form-logic-engine.js +1 -1
  166. package/public/js/forms.js +2 -1
  167. package/public/js/site.js +1 -1
  168. package/scripts/build.js +14 -14
  169. package/scripts/create-plugin.js +2 -2
  170. package/scripts/fresh.js +2 -2
  171. package/scripts/gen-instance-secret.js +1 -1
  172. package/scripts/pro.js +12 -12
  173. package/scripts/reset.js +2 -2
  174. package/scripts/setup.js +15 -15
  175. package/scripts/users.js +5 -5
  176. package/scripts/verify-assets.mjs +6 -6
  177. package/server/config.js +1 -1
  178. package/server/middleware/auth.js +253 -253
  179. package/server/middleware/managerAuth.js +3 -3
  180. package/server/routes/api/actions.js +4 -4
  181. package/server/routes/api/api-endpoints.js +7 -7
  182. package/server/routes/api/api-tokens.js +5 -5
  183. package/server/routes/api/auth.js +309 -309
  184. package/server/routes/api/blocks.js +3 -3
  185. package/server/routes/api/collections.js +18 -18
  186. package/server/routes/api/dashboard.js +5 -5
  187. package/server/routes/api/effects.js +2 -2
  188. package/server/routes/api/endpoints-public.js +5 -5
  189. package/server/routes/api/forms.js +47 -47
  190. package/server/routes/api/menu-locations.js +3 -3
  191. package/server/routes/api/menus.js +7 -7
  192. package/server/routes/api/navigation.js +42 -42
  193. package/server/routes/api/notifications.js +2 -2
  194. package/server/routes/api/pages.js +14 -2
  195. package/server/routes/api/plugin-marketplace.js +3 -3
  196. package/server/routes/api/plugins.js +6 -6
  197. package/server/routes/api/projects.js +9 -9
  198. package/server/routes/api/scaffold.js +7 -7
  199. package/server/routes/api/settings.js +6 -6
  200. package/server/routes/api/sidebar.js +1 -1
  201. package/server/routes/api/users.js +5 -5
  202. package/server/routes/api/versions.js +1 -1
  203. package/server/routes/api/views.js +1 -1
  204. package/server/routes/docs-public.js +3 -3
  205. package/server/routes/public.js +14 -14
  206. package/server/server.js +34 -16
  207. package/server/services/actions.js +19 -19
  208. package/server/services/adapters/FileAdapter.js +2 -2
  209. package/server/services/adapters/MongoAdapter.js +5 -5
  210. package/server/services/apiEndpoints.js +15 -15
  211. package/server/services/apiTokens.js +7 -7
  212. package/server/services/blocks.js +13 -13
  213. package/server/services/cache/drivers/MemoryDriver.js +3 -3
  214. package/server/services/cache/drivers/NoneDriver.js +1 -1
  215. package/server/services/cache/index.js +5 -5
  216. package/server/services/collections.js +14 -14
  217. package/server/services/components.js +9 -9
  218. package/server/services/connectionManager.js +4 -4
  219. package/server/services/content.js +25 -2
  220. package/server/services/docs.js +14 -14
  221. package/server/services/email.js +167 -167
  222. package/server/services/filterEngine.js +6 -6
  223. package/server/services/forms.js +18 -10
  224. package/server/services/health.js +1 -1
  225. package/server/services/hooks.js +7 -7
  226. package/server/services/images.js +4 -4
  227. package/server/services/managerClient.js +1 -1
  228. package/server/services/markdown.js +79 -79
  229. package/server/services/menuRender.js +5 -5
  230. package/server/services/menus-migration.js +3 -3
  231. package/server/services/menus.js +31 -31
  232. package/server/services/permissionRegistry.js +1 -1
  233. package/server/services/pluginFiles.js +5 -5
  234. package/server/services/pluginInstaller.js +17 -17
  235. package/server/services/pluginScaffold.js +1 -1
  236. package/server/services/plugins.js +17 -17
  237. package/server/services/presetCollections.js +2 -2
  238. package/server/services/projects.js +35 -26
  239. package/server/services/recipes/contact-list.json +1 -1
  240. package/server/services/recipes/onboarding.json +8 -8
  241. package/server/services/references.js +8 -8
  242. package/server/services/renderer.js +9 -9
  243. package/server/services/roles.js +9 -9
  244. package/server/services/rowAccess.js +10 -10
  245. package/server/services/scaffolder.js +30 -30
  246. package/server/services/sidebar-migration.js +10 -10
  247. package/server/services/sitemap.js +1 -1
  248. package/server/services/userProfiles.js +199 -199
  249. package/server/services/userRoles.js +7 -7
  250. package/server/services/users.js +302 -302
  251. package/server/services/versions.js +3 -3
  252. package/server/services/viewPipeline.js +6 -6
  253. package/server/services/views.js +11 -11
  254. package/server/templates/page.html +5 -5
@@ -4,15 +4,15 @@
4
4
  * One builder, three callers: the `[menu]` shortcode, the overlay menus the
5
5
  * renderer injects into the page shell, and anything a plugin wants to render
6
6
  * itself. Keeping it here means a menu looks the same however it reaches the
7
- * page — the shortcode and an overlay binding of the same menu produce byte-
7
+ * page - the shortcode and an overlay binding of the same menu produce byte-
8
8
  * identical markup.
9
9
  *
10
10
  * Item filtering (visibility, `hidden`), depth capping and badge-count
11
- * resolution happen before this — callers hand in the items they want drawn.
11
+ * resolution happen before this - callers hand in the items they want drawn.
12
12
  */
13
13
  import {colourToCss, floatToCss} from './menus.js';
14
14
 
15
- /** Attribute-safe escaping — mirrors escapeAttr in markdown.js. */
15
+ /** Attribute-safe escaping - mirrors escapeAttr in markdown.js. */
16
16
  function esc(str) {
17
17
  return String(str ?? '')
18
18
  .replace(/&/g, '&')
@@ -43,7 +43,7 @@ export function renderMenuItemsAsUl(items) {
43
43
  const colourCss = it.colour ? colourToCss(it.colour) : '';
44
44
  const styleAttr = colourCss ? ` style="color:${colourCss}"` : '';
45
45
 
46
- // Badge — static text, or a count stamped on by resolveMenuDecorations.
46
+ // Badge - static text, or a count stamped on by resolveMenuDecorations.
47
47
  let badge = '';
48
48
  if (it.badge && it.badge.text != null && it.badge.text !== '') {
49
49
  const bg = it.badge.variant ? colourToCss(it.badge.variant) : '';
@@ -60,7 +60,7 @@ export function renderMenuItemsAsUl(items) {
60
60
  * Build the full `<nav>` for a menu.
61
61
  *
62
62
  * @param {object} menu The menu record (slug, orientation, position, float)
63
- * @param {Array} items Items to draw — already filtered/capped/decorated
63
+ * @param {Array} items Items to draw - already filtered/capped/decorated
64
64
  * @param {object} [opts]
65
65
  * @param {string} [opts.orientation] Override the menu's own orientation for this placement
66
66
  * @param {string} [opts.variant] Adds `dm-menu--variant-<x>`
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Menus auto-migration.
3
3
  *
4
- * Runs once on server boot. Idempotent — guarded by the existence of
4
+ * Runs once on server boot. Idempotent - guarded by the existence of
5
5
  * config/menu-locations.json. Converts:
6
6
  * - config/navigation.json → config/menus/main.json + locations.navbar
7
7
  * (brand moves into site.json.brand)
@@ -28,7 +28,7 @@ async function readJson(p) {
28
28
 
29
29
  async function writeJson(p, data) {
30
30
  await fs.writeFile(p, JSON.stringify(data, null, 2) + '\n', 'utf8');
31
- // Menu files written outside the menus service — drop its cached index so
31
+ // Menu files written outside the menus service - drop its cached index so
32
32
  // binding resolution sees the migrated menus.
33
33
  invalidateMenuIndex();
34
34
  }
@@ -104,7 +104,7 @@ export async function runMigration(opts = {}) {
104
104
  }
105
105
  }
106
106
 
107
- // 4. Write the locations map (even if empty — its existence is the migration guard)
107
+ // 4. Write the locations map (even if empty - its existence is the migration guard)
108
108
  await writeJson(path.join(configDir, 'menu-locations.json'), locations);
109
109
 
110
110
  return {migrated: true};
@@ -18,13 +18,13 @@ import * as cache from './cache/index.js';
18
18
  /**
19
19
  * Bust the response-cache tags that menu changes affect. Public pages are
20
20
  * cached with the `nav` and `site` tags (see routes/public.js), so any menu
21
- * mutation — content, behaviour/style overrides, or slot mapping — must clear
21
+ * mutation - content, behaviour/style overrides, or slot mapping - must clear
22
22
  * them or the navbar/footer/[menu] output stays stale until TTL or restart.
23
23
  * No-op when the cache is disabled (e.g. dev / boot-time seeding).
24
24
  */
25
25
  async function invalidateNavCache() {
26
26
  try { await cache.invalidateTags(['nav', 'site']); }
27
- catch { /* cache not ready (boot) — safe to ignore */ }
27
+ catch { /* cache not ready (boot) - safe to ignore */ }
28
28
  }
29
29
 
30
30
  const __filename = fileURLToPath(import.meta.url);
@@ -52,7 +52,7 @@ export function validateSlug(slug) {
52
52
  }
53
53
 
54
54
  // ---------------------------------------------------------------------------
55
- // Decoration colour convention — preset key → theme CSS var, or #rrggbb hex.
55
+ // Decoration colour convention - preset key → theme CSS var, or #rrggbb hex.
56
56
  // A "colour value" is one of these preset keys or a 6-digit hex.
57
57
  // Keep PRESET_COLOUR_VARS in sync with the copy in public/js/menu-decor.mjs.
58
58
  // ---------------------------------------------------------------------------
@@ -82,7 +82,7 @@ export function colourToCss(value) {
82
82
  }
83
83
 
84
84
  // ---------------------------------------------------------------------------
85
- // Floating menus — a menu with position "floating" detaches from the document
85
+ // Floating menus - a menu with position "floating" detaches from the document
86
86
  // flow and pins itself to a viewport corner or edge, the same way the
87
87
  // breadcrumb trail does. The anchor/offset shape deliberately mirrors
88
88
  // site.json's `breadcrumbs` config so the two read alike in the admin.
@@ -112,7 +112,7 @@ function floatNumber(value, fallback, min, max) {
112
112
  }
113
113
 
114
114
  /**
115
- * Surface declarations for a floating panel — corner rounding, drop shadow and
115
+ * Surface declarations for a floating panel - corner rounding, drop shadow and
116
116
  * accent colour. Everything is optional: an omitted field falls through to the
117
117
  * `.dm-menu-floating` defaults in site.css rather than being frozen inline.
118
118
  *
@@ -135,7 +135,7 @@ function floatSurfaceCss(f) {
135
135
  decls.push(`--dm-menu-accent:${css}`, `border-color:${css}`);
136
136
  }
137
137
  if (f.opacity != null && f.opacity !== '') {
138
- // Panel translucency, not item translucency — the backdrop filter in
138
+ // Panel translucency, not item translucency - the backdrop filter in
139
139
  // site.css keeps the text readable over busy page content.
140
140
  decls.push(`--dm-menu-panel-opacity:${floatNumber(f.opacity, 100, 20, 100) / 100}`);
141
141
  }
@@ -176,7 +176,7 @@ export function floatToCss(menu) {
176
176
  decls.push(`width:${floatNumber(f.width, 260, 120, 600)}px`);
177
177
  }
178
178
 
179
- // A pinned panel must never grow past the viewport — it scrolls instead.
179
+ // A pinned panel must never grow past the viewport - it scrolls instead.
180
180
  decls.push(`max-height:calc(100vh - ${(anchor === 'L' || anchor === 'R') ? 32 : y * 2}px)`);
181
181
 
182
182
  decls.push(...floatSurfaceCss(f));
@@ -185,19 +185,19 @@ export function floatToCss(menu) {
185
185
  }
186
186
 
187
187
  // ---------------------------------------------------------------------------
188
- // Orientation — how the menu flows where it renders. `horizontal` is the
188
+ // Orientation - how the menu flows where it renders. `horizontal` is the
189
189
  // historic behaviour and stays the default for every menu without the field.
190
190
  // `vertical` stacks the items into a column: a side rail for the navbar slot,
191
191
  // a stacked list for `[menu]` and floating panels.
192
192
  //
193
- // `side` only means anything for a vertical menu in the navbar slot — it picks
193
+ // `side` only means anything for a vertical menu in the navbar slot - it picks
194
194
  // the viewport edge the rail docks to.
195
195
  // ---------------------------------------------------------------------------
196
196
  export const ORIENTATIONS = new Set(['horizontal', 'vertical']);
197
197
  export const SIDES = new Set(['left', 'right']);
198
198
 
199
199
  // ---------------------------------------------------------------------------
200
- // Bindings — a menu can claim a slot for a subset of the site instead of being
200
+ // Bindings - a menu can claim a slot for a subset of the site instead of being
201
201
  // mapped to it globally in menu-locations.json. `binding.slot` names the slot
202
202
  // it takes over; `binding.projects` and `binding.pages` say where.
203
203
  //
@@ -300,12 +300,12 @@ async function writeMenuFile(menu) {
300
300
  await ensureMenusDir();
301
301
  await fs.writeFile(menuPath(menu.slug), JSON.stringify(menu, null, 2) + '\n', 'utf8');
302
302
  invalidateMenuIndex();
303
- // Menu content / behaviour / style changed — drop the cached public pages.
303
+ // Menu content / behaviour / style changed - drop the cached public pages.
304
304
  await invalidateNavCache();
305
305
  }
306
306
 
307
307
  /**
308
- * List all menus. Returns metadata only (no items) — use getMenu(slug) for
308
+ * List all menus. Returns metadata only (no items) - use getMenu(slug) for
309
309
  * the full payload. Sorted alphabetically by slug.
310
310
  *
311
311
  * @returns {Promise<Array<{slug:string,name:string,description:string,itemsCount:number,meta:object}>>}
@@ -368,7 +368,7 @@ export async function createMenu(input) {
368
368
  throw new Error(`Menu "${input.slug}" already exists`);
369
369
  }
370
370
  // Normalise the binding first so validation judges what will actually be
371
- // written — surrounding whitespace and duplicates are tidied, not rejected.
371
+ // written - surrounding whitespace and duplicates are tidied, not rejected.
372
372
  const binding = normaliseBinding(input.binding);
373
373
  const errors = validateMenu({...input, binding});
374
374
  if (errors.length) {
@@ -444,7 +444,7 @@ export async function updateMenu(slug, input) {
444
444
 
445
445
  /**
446
446
  * Delete a menu file. Returns true if removed, false if it didn't exist.
447
- * Callers must check the locations map first — service does not.
447
+ * Callers must check the locations map first - service does not.
448
448
  *
449
449
  * @param {string} slug
450
450
  * @returns {Promise<boolean>}
@@ -531,30 +531,30 @@ export function validateMenu(menu) {
531
531
  const errors = [];
532
532
  if (!menu || typeof menu !== 'object') return ['Menu must be an object'];
533
533
  if (!validateSlug(menu.slug)) {
534
- errors.push(`Invalid slug "${menu.slug}" — lowercase alphanumeric + hyphen only`);
534
+ errors.push(`Invalid slug "${menu.slug}" - lowercase alphanumeric + hyphen only`);
535
535
  }
536
536
  if (!menu.name || typeof menu.name !== 'string') {
537
537
  errors.push('name is required');
538
538
  }
539
539
  if (menu.position != null && !POSITIONS.has(menu.position)) {
540
- errors.push(`Invalid position "${menu.position}" — expected one of: ${[...POSITIONS].join(', ')}`);
540
+ errors.push(`Invalid position "${menu.position}" - expected one of: ${[...POSITIONS].join(', ')}`);
541
541
  }
542
542
  if (menu.orientation != null && !ORIENTATIONS.has(menu.orientation)) {
543
- errors.push(`Invalid orientation "${menu.orientation}" — expected one of: ${[...ORIENTATIONS].join(', ')}`);
543
+ errors.push(`Invalid orientation "${menu.orientation}" - expected one of: ${[...ORIENTATIONS].join(', ')}`);
544
544
  }
545
545
  if (menu.side != null && !SIDES.has(menu.side)) {
546
- errors.push(`Invalid side "${menu.side}" — expected one of: ${[...SIDES].join(', ')}`);
546
+ errors.push(`Invalid side "${menu.side}" - expected one of: ${[...SIDES].join(', ')}`);
547
547
  }
548
548
  if (menu.binding != null) {
549
549
  if (typeof menu.binding !== 'object' || Array.isArray(menu.binding)) {
550
550
  errors.push('binding must be an object');
551
551
  } else {
552
552
  const {slot, projects, pages} = menu.binding;
553
- // A binding with no slot is inert rather than invalid — the admin
554
- // sends an empty block when the feature is switched off — but a
553
+ // A binding with no slot is inert rather than invalid - the admin
554
+ // sends an empty block when the feature is switched off - but a
555
555
  // slot that IS present has to be a usable slot name.
556
556
  if (slot != null && slot !== '' && !validateSlug(slot)) {
557
- errors.push(`binding.slot "${slot}" — lowercase alphanumeric + hyphen only`);
557
+ errors.push(`binding.slot "${slot}" - lowercase alphanumeric + hyphen only`);
558
558
  }
559
559
  for (const [key, list] of [['projects', projects], ['pages', pages]]) {
560
560
  if (list == null) continue;
@@ -597,7 +597,7 @@ export function validateMenu(menu) {
597
597
  errors.push('items must be an array');
598
598
  } else {
599
599
  walkItems(menu.items, (item, path) => {
600
- // Separator items are purely visual dividers — no text/url required.
600
+ // Separator items are purely visual dividers - no text/url required.
601
601
  // Renderers translate them into <hr> / structural breaks.
602
602
  if (item && item.type === 'separator') return;
603
603
 
@@ -605,7 +605,7 @@ export function validateMenu(menu) {
605
605
  errors.push(`Item ${path}: text is required`);
606
606
  }
607
607
  // url is only required for LEAF items (no children). Folder items
608
- // — those with their own `items` array — are pure groupings and
608
+ // - those with their own `items` array - are pure groupings and
609
609
  // don't need a navigation target. The admin sidebar uses this
610
610
  // shape extensively (Content / Data / System headings etc.).
611
611
  const hasChildren = Array.isArray(item.items) && item.items.length > 0;
@@ -671,7 +671,7 @@ function walkItems(items, fn, parentPath = '') {
671
671
  }
672
672
 
673
673
  // ---------------------------------------------------------------------------
674
- // Locations registry — module-level Map, seeded by core, extended by plugins
674
+ // Locations registry - module-level Map, seeded by core, extended by plugins
675
675
  // ---------------------------------------------------------------------------
676
676
 
677
677
  const _locationRegistry = new Map(); // slot -> { label, description, maxDepth, source }
@@ -687,7 +687,7 @@ const _locationRegistry = new Map(); // slot -> { label, description, maxDepth,
687
687
  export function registerLocation(slot, def) {
688
688
  if (!validateSlug(slot)) throw new Error(`Invalid slot name: ${slot}`);
689
689
  if (_locationRegistry.has(slot)) {
690
- console.warn(`[menus] Slot "${slot}" re-registered — last write wins`);
690
+ console.warn(`[menus] Slot "${slot}" re-registered - last write wins`);
691
691
  }
692
692
  _locationRegistry.set(slot, {
693
693
  label: def?.label || slot,
@@ -704,7 +704,7 @@ export function getLocationRegistry() {
704
704
  return [..._locationRegistry.entries()].map(([slot, def]) => ({slot, ...def}));
705
705
  }
706
706
 
707
- // Core slots — registered at module load. Plugins register theirs from
707
+ // Core slots - registered at module load. Plugins register theirs from
708
708
  // their plugin.js setup function via the registerMenuLocation hook (Task 7).
709
709
  registerLocation('navbar', {label: 'Navbar', description: 'Site navbar', source: 'core'});
710
710
  registerLocation('footer-primary', {label: 'Footer primary', description: 'Primary footer column', source: 'core', maxDepth: 1});
@@ -713,7 +713,7 @@ registerLocation('admin-sidebar', {label: 'Admin sidebar', description: 'The l
713
713
  // The overlay slot is the "anywhere" placement: menus bound to it render on
714
714
  // matching pages positioned by their own `float` config, with no navbar, no
715
715
  // footer and no shortcode involved. Unlike the other slots it is not
716
- // one-menu-wins — a page can carry several overlays at once.
716
+ // one-menu-wins - a page can carry several overlays at once.
717
717
  registerLocation('overlay', {label: 'Page overlay', description: 'Floating menus placed on matching pages by their binding', source: 'core', maxDepth: null});
718
718
 
719
719
  // ---------------------------------------------------------------------------
@@ -768,8 +768,8 @@ export async function setLocations(map) {
768
768
 
769
769
  /**
770
770
  * Every menu on disk, cached in memory. Bound-menu resolution has to consider
771
- * all of them on each render, and the alternative — re-reading the directory
772
- * three times per uncached page — is wasteful for a set of files that only
771
+ * all of them on each render, and the alternative - re-reading the directory
772
+ * three times per uncached page - is wasteful for a set of files that only
773
773
  * changes when an admin saves a menu.
774
774
  *
775
775
  * Every write path in this module invalidates the cache; anything writing menu
@@ -777,7 +777,7 @@ export async function setLocations(map) {
777
777
  */
778
778
  let _menuIndex = null;
779
779
 
780
- /** Drop the cached menu index — call after writing menu files out-of-band. */
780
+ /** Drop the cached menu index - call after writing menu files out-of-band. */
781
781
  export function invalidateMenuIndex() {
782
782
  _menuIndex = null;
783
783
  }
@@ -880,7 +880,7 @@ export async function resolveBoundMenu(slot, ctx = {}) {
880
880
  *
881
881
  * A menu bound to the current page or project (see `resolveBoundMenu`) takes
882
882
  * the slot over; otherwise the `menu-locations.json` mapping applies. Callers
883
- * that know the page they are rendering should pass `ctx` — without it only the
883
+ * that know the page they are rendering should pass `ctx` - without it only the
884
884
  * global mapping is considered, which is the pre-binding behaviour.
885
885
  *
886
886
  * Note: `user` may be null (anonymous). `user.role` and
@@ -242,7 +242,7 @@ const _pluginContributed = [];
242
242
 
243
243
  /**
244
244
  * Register a resource contributed by a plugin.
245
- * Idempotent — if the key already exists in the effective registry it is skipped.
245
+ * Idempotent - if the key already exists in the effective registry it is skipped.
246
246
  *
247
247
  * @param {{key:string,label:string,description?:string,icon?:string,group?:string,actions?:{key:string,label:string,description?:string}[]}} resource
248
248
  * @param {string} plugin - Plugin name that owns this resource
@@ -2,7 +2,7 @@
2
2
  * Plugin Files Service
3
3
  * Read/write access to a plugin's source files for the in-admin code editor.
4
4
  *
5
- * Every operation is confined to plugins/<name>/ — names must be discovered
5
+ * Every operation is confined to plugins/<name>/ - names must be discovered
6
6
  * plugin directories (underscore-prefixed dirs like _template/_lib are never
7
7
  * valid names), paths are normalised and containment-checked after symlink
8
8
  * resolution, and only known text extensions are editable.
@@ -20,7 +20,7 @@ const NAME_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
20
20
  // Editable/viewable file types. Everything else is invisible to the editor.
21
21
  const TEXT_EXTENSIONS = new Set(['.js', '.json', '.md', '.html', '.css', '.txt', '.svg']);
22
22
 
23
- // Never deletable — removing any of these breaks discovery of the plugin.
23
+ // Never deletable - removing any of these breaks discovery of the plugin.
24
24
  const REQUIRED_FILES = new Set(['plugin.json', 'plugin.js', 'config.js']);
25
25
 
26
26
  // Directories skipped when listing.
@@ -86,7 +86,7 @@ async function resolveFilePath(pluginDir, relPath) {
86
86
  }
87
87
  } catch (err) {
88
88
  if (err.statusCode) throw err;
89
- // ENOENT — new file, fine
89
+ // ENOENT - new file, fine
90
90
  }
91
91
 
92
92
  return abs;
@@ -184,7 +184,7 @@ export async function writePluginFile(name, relPath, content) {
184
184
  await fs.writeFile(abs, content, 'utf8');
185
185
 
186
186
  const rel = path.relative(pluginDir, abs).split(path.sep).join('/');
187
- // admin/** is fetched by the SPA at runtime — a tab refresh picks it up.
187
+ // admin/** is fetched by the SPA at runtime - a tab refresh picks it up.
188
188
  // Everything else (plugin.js, config.js, plugin.json, public/ snippets)
189
189
  // is read or imported server-side and needs a restart.
190
190
  const restartRequired = !rel.startsWith('admin/');
@@ -204,7 +204,7 @@ export async function deletePluginFile(name, relPath) {
204
204
  const rel = path.relative(pluginDir, abs).split(path.sep).join('/');
205
205
 
206
206
  if (REQUIRED_FILES.has(rel)) {
207
- throw fail(400, `${rel} is required — deleting it would break the plugin.`);
207
+ throw fail(400, `${rel} is required - deleting it would break the plugin.`);
208
208
  }
209
209
 
210
210
  try {
@@ -27,7 +27,7 @@ const CONFIG_PATH = path.join(ROOT, 'config', 'plugins.json');
27
27
  const AUDIT_LOG = path.join(ROOT, 'content', 'instances_log.json');
28
28
 
29
29
  // ---------------------------------------------------------------------------
30
- // Trusted public keys — rotate by adding new keyId + bumping CMS version
30
+ // Trusted public keys - rotate by adding new keyId + bumping CMS version
31
31
  // Private key counterpart: domma-cms-manager/config/keys/plugin-signing-2026.pem
32
32
  // ---------------------------------------------------------------------------
33
33
 
@@ -109,12 +109,12 @@ async function extractTar(tarBuffer, slug, destDir) {
109
109
  zeroBlockCount = 0;
110
110
  offset += 512;
111
111
 
112
- // Filename: bytes 0–99, null-terminated
112
+ // Filename: bytes 0-99, null-terminated
113
113
  const nameEnd = header.indexOf(0, 0);
114
114
  const name = header.slice(0, nameEnd === -1 ? 100 : Math.min(nameEnd, 100)).toString('utf8');
115
115
  if (!name) break;
116
116
 
117
- // File size: bytes 124–135, octal ASCII
117
+ // File size: bytes 124-135, octal ASCII
118
118
  const sizeStr = header.slice(124, 136).toString('utf8').replace(/\0/g, '').trim();
119
119
  const size = parseInt(sizeStr, 8) || 0;
120
120
 
@@ -177,36 +177,36 @@ export async function installPlugin(slug, { tarball, signature, publicKeyId }) {
177
177
  throw new Error(`Invalid plugin slug: ${slug}`);
178
178
  }
179
179
 
180
- // Step 1 — Verify signature
180
+ // Step 1 - Verify signature
181
181
  const pubKeyPem = TRUSTED_KEYS[publicKeyId];
182
182
  if (!pubKeyPem) {
183
183
  throw new Error(`Unknown public key: ${publicKeyId}`);
184
184
  }
185
185
 
186
186
  if (pubKeyPem.includes('PLACEHOLDER')) {
187
- throw new Error(`pluginInstaller: TRUSTED_KEYS['${publicKeyId}'] is a placeholder — not usable for verification`);
187
+ throw new Error(`pluginInstaller: TRUSTED_KEYS['${publicKeyId}'] is a placeholder - not usable for verification`);
188
188
  }
189
189
 
190
190
  const signatureBuffer = Buffer.from(signature, 'base64');
191
191
  const valid = crypto.verify('sha256', tarball, pubKeyPem, signatureBuffer);
192
192
  if (!valid) {
193
- throw new Error('Plugin signature invalid — refusing to install');
193
+ throw new Error('Plugin signature invalid - refusing to install');
194
194
  }
195
195
 
196
- // Step 2 — Reject if already installed
196
+ // Step 2 - Reject if already installed
197
197
  const destDir = path.join(PLUGINS_DIR, slug);
198
198
  let alreadyExists = false;
199
199
  try {
200
200
  await fs.access(destDir);
201
201
  alreadyExists = true;
202
202
  } catch {
203
- // Not present — proceed
203
+ // Not present - proceed
204
204
  }
205
205
  if (alreadyExists) {
206
206
  throw new Error(`Plugin already installed: ${slug}`);
207
207
  }
208
208
 
209
- // Step 3 — Decompress + extract
209
+ // Step 3 - Decompress + extract
210
210
  await fs.mkdir(destDir, { recursive: true });
211
211
  try {
212
212
  const tarBuffer = zlib.gunzipSync(tarball);
@@ -217,7 +217,7 @@ export async function installPlugin(slug, { tarball, signature, publicKeyId }) {
217
217
  throw err;
218
218
  }
219
219
 
220
- // Step 4 — Validate manifest
220
+ // Step 4 - Validate manifest
221
221
  const manifests = await discoverPlugins();
222
222
  const manifest = manifests.find(m => m.name === slug);
223
223
  if (!manifest) {
@@ -225,14 +225,14 @@ export async function installPlugin(slug, { tarball, signature, publicKeyId }) {
225
225
  throw new Error(`Plugin manifest validation failed after extraction: ${slug}`);
226
226
  }
227
227
 
228
- // Step 5 — Update config/plugins.json
228
+ // Step 5 - Update config/plugins.json
229
229
  const cfg = await readPluginsConfig();
230
230
  if (!cfg[slug]) {
231
231
  cfg[slug] = { enabled: false, settings: {} };
232
232
  await writePluginsConfig(cfg);
233
233
  }
234
234
 
235
- // Step 6 — Run onEnable lifecycle hook (warn on failure, do not throw)
235
+ // Step 6 - Run onEnable lifecycle hook (warn on failure, do not throw)
236
236
  try {
237
237
  const pluginJsPath = path.join(destDir, 'plugin.js');
238
238
  const mod = await import(pluginJsPath);
@@ -243,7 +243,7 @@ export async function installPlugin(slug, { tarball, signature, publicKeyId }) {
243
243
  console.warn(`[pluginInstaller] onEnable hook for "${slug}" failed: ${err.message}`);
244
244
  }
245
245
 
246
- // Step 7 — Audit log
246
+ // Step 7 - Audit log
247
247
  await writeAuditLog({
248
248
  action: 'install',
249
249
  slug,
@@ -271,7 +271,7 @@ export async function uninstallPlugin(slug) {
271
271
 
272
272
  const destDir = path.join(PLUGINS_DIR, slug);
273
273
 
274
- // Step 1 — Run onDisable lifecycle hook (warn on failure, do not throw)
274
+ // Step 1 - Run onDisable lifecycle hook (warn on failure, do not throw)
275
275
  try {
276
276
  const pluginJsPath = path.join(destDir, 'plugin.js');
277
277
  const mod = await import(pluginJsPath);
@@ -282,17 +282,17 @@ export async function uninstallPlugin(slug) {
282
282
  console.warn(`[pluginInstaller] onDisable hook for "${slug}" failed: ${err.message}`);
283
283
  }
284
284
 
285
- // Step 2 — Remove plugin directory
285
+ // Step 2 - Remove plugin directory
286
286
  await fs.rm(destDir, { recursive: true, force: true });
287
287
 
288
- // Step 3 — Remove from config/plugins.json
288
+ // Step 3 - Remove from config/plugins.json
289
289
  const cfg = await readPluginsConfig();
290
290
  if (slug in cfg) {
291
291
  delete cfg[slug];
292
292
  await writePluginsConfig(cfg);
293
293
  }
294
294
 
295
- // Step 4 — Audit log
295
+ // Step 4 - Audit log
296
296
  await writeAuditLog({
297
297
  action: 'uninstall',
298
298
  slug,
@@ -84,7 +84,7 @@ export async function scaffoldPlugin(input) {
84
84
  throw fail(409, `A plugin named "${slug}" already exists.`);
85
85
  } catch (err) {
86
86
  if (err.statusCode) throw err;
87
- // ENOENT — target is free
87
+ // ENOENT - target is free
88
88
  }
89
89
 
90
90
  const replacements = [
@@ -3,12 +3,12 @@
3
3
  * Discovers, loads, and manages CMS plugins.
4
4
  *
5
5
  * Plugin structure:
6
- * plugins/{name}/plugin.json — manifest (required)
7
- * plugins/{name}/plugin.js — Fastify plugin, default export (required)
8
- * plugins/{name}/config.js — default settings, default export (required)
9
- * plugins/{name}/admin/ — optional admin views/templates
10
- * plugins/{name}/public/ — optional injection HTML snippets
11
- * plugins/{name}/data/ — optional plugin-specific data store
6
+ * plugins/{name}/plugin.json - manifest (required)
7
+ * plugins/{name}/plugin.js - Fastify plugin, default export (required)
8
+ * plugins/{name}/config.js - default settings, default export (required)
9
+ * plugins/{name}/admin/ - optional admin views/templates
10
+ * plugins/{name}/public/ - optional injection HTML snippets
11
+ * plugins/{name}/data/ - optional plugin-specific data store
12
12
  */
13
13
  import fs from 'fs/promises';
14
14
  import path from 'path';
@@ -61,7 +61,7 @@ export function getPluginLoadFailures() {
61
61
  const _loadedPlugins = {};
62
62
 
63
63
  /**
64
- * Core plugins — always loaded regardless of plugins.json enabled state.
64
+ * Core plugins - always loaded regardless of plugins.json enabled state.
65
65
  * These are considered first-class CMS features, not optional add-ons.
66
66
  */
67
67
  const CORE_PLUGINS = new Set(['analytics']);
@@ -101,7 +101,7 @@ export async function discoverPlugins() {
101
101
  for (const field of REQUIRED_MANIFEST_FIELDS) {
102
102
  if (!manifest[field]) {
103
103
  const reason = `missing required field "${field}" in plugin.json`;
104
- console.warn(`Plugin "${dir}": ${reason} — skipping`);
104
+ console.warn(`Plugin "${dir}": ${reason} - skipping`);
105
105
  recordPluginLoadFailure(dir, reason);
106
106
  valid = false;
107
107
  break;
@@ -117,7 +117,7 @@ export async function discoverPlugins() {
117
117
  await fs.access(pluginJsPath);
118
118
  } catch {
119
119
  const reason = 'missing required file plugin.js';
120
- console.warn(`Plugin "${dir}": ${reason} — skipping`);
120
+ console.warn(`Plugin "${dir}": ${reason} - skipping`);
121
121
  recordPluginLoadFailure(dir, reason);
122
122
  continue;
123
123
  }
@@ -126,7 +126,7 @@ export async function discoverPlugins() {
126
126
  await fs.access(configJsPath);
127
127
  } catch {
128
128
  const reason = 'missing required file config.js';
129
- console.warn(`Plugin "${dir}": ${reason} — skipping`);
129
+ console.warn(`Plugin "${dir}": ${reason} - skipping`);
130
130
  recordPluginLoadFailure(dir, reason);
131
131
  continue;
132
132
  }
@@ -153,7 +153,7 @@ async function loadConfigDefaults(name) {
153
153
  const {default: exported} = await import(configJsPath);
154
154
  defaults = exported || {};
155
155
  } catch (err) {
156
- const reason = `failed to load config.js — ${err.message}`;
156
+ const reason = `failed to load config.js - ${err.message}`;
157
157
  console.warn(`Plugin "${name}": ${reason}`);
158
158
  recordPluginLoadFailure(name, reason);
159
159
  }
@@ -233,7 +233,7 @@ export async function registerPlugins(fastify) {
233
233
  }
234
234
  }
235
235
 
236
- // Ensure plugin collections exist (idempotent — skips if already present)
236
+ // Ensure plugin collections exist (idempotent - skips if already present)
237
237
  await setupPlugin(manifest.name, {collections: {getCollection, createCollection}})
238
238
  .catch(err => fastify.log.warn(`[plugins] Collection setup for "${manifest.name}" failed: ${err.message}`));
239
239
 
@@ -244,7 +244,7 @@ export async function registerPlugins(fastify) {
244
244
  await fs.access(publicEntryPath);
245
245
  _loadedPlugins[manifest.name].publicEntry = publicEntryPath;
246
246
  } catch {
247
- // no public entry — that's fine
247
+ // no public entry - that's fine
248
248
  }
249
249
  } catch (err) {
250
250
  const reason = `server failed to load: ${err.message}`;
@@ -306,7 +306,7 @@ export async function getInjectionSnippets() {
306
306
  if (!manifest.inject[key]) continue;
307
307
  const filePath = path.resolve(PLUGINS_DIR, manifest.name, manifest.inject[key]);
308
308
  if (!filePath.startsWith(pluginRoot + path.sep)) {
309
- console.warn(`Plugin "${manifest.name}": inject.${key} path escapes plugin directory — skipping`);
309
+ console.warn(`Plugin "${manifest.name}": inject.${key} path escapes plugin directory - skipping`);
310
310
  continue;
311
311
  }
312
312
  try {
@@ -314,7 +314,7 @@ export async function getInjectionSnippets() {
314
314
  if (target === 'head') head += content + '\n';
315
315
  if (target === 'headLate') headLate += content + '\n';
316
316
  if (target === 'bodyEnd') bodyEnd += content + '\n';
317
- } catch { /* snippet not found — skip */
317
+ } catch { /* snippet not found - skip */
318
318
  }
319
319
  }
320
320
  }
@@ -431,7 +431,7 @@ export async function setupPlugin(pluginName, services) {
431
431
  }
432
432
  }
433
433
 
434
- // Roles — read plugins/<name>/roles/*.json, register resources + persist roles
434
+ // Roles - read plugins/<name>/roles/*.json, register resources + persist roles
435
435
  if (services.roles) {
436
436
  const rolesDir = path.join(pluginDir, 'roles');
437
437
  let roleFiles;
@@ -511,7 +511,7 @@ export async function teardownPlugin(pluginName, services, fastify) {
511
511
  }
512
512
  }
513
513
 
514
- // Roles — remove plugin-contributed roles and unregister their resources
514
+ // Roles - remove plugin-contributed roles and unregister their resources
515
515
  if (services.roles) {
516
516
  try {
517
517
  await services.roles.removeRolesByPlugin(pluginName);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Preset Collections
3
3
  * Built-in collections whose schemas are defined in code and overwritten on boot.
4
- * Data files are never touched — only schema.json is reset each startup.
4
+ * Data files are never touched - only schema.json is reset each startup.
5
5
  */
6
6
  import fs from 'fs/promises';
7
7
  import path from 'path';
@@ -10,7 +10,7 @@ import {config} from '../config.js';
10
10
  const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
11
11
 
12
12
  // contacts, enquiries, feedback, notes, categories, posts, comments, to-do
13
- // have been removed — they belong to plugins and are seeded on plugin enable.
13
+ // have been removed - they belong to plugins and are seeded on plugin enable.
14
14
 
15
15
  const PRESETS = [
16
16
  {