domma-cms 0.17.0 → 0.21.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 (136) hide show
  1. package/CLAUDE.md +39 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/css/dashboard.css +1 -1
  4. package/admin/index.html +2 -2
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/config/sidebar-config.js +1 -1
  8. package/admin/js/lib/card-builder.js +3 -3
  9. package/admin/js/lib/crud-tutorial.js +1 -0
  10. package/admin/js/lib/effects-builder.js +1 -1
  11. package/admin/js/lib/markdown-toolbar.js +6 -6
  12. package/admin/js/lib/project-context.js +1 -0
  13. package/admin/js/lib/sidebar-renderer.js +4 -0
  14. package/admin/js/templates/action-editor.html +7 -0
  15. package/admin/js/templates/block-editor.html +7 -0
  16. package/admin/js/templates/collection-editor.html +9 -0
  17. package/admin/js/templates/dashboard/cache.html +32 -0
  18. package/admin/js/templates/dashboard.html +4 -0
  19. package/admin/js/templates/form-editor.html +9 -0
  20. package/admin/js/templates/menu-editor.html +98 -0
  21. package/admin/js/templates/menu-locations.html +14 -0
  22. package/admin/js/templates/menus.html +14 -0
  23. package/admin/js/templates/page-editor.html +9 -2
  24. package/admin/js/templates/project-detail.html +50 -0
  25. package/admin/js/templates/project-editor.html +45 -0
  26. package/admin/js/templates/project-settings.html +60 -0
  27. package/admin/js/templates/projects.html +13 -0
  28. package/admin/js/templates/role-editor.html +11 -0
  29. package/admin/js/templates/settings.html +26 -0
  30. package/admin/js/templates/tutorials.html +335 -2
  31. package/admin/js/templates/view-editor.html +7 -0
  32. package/admin/js/views/action-editor.js +1 -1
  33. package/admin/js/views/actions-list.js +1 -1
  34. package/admin/js/views/block-editor-enhance.js +1 -1
  35. package/admin/js/views/block-editor.js +8 -8
  36. package/admin/js/views/blocks.js +2 -2
  37. package/admin/js/views/collection-editor.js +4 -4
  38. package/admin/js/views/collections.js +1 -1
  39. package/admin/js/views/dashboard/widgets/activity-feed.js +1 -1
  40. package/admin/js/views/dashboard/widgets/cache.js +1 -0
  41. package/admin/js/views/dashboard/widgets/journeys.js +1 -1
  42. package/admin/js/views/dashboard/widgets/spike-feed.js +1 -1
  43. package/admin/js/views/dashboard/widgets/top-pages.js +1 -1
  44. package/admin/js/views/dashboard.js +1 -1
  45. package/admin/js/views/form-editor.js +6 -6
  46. package/admin/js/views/forms.js +1 -1
  47. package/admin/js/views/index.js +1 -1
  48. package/admin/js/views/menu-editor.js +19 -0
  49. package/admin/js/views/menu-locations.js +1 -0
  50. package/admin/js/views/menus.js +5 -0
  51. package/admin/js/views/page-editor.js +41 -36
  52. package/admin/js/views/pages.js +3 -3
  53. package/admin/js/views/project-detail.js +4 -0
  54. package/admin/js/views/project-editor.js +1 -0
  55. package/admin/js/views/project-settings.js +1 -0
  56. package/admin/js/views/projects.js +7 -0
  57. package/admin/js/views/role-editor.js +1 -1
  58. package/admin/js/views/roles.js +3 -3
  59. package/admin/js/views/settings.js +3 -3
  60. package/admin/js/views/tutorials.js +1 -1
  61. package/admin/js/views/user-editor.js +1 -1
  62. package/admin/js/views/users.js +3 -3
  63. package/admin/js/views/view-editor.js +1 -1
  64. package/admin/js/views/views-list.js +1 -1
  65. package/config/cache.json +4 -0
  66. package/config/cache.json.example +12 -0
  67. package/config/menu-locations.json +5 -0
  68. package/config/menus/admin-sidebar.json +185 -0
  69. package/config/menus/footer.json +33 -0
  70. package/config/menus/main.json +35 -0
  71. package/config/menus/sproj-1779696558011-menu.json +17 -0
  72. package/config/menus/sproj-1779696960337-menu.json +18 -0
  73. package/config/menus/sproj-1779696985353-menu.json +18 -0
  74. package/config/site.json +6 -22
  75. package/package.json +4 -3
  76. package/plugins/analytics/daily.json +3 -0
  77. package/plugins/analytics/journeys.json +8 -0
  78. package/plugins/analytics/lifetime.json +1 -1
  79. package/public/css/site.css +1 -1
  80. package/public/js/collection-browser.js +4 -0
  81. package/public/js/forms.js +1 -1
  82. package/public/js/site.js +1 -1
  83. package/server/config.js +12 -1
  84. package/server/middleware/auth.js +88 -22
  85. package/server/routes/api/actions.js +58 -5
  86. package/server/routes/api/auth.js +2 -2
  87. package/server/routes/api/blocks.js +18 -3
  88. package/server/routes/api/cache.js +57 -0
  89. package/server/routes/api/collections.js +201 -8
  90. package/server/routes/api/forms.js +266 -21
  91. package/server/routes/api/menu-locations.js +46 -0
  92. package/server/routes/api/menus.js +115 -0
  93. package/server/routes/api/navigation.js +2 -0
  94. package/server/routes/api/pages.js +1 -1
  95. package/server/routes/api/projects.js +107 -0
  96. package/server/routes/api/scaffold.js +86 -0
  97. package/server/routes/api/settings.js +3 -0
  98. package/server/routes/api/sidebar.js +23 -0
  99. package/server/routes/api/users.js +32 -7
  100. package/server/routes/api/views.js +10 -2
  101. package/server/routes/public.js +88 -7
  102. package/server/server.js +54 -3
  103. package/server/services/actions.js +137 -8
  104. package/server/services/adapters/FileAdapter.js +23 -8
  105. package/server/services/adapters/MongoAdapter.js +36 -18
  106. package/server/services/blocks.js +23 -8
  107. package/server/services/cache/drivers/MemoryDriver.js +118 -0
  108. package/server/services/cache/drivers/NoneDriver.js +12 -0
  109. package/server/services/cache/index.js +229 -0
  110. package/server/services/cache/lru.js +61 -0
  111. package/server/services/collections.js +102 -12
  112. package/server/services/content.js +25 -6
  113. package/server/services/filterEngine.js +281 -0
  114. package/server/services/forms.js +3 -0
  115. package/server/services/hooks.js +48 -0
  116. package/server/services/markdown.js +711 -124
  117. package/server/services/menus-migration.js +107 -0
  118. package/server/services/menus.js +422 -0
  119. package/server/services/permissionRegistry.js +26 -0
  120. package/server/services/plugins.js +9 -2
  121. package/server/services/presetCollections.js +22 -0
  122. package/server/services/projects.js +429 -0
  123. package/server/services/recipes/contact-list.json +78 -0
  124. package/server/services/recipes/onboarding.json +426 -0
  125. package/server/services/references.js +174 -0
  126. package/server/services/renderer.js +237 -40
  127. package/server/services/roles.js +6 -1
  128. package/server/services/rowAccess.js +86 -13
  129. package/server/services/scaffolder.js +465 -0
  130. package/server/services/sidebar-migration.js +117 -0
  131. package/server/services/sitemap.js +112 -0
  132. package/server/services/userRoles.js +86 -0
  133. package/server/services/users.js +23 -2
  134. package/server/services/views.js +19 -4
  135. package/server/templates/page.html +135 -130
  136. /package/config/{navigation.json → navigation.json.bak} +0 -0
@@ -11,6 +11,7 @@ import path from 'path';
11
11
  import {v4 as uuidv4} from 'uuid';
12
12
  import {config} from '../config.js';
13
13
  import {getAdapter, invalidate} from './adapterRegistry.js';
14
+ import * as cache from './cache/index.js';
14
15
 
15
16
  const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
16
17
 
@@ -120,7 +121,7 @@ export async function getCollection(slug) {
120
121
  * @returns {Promise<object>} Created schema
121
122
  * @throws {Error} If a collection with that slug already exists
122
123
  */
123
- export async function createCollection({title, slug, description = '', fields = [], api = {}, storage, bundled, plugin}) {
124
+ export async function createCollection({title, slug, description = '', fields = [], api = {}, storage, bundled, plugin, meta}) {
124
125
  await ensureDir();
125
126
  const finalSlug = slug ? slugify(slug) : slugify(title);
126
127
  if (!finalSlug) throw new Error('Could not derive a slug from the title');
@@ -143,12 +144,14 @@ export async function createCollection({title, slug, description = '', fields =
143
144
  storage: storage || {adapter: 'file'},
144
145
  ...(bundled ? {bundled: true} : {}),
145
146
  ...(plugin ? {plugin} : {}),
147
+ ...(meta != null && {meta}),
146
148
  createdAt: now,
147
149
  updatedAt: now
148
150
  };
149
151
 
150
152
  await writeSchema(schema);
151
153
  await (await getAdapter(finalSlug)).insertMany(finalSlug, []);
154
+ await cache.invalidateTags([`collection:${finalSlug}`]);
152
155
  return schema;
153
156
  }
154
157
 
@@ -191,6 +194,7 @@ export async function updateCollection(slug, updates) {
191
194
 
192
195
  // Invalidate cached adapter in case storage config changed.
193
196
  invalidate(slug);
197
+ await cache.invalidateTags([`collection:${slug}`]);
194
198
 
195
199
  return updated;
196
200
  }
@@ -218,6 +222,7 @@ export async function deleteCollection(slug) {
218
222
 
219
223
  invalidate(slug);
220
224
  await fs.rm(collectionDir(slug), { recursive: true, force: true });
225
+ await cache.invalidateTags([`collection:${slug}`]);
221
226
  }
222
227
 
223
228
  // ---------------------------------------------------------------------------
@@ -227,6 +232,10 @@ export async function deleteCollection(slug) {
227
232
  /**
228
233
  * Validate entry data against a collection schema.
229
234
  *
235
+ * Reference-field validation (target id existence) requires async I/O and is
236
+ * therefore done in `validateReferences()` — called from create/update paths
237
+ * after this synchronous shape check passes.
238
+ *
230
239
  * @param {object} schema
231
240
  * @param {object} data
232
241
  * @returns {{ valid: boolean, errors: string[] }}
@@ -243,27 +252,93 @@ export function validateEntryData(schema, data) {
243
252
  return { valid: errors.length === 0, errors };
244
253
  }
245
254
 
255
+ /**
256
+ * Asynchronously validate that every reference field's value points to an
257
+ * entry that actually exists in the target collection. Single-valued and
258
+ * array-valued references are both supported.
259
+ *
260
+ * Empty/null references are skipped here — the required check in
261
+ * `validateEntryData()` handles those.
262
+ *
263
+ * @param {object} schema
264
+ * @param {object} data
265
+ * @returns {Promise<string[]>} List of error messages (empty if all OK)
266
+ */
267
+ export async function validateReferences(schema, data) {
268
+ const errors = [];
269
+ for (const field of (schema.fields || [])) {
270
+ if (field.type !== 'reference') continue;
271
+ const targetSlug = field.reference?.collection;
272
+ if (!targetSlug) continue; // misconfigured schema — skip silently
273
+
274
+ const raw = data[field.name];
275
+ if (raw === undefined || raw === null || raw === '') continue;
276
+
277
+ // Support arrays (one-to-many references) and single ids
278
+ const ids = Array.isArray(raw) ? raw.filter(Boolean) : [raw];
279
+ if (!ids.length) continue;
280
+
281
+ const adapter = await getAdapter(targetSlug);
282
+ for (const id of ids) {
283
+ const target = await adapter.get(targetSlug, String(id));
284
+ if (!target) {
285
+ errors.push(`"${field.label || field.name}" → no entry "${id}" in "${targetSlug}"`);
286
+ }
287
+ }
288
+ }
289
+ return errors;
290
+ }
291
+
246
292
  // ---------------------------------------------------------------------------
247
293
  // Entry operations — delegated to storage adapter
248
294
  // ---------------------------------------------------------------------------
249
295
 
250
296
  /**
251
- * List entries with optional pagination, sorting, and search.
297
+ * List entries with optional pagination, sorting, free-text search,
298
+ * structured filtering, and reference resolution.
299
+ *
300
+ * Delegates to the configured adapter (File or Mongo). Both adapters honour
301
+ * identical option semantics — see `filterEngine.js` for the filter DSL.
302
+ *
303
+ * @example
304
+ * // Paginated, sorted, simple search
305
+ * await listEntries('jobs', { page: 1, limit: 20, sort: 'postedAt', search: 'engineer' });
306
+ *
307
+ * @example
308
+ * // Structured filter — full-time London roles paying £50k+
309
+ * await listEntries('jobs', { filter: { location: 'London', type: 'full-time', salary_gte: 50000 } });
310
+ *
311
+ * @example
312
+ * // Row-level scoping — applications belonging to one user
313
+ * await listEntries('applications', { filter: { createdBy: userId } });
314
+ *
315
+ * @example
316
+ * // Resolve reference fields — each entry gets `_refs.<field>` with display labels
317
+ * await listEntries('applications', { resolveRefs: true });
318
+ * // → entries[0]._refs.jobId === { id, display: 'Senior Engineer', missing: false, link: null }
252
319
  *
253
320
  * @param {string} slug
254
321
  * @param {object} [opts]
255
- * @param {number} [opts.page=1]
256
- * @param {number} [opts.limit=50]
257
- * @param {string} [opts.sort='createdAt']
258
- * @param {string} [opts.order='desc']
259
- * @param {string} [opts.search]
322
+ * @param {number} [opts.page=1]
323
+ * @param {number} [opts.limit=50] - Use 0 for "no limit"
324
+ * @param {string} [opts.sort='createdAt'] - Field name or 'createdAt'
325
+ * @param {string} [opts.order='desc'] - 'asc' | 'desc'
326
+ * @param {string} [opts.search] - Substring match across all fields
327
+ * @param {Record<string, unknown>} [opts.filter] - Structured filter; see filterEngine.js
328
+ * @param {boolean} [opts.resolveRefs=false] - Batch-resolve reference fields to display labels
260
329
  * @returns {Promise<{ entries: object[], total: number, page: number, limit: number }>}
330
+ * @throws {Error} If collection slug does not exist
261
331
  */
262
332
  export async function listEntries(slug, opts = {}) {
263
333
  const schema = await getCollection(slug);
264
334
  if (!schema) throw new Error(`Collection "${slug}" not found`);
265
335
  const adapter = await getAdapter(slug);
266
- return adapter.list(slug, opts);
336
+ const result = await adapter.list(slug, opts);
337
+ if (opts.resolveRefs) {
338
+ const {resolveReferences} = await import('./references.js');
339
+ await resolveReferences(schema, result.entries);
340
+ }
341
+ return result;
267
342
  }
268
343
 
269
344
  /**
@@ -296,6 +371,9 @@ export async function createEntry(slug, data, { createdBy = null, source = 'admi
296
371
  const { valid, errors } = validateEntryData(schema, data);
297
372
  if (!valid) throw new Error(`Validation failed: ${errors.join('; ')}`);
298
373
 
374
+ const refErrors = await validateReferences(schema, data);
375
+ if (refErrors.length) throw new Error(`Validation failed: ${refErrors.join('; ')}`);
376
+
299
377
  const now = new Date().toISOString();
300
378
  const entry = {
301
379
  id: uuidv4(),
@@ -304,7 +382,9 @@ export async function createEntry(slug, data, { createdBy = null, source = 'admi
304
382
  };
305
383
 
306
384
  const adapter = await getAdapter(slug);
307
- return adapter.insert(slug, entry);
385
+ const created = await adapter.insert(slug, entry);
386
+ await cache.invalidateTags([`collection:${slug}`]);
387
+ return created;
308
388
  }
309
389
 
310
390
  /**
@@ -323,6 +403,9 @@ export async function updateEntry(slug, entryId, data) {
323
403
  const { valid, errors } = validateEntryData(schema, data);
324
404
  if (!valid) throw new Error(`Validation failed: ${errors.join('; ')}`);
325
405
 
406
+ const refErrors = await validateReferences(schema, data);
407
+ if (refErrors.length) throw new Error(`Validation failed: ${refErrors.join('; ')}`);
408
+
326
409
  const adapter = await getAdapter(slug);
327
410
  const existing = await adapter.get(slug, entryId);
328
411
  if (!existing) throw new Error('Entry not found');
@@ -333,7 +416,9 @@ export async function updateEntry(slug, entryId, data) {
333
416
  meta: { ...existing.meta, updatedAt: new Date().toISOString() }
334
417
  };
335
418
 
336
- return adapter.update(slug, entryId, updated);
419
+ const result = await adapter.update(slug, entryId, updated);
420
+ await cache.invalidateTags([`collection:${slug}`]);
421
+ return result;
337
422
  }
338
423
 
339
424
  /**
@@ -346,7 +431,9 @@ export async function updateEntry(slug, entryId, data) {
346
431
  */
347
432
  export async function deleteEntry(slug, entryId) {
348
433
  const adapter = await getAdapter(slug);
349
- return adapter.remove(slug, entryId);
434
+ const result = await adapter.remove(slug, entryId);
435
+ await cache.invalidateTags([`collection:${slug}`]);
436
+ return result;
350
437
  }
351
438
 
352
439
  /**
@@ -359,7 +446,9 @@ export async function clearEntries(slug) {
359
446
  const schema = await getCollection(slug);
360
447
  if (!schema) throw new Error(`Collection "${slug}" not found`);
361
448
  const adapter = await getAdapter(slug);
362
- return adapter.clear(slug);
449
+ const result = await adapter.clear(slug);
450
+ await cache.invalidateTags([`collection:${slug}`]);
451
+ return result;
363
452
  }
364
453
 
365
454
  // ---------------------------------------------------------------------------
@@ -433,6 +522,7 @@ export async function importEntries(slug, incoming, { createdBy = null } = {}) {
433
522
  const adapter = await getAdapter(slug);
434
523
  if (valid.length > 0) {
435
524
  await adapter.insertMany(slug, valid);
525
+ await cache.invalidateTags([`collection:${slug}`]);
436
526
  }
437
527
 
438
528
  return { imported: valid.length, skipped, errors };
@@ -9,6 +9,7 @@ import {parseMarkdown, serialiseMarkdown} from './markdown.js';
9
9
  import {config} from '../config.js';
10
10
  import {hooks} from './hooks.js';
11
11
  import {createVersion, deleteAllVersions, renameVersionDir} from './versions.js';
12
+ import * as cache from './cache/index.js';
12
13
 
13
14
  const CONTENT_DIR = config.content.contentDir;
14
15
  const PAGES_DIR = path.join(CONTENT_DIR, 'pages');
@@ -27,7 +28,14 @@ const MEDIA_DIR = config.content.mediaDir;
27
28
  export async function listPages() {
28
29
  const files = await collectMdFiles(PAGES_DIR);
29
30
  const pages = await Promise.all(files.map(filePath => readPageFile(filePath)));
30
- return pages.sort((a, b) => (a.sortOrder ?? 99) - (b.sortOrder ?? 99) || a.title.localeCompare(b.title));
31
+ pages.sort((a, b) => (a.sortOrder ?? 99) - (b.sortOrder ?? 99) || a.title.localeCompare(b.title));
32
+ const {getProjectForPage} = await import('./projects.js');
33
+ for (const page of pages) {
34
+ const url = page.url || page.urlPath || '/';
35
+ const explicit = (page.project === undefined) ? undefined : page.project;
36
+ page.resolvedProject = await getProjectForPage(url, explicit);
37
+ }
38
+ return pages;
31
39
  }
32
40
 
33
41
  /**
@@ -35,12 +43,15 @@ export async function listPages() {
35
43
  * The empty string / '/' resolves to index.md.
36
44
  *
37
45
  * @param {string} urlPath
46
+ * @param {object} [opts]
47
+ * @param {object|null} [opts.user] - Forwarded to parseMarkdown so shortcodes
48
+ * that gate output by role (currently `[menu]`) can filter correctly.
38
49
  * @returns {Promise<object|null>}
39
50
  */
40
- export async function getPage(urlPath) {
51
+ export async function getPage(urlPath, opts = {}) {
41
52
  const filePath = await resolveExistingFilePath(urlPath);
42
53
  try {
43
- return await readPageFile(filePath);
54
+ return await readPageFile(filePath, {user: opts.user || null});
44
55
  } catch {
45
56
  return null;
46
57
  }
@@ -77,6 +88,7 @@ export async function createPage(urlPath, frontmatter, body) {
77
88
 
78
89
  await fs.writeFile(filePath, serialiseMarkdown(meta, body || ''), 'utf8');
79
90
  const page = await readPageFile(filePath);
91
+ await cache.invalidateTags([`page:${urlPath}`, 'sitemap']);
80
92
  hooks.emit('content:pageCreated', {page, urlPath});
81
93
  return page;
82
94
  }
@@ -111,6 +123,7 @@ export async function updatePage(urlPath, frontmatter, body, {author} = {}) {
111
123
 
112
124
  await fs.writeFile(filePath, serialiseMarkdown(meta, body ?? existingContent), 'utf8');
113
125
  const page = await readPageFile(filePath);
126
+ await cache.invalidateTags([`page:${urlPath}`, 'sitemap']);
114
127
  hooks.emit('content:pageUpdated', {page, urlPath});
115
128
  return page;
116
129
  }
@@ -128,6 +141,7 @@ export async function renamePage(oldUrlPath, newUrlPath) {
128
141
  const newFile = urlPathToFilePath(newUrlPath);
129
142
  await fs.mkdir(path.dirname(newFile), { recursive: true });
130
143
  await fs.rename(oldFile, newFile);
144
+ await cache.invalidateTags([`page:${oldUrlPath}`, `page:${newUrlPath}`, 'sitemap']);
131
145
  try {
132
146
  await renameVersionDir(oldUrlPath, newUrlPath);
133
147
  } catch {
@@ -144,6 +158,7 @@ export async function renamePage(oldUrlPath, newUrlPath) {
144
158
  export async function deletePage(urlPath) {
145
159
  const filePath = await resolveExistingFilePath(urlPath);
146
160
  await fs.unlink(filePath);
161
+ await cache.invalidateTags([`page:${urlPath}`, 'sitemap']);
147
162
  hooks.emit('content:pageDeleted', {urlPath});
148
163
  try {
149
164
  await deleteAllVersions(urlPath);
@@ -284,13 +299,17 @@ async function collectMdFiles(dir) {
284
299
  * Read and parse a single .md file, injecting the derived urlPath.
285
300
  *
286
301
  * @param {string} filePath
302
+ * @param {object} [opts]
303
+ * @param {object|null} [opts.user] - Forwarded to parseMarkdown so the
304
+ * `[menu]` shortcode can filter items by per-user visibility. Defaults to
305
+ * anonymous (null) — most callers (listings, admin previews) leave this unset.
287
306
  * @returns {Promise<object>}
288
307
  */
289
- async function readPageFile(filePath) {
308
+ async function readPageFile(filePath, opts = {}) {
290
309
  const raw = await fs.readFile(filePath, 'utf8');
291
- const { data, content, html, usedComponents } = await parseMarkdown(raw);
310
+ const { data, content, html, usedComponents, tags } = await parseMarkdown(raw, {user: opts.user || null});
292
311
  const urlPath = filePathToUrlPath(filePath);
293
- return { ...data, urlPath, content, html, usedComponents };
312
+ return { ...data, urlPath, content, html, usedComponents, tags };
294
313
  }
295
314
 
296
315
  /**
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Filter Engine — Structured Query Translator for Collection Entries
3
+ *
4
+ * Defines the structured-filter DSL used by `listEntries` and the `[collection]`
5
+ * shortcode (via `where_*` attributes). One module, two outputs:
6
+ *
7
+ * - applyFilter(entries, filter) → in-memory filtering for FileAdapter
8
+ * - toMongoQuery(filter) → MongoDB query for MongoAdapter
9
+ *
10
+ * Both adapters share the same operator semantics so a page authored against
11
+ * the file adapter behaves identically on Mongo.
12
+ *
13
+ * ---------------------------------------------------------------------------
14
+ * Operator suffixes
15
+ * ---------------------------------------------------------------------------
16
+ * Filter keys are `<field>` (defaults to _eq) or `<field>_<op>`. Operators:
17
+ *
18
+ * _eq equal → "London"
19
+ * _ne not equal → "London"
20
+ * _gt greater than → 100
21
+ * _gte greater than or equal → 100
22
+ * _lt less than → 100
23
+ * _lte less than or equal → 100
24
+ * _in value in comma-separated list → "remote,hybrid"
25
+ * _nin value not in list → "remote,hybrid"
26
+ * _contains substring match (case-insens.) → "engineer"
27
+ * _starts starts-with (case-insens.) → "Lon"
28
+ * _ends ends-with (case-insens.) → "ish"
29
+ * _exists field is present and non-null → "true" | "false"
30
+ *
31
+ * ---------------------------------------------------------------------------
32
+ * Value coercion
33
+ * ---------------------------------------------------------------------------
34
+ * Filter values arrive as strings (from query params and shortcode attrs).
35
+ * Numeric operators (_gt/_gte/_lt/_lte) coerce both sides to Number when
36
+ * possible; all comparisons fall back to string compare on NaN. _in / _nin
37
+ * split on `,` and trim each value. _exists accepts "true" | "false" | "1" | "0".
38
+ *
39
+ * Field paths support dot notation against `entry.data` — e.g.
40
+ * { 'address.city_eq': 'London' } → matches entry.data.address.city
41
+ *
42
+ * ---------------------------------------------------------------------------
43
+ * Special pseudo-fields
44
+ * ---------------------------------------------------------------------------
45
+ * createdBy — matches against entry.meta.createdBy (not entry.data.*)
46
+ * id — matches against entry.id (top-level)
47
+ *
48
+ * Anything else maps to a path inside entry.data.
49
+ */
50
+
51
+ /** Recognised operator suffixes — order matters for greedy matching (_gte before _gt). */
52
+ export const OPERATORS = [
53
+ '_eq', '_ne', '_gte', '_lte', '_gt', '_lt',
54
+ '_in', '_nin', '_contains', '_starts', '_ends', '_exists'
55
+ ];
56
+
57
+ /**
58
+ * Parse a filter key into `{ field, op }`.
59
+ *
60
+ * @example
61
+ * parseFilterKey('location') → { field: 'location', op: '_eq' }
62
+ * parseFilterKey('salary_gte') → { field: 'salary', op: '_gte' }
63
+ * parseFilterKey('address.city_eq') → { field: 'address.city', op: '_eq' }
64
+ *
65
+ * @param {string} key
66
+ * @returns {{ field: string, op: string }}
67
+ */
68
+ export function parseFilterKey(key) {
69
+ for (const op of OPERATORS) {
70
+ if (key.endsWith(op)) {
71
+ return { field: key.slice(0, -op.length), op };
72
+ }
73
+ }
74
+ return { field: key, op: '_eq' };
75
+ }
76
+
77
+ /**
78
+ * Resolve a dot-path against an entry, with two special-cased pseudo-fields.
79
+ *
80
+ * @param {object} entry
81
+ * @param {string} field
82
+ * @returns {unknown}
83
+ */
84
+ function resolveField(entry, field) {
85
+ if (field === 'createdBy') return entry.meta?.createdBy;
86
+ if (field === 'id') return entry.id;
87
+
88
+ const path = field.split('.');
89
+ let val = entry.data;
90
+ for (const k of path) {
91
+ if (val == null) return undefined;
92
+ val = val[k];
93
+ }
94
+ return val;
95
+ }
96
+
97
+ /**
98
+ * Coerce a string to a number if it round-trips; otherwise return the original.
99
+ *
100
+ * @param {unknown} v
101
+ * @returns {unknown}
102
+ */
103
+ function maybeNumber(v) {
104
+ if (typeof v === 'number') return v;
105
+ if (typeof v !== 'string') return v;
106
+ const n = Number(v);
107
+ return Number.isFinite(n) && String(n) === v.trim() ? n : v;
108
+ }
109
+
110
+ /**
111
+ * Coerce a string to a boolean (for _exists).
112
+ *
113
+ * @param {unknown} v
114
+ * @returns {boolean}
115
+ */
116
+ function toBool(v) {
117
+ if (typeof v === 'boolean') return v;
118
+ return v === 'true' || v === '1' || v === 1 || v === true;
119
+ }
120
+
121
+ /**
122
+ * Apply one operator to a single entry-side value and filter-side value.
123
+ *
124
+ * @param {string} op
125
+ * @param {unknown} entryVal
126
+ * @param {unknown} filterVal
127
+ * @returns {boolean}
128
+ */
129
+ function applyOp(op, entryVal, filterVal) {
130
+ switch (op) {
131
+ case '_eq': {
132
+ // Loose equality: number-coerce both sides for numeric fields stored as strings.
133
+ const a = maybeNumber(entryVal);
134
+ const b = maybeNumber(filterVal);
135
+ // Match-in-array: if entry value is an array, treat _eq as "contains".
136
+ if (Array.isArray(entryVal)) return entryVal.map(maybeNumber).includes(b);
137
+ return a === b || String(a) === String(b);
138
+ }
139
+ case '_ne': {
140
+ const a = maybeNumber(entryVal);
141
+ const b = maybeNumber(filterVal);
142
+ if (Array.isArray(entryVal)) return !entryVal.map(maybeNumber).includes(b);
143
+ return !(a === b || String(a) === String(b));
144
+ }
145
+ case '_gt': return maybeNumber(entryVal) > maybeNumber(filterVal);
146
+ case '_gte': return maybeNumber(entryVal) >= maybeNumber(filterVal);
147
+ case '_lt': return maybeNumber(entryVal) < maybeNumber(filterVal);
148
+ case '_lte': return maybeNumber(entryVal) <= maybeNumber(filterVal);
149
+ case '_in': {
150
+ const list = String(filterVal).split(',').map(s => maybeNumber(s.trim()));
151
+ if (Array.isArray(entryVal)) {
152
+ return entryVal.map(maybeNumber).some(v => list.includes(v));
153
+ }
154
+ return list.includes(maybeNumber(entryVal));
155
+ }
156
+ case '_nin': {
157
+ const list = String(filterVal).split(',').map(s => maybeNumber(s.trim()));
158
+ if (Array.isArray(entryVal)) {
159
+ return !entryVal.map(maybeNumber).some(v => list.includes(v));
160
+ }
161
+ return !list.includes(maybeNumber(entryVal));
162
+ }
163
+ case '_contains':
164
+ return String(entryVal ?? '').toLowerCase().includes(String(filterVal).toLowerCase());
165
+ case '_starts':
166
+ return String(entryVal ?? '').toLowerCase().startsWith(String(filterVal).toLowerCase());
167
+ case '_ends':
168
+ return String(entryVal ?? '').toLowerCase().endsWith(String(filterVal).toLowerCase());
169
+ case '_exists': {
170
+ const present = entryVal !== undefined && entryVal !== null && entryVal !== '';
171
+ return toBool(filterVal) ? present : !present;
172
+ }
173
+ default:
174
+ // Unknown operator → fail open (no match) rather than throw, so a
175
+ // typo in a page shortcode doesn't 500 the entire page render.
176
+ return false;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Apply a filter object to an in-memory list of entries (FileAdapter path).
182
+ * Combines conditions with AND. Returns matching entries in original order.
183
+ *
184
+ * @example
185
+ * applyFilter(entries, { location: 'London', salary_gte: '50000' })
186
+ *
187
+ * @param {object[]} entries
188
+ * @param {Record<string, unknown>} filter
189
+ * @returns {object[]}
190
+ */
191
+ export function applyFilter(entries, filter) {
192
+ if (!filter || Object.keys(filter).length === 0) return entries;
193
+
194
+ return entries.filter(entry => {
195
+ for (const [key, filterVal] of Object.entries(filter)) {
196
+ if (filterVal === undefined || filterVal === null || filterVal === '') continue;
197
+ const { field, op } = parseFilterKey(key);
198
+ const entryVal = resolveField(entry, field);
199
+ if (!applyOp(op, entryVal, filterVal)) return false;
200
+ }
201
+ return true;
202
+ });
203
+ }
204
+
205
+ /**
206
+ * Translate a filter object to a MongoDB query document (MongoAdapter path).
207
+ * Fields are mapped to the stored shape: `data.<field>`, except for special
208
+ * pseudo-fields (`createdBy` → `meta.createdBy`, `id` → `id`).
209
+ *
210
+ * Conditions are combined with AND. Multiple operators on the same field are
211
+ * merged into a single sub-document — e.g. `{ salary_gte: 100, salary_lte: 200 }`
212
+ * becomes `{ 'data.salary': { $gte: 100, $lte: 200 } }`.
213
+ *
214
+ * @param {Record<string, unknown>} filter
215
+ * @returns {object} Mongo query document
216
+ */
217
+ export function toMongoQuery(filter) {
218
+ if (!filter || Object.keys(filter).length === 0) return {};
219
+
220
+ const opMap = {
221
+ _eq: '$eq',
222
+ _ne: '$ne',
223
+ _gt: '$gt',
224
+ _gte: '$gte',
225
+ _lt: '$lt',
226
+ _lte: '$lte',
227
+ _in: '$in',
228
+ _nin: '$nin'
229
+ };
230
+
231
+ const query = {};
232
+
233
+ for (const [key, filterVal] of Object.entries(filter)) {
234
+ if (filterVal === undefined || filterVal === null || filterVal === '') continue;
235
+ const { field, op } = parseFilterKey(key);
236
+
237
+ const path = field === 'createdBy' ? 'meta.createdBy'
238
+ : field === 'id' ? 'id'
239
+ : `data.${field}`;
240
+
241
+ query[path] = query[path] || {};
242
+
243
+ if (opMap[op]) {
244
+ const value = (op === '_in' || op === '_nin')
245
+ ? String(filterVal).split(',').map(s => maybeNumber(s.trim()))
246
+ : maybeNumber(filterVal);
247
+ query[path][opMap[op]] = value;
248
+ } else if (op === '_contains') {
249
+ query[path].$regex = escapeRegex(String(filterVal));
250
+ query[path].$options = 'i';
251
+ } else if (op === '_starts') {
252
+ query[path].$regex = '^' + escapeRegex(String(filterVal));
253
+ query[path].$options = 'i';
254
+ } else if (op === '_ends') {
255
+ query[path].$regex = escapeRegex(String(filterVal)) + '$';
256
+ query[path].$options = 'i';
257
+ } else if (op === '_exists') {
258
+ query[path].$exists = toBool(filterVal);
259
+ if (toBool(filterVal)) query[path].$ne = null;
260
+ }
261
+ }
262
+
263
+ // Collapse single-operator sub-docs that only contain $eq back to a flat value.
264
+ for (const [path, sub] of Object.entries(query)) {
265
+ if (sub && typeof sub === 'object' && Object.keys(sub).length === 1 && '$eq' in sub) {
266
+ query[path] = sub.$eq;
267
+ }
268
+ }
269
+
270
+ return query;
271
+ }
272
+
273
+ /**
274
+ * Escape regex metacharacters in a user-supplied substring.
275
+ *
276
+ * @param {string} s
277
+ * @returns {string}
278
+ */
279
+ function escapeRegex(s) {
280
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
281
+ }
@@ -6,6 +6,7 @@
6
6
  import fs from 'fs/promises';
7
7
  import path from 'path';
8
8
  import {fileURLToPath} from 'url';
9
+ import * as cache from './cache/index.js';
9
10
 
10
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
12
  const ROOT = path.resolve(__dirname, '..', '..');
@@ -43,6 +44,7 @@ export async function writeForm(slug, data) {
43
44
  await ensureFormsDir();
44
45
  const file = path.join(FORMS_DIR, `${slug}.json`);
45
46
  await fs.writeFile(file, JSON.stringify(data, null, 4) + '\n', 'utf8');
47
+ await cache.invalidateTags([`form:${slug}`]);
46
48
  }
47
49
 
48
50
  /**
@@ -78,6 +80,7 @@ export async function listForms() {
78
80
  */
79
81
  export async function deleteForm(slug) {
80
82
  await fs.unlink(path.join(FORMS_DIR, `${slug}.json`));
83
+ await cache.invalidateTags([`form:${slug}`]);
81
84
  }
82
85
 
83
86
  /**
@@ -46,6 +46,54 @@ export function getShortcodeProcessors() {
46
46
  return [..._shortcodes.values()].sort((a, b) => a.priority - b.priority);
47
47
  }
48
48
 
49
+ // ---------------------------------------------------------------------------
50
+ // Menu-location registry — delegated to server/services/menus.js
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Register a menu-location slot. Plugins call this from their setupPlugin to
55
+ * declare slots where a curated menu can be rendered (e.g. a sidebar widget).
56
+ * The slot then appears in the admin Locations editor and can be filled with
57
+ * any menu the admin chooses.
58
+ *
59
+ * @param {string} slot - lower-snake-case slot name (e.g. 'sidebar')
60
+ * @param {{label?:string, description?:string, maxDepth?:number}} def
61
+ */
62
+ export async function registerMenuLocation(slot, def) {
63
+ const {registerLocation} = await import('./menus.js');
64
+ registerLocation(slot, {...def, source: def?.source || 'plugin'});
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Admin sidebar items — plugin-registered runtime injection
69
+ // ---------------------------------------------------------------------------
70
+
71
+ const _registeredSidebarItems = [];
72
+
73
+ /**
74
+ * Register a sidebar item from a plugin. Items are merged into the rendered
75
+ * admin sidebar at render time — they are NOT persisted into menu JSON.
76
+ * Plugins re-register on each server boot via their setupPlugin hook.
77
+ *
78
+ * @param {{parent?: string, item: {text: string, url: string, icon?: string, permission?: string, items?: object[]}}} opts
79
+ */
80
+ export function registerSidebarItem(opts) {
81
+ if (!opts || !opts.item || typeof opts.item.text !== 'string') {
82
+ throw new Error('registerSidebarItem: opts.item.text is required');
83
+ }
84
+ _registeredSidebarItems.push({
85
+ parent: opts.parent || null,
86
+ item: opts.item
87
+ });
88
+ }
89
+
90
+ /**
91
+ * @returns {Array<{parent: string|null, item: object}>}
92
+ */
93
+ export function getRegisteredSidebarItems() {
94
+ return _registeredSidebarItems.slice();
95
+ }
96
+
49
97
  // ---------------------------------------------------------------------------
50
98
  // 3. Sanitize rule extensions
51
99
  // ---------------------------------------------------------------------------