domma-cms 0.18.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 (110) hide show
  1. package/CLAUDE.md +37 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/api.js +1 -1
  4. package/admin/js/app.js +4 -4
  5. package/admin/js/config/sidebar-config.js +1 -1
  6. package/admin/js/lib/crud-tutorial.js +1 -0
  7. package/admin/js/lib/markdown-toolbar.js +5 -5
  8. package/admin/js/lib/project-context.js +1 -0
  9. package/admin/js/lib/sidebar-renderer.js +4 -0
  10. package/admin/js/templates/action-editor.html +7 -0
  11. package/admin/js/templates/block-editor.html +7 -0
  12. package/admin/js/templates/collection-editor.html +9 -0
  13. package/admin/js/templates/form-editor.html +9 -0
  14. package/admin/js/templates/menu-editor.html +98 -0
  15. package/admin/js/templates/menu-locations.html +14 -0
  16. package/admin/js/templates/menus.html +14 -0
  17. package/admin/js/templates/page-editor.html +9 -2
  18. package/admin/js/templates/project-detail.html +50 -0
  19. package/admin/js/templates/project-editor.html +45 -0
  20. package/admin/js/templates/project-settings.html +60 -0
  21. package/admin/js/templates/projects.html +13 -0
  22. package/admin/js/templates/role-editor.html +11 -0
  23. package/admin/js/templates/tutorials.html +335 -2
  24. package/admin/js/templates/view-editor.html +7 -0
  25. package/admin/js/views/action-editor.js +1 -1
  26. package/admin/js/views/actions-list.js +1 -1
  27. package/admin/js/views/block-editor.js +8 -8
  28. package/admin/js/views/blocks.js +2 -2
  29. package/admin/js/views/collection-editor.js +4 -4
  30. package/admin/js/views/collections.js +1 -1
  31. package/admin/js/views/form-editor.js +5 -5
  32. package/admin/js/views/forms.js +1 -1
  33. package/admin/js/views/index.js +1 -1
  34. package/admin/js/views/menu-editor.js +19 -0
  35. package/admin/js/views/menu-locations.js +1 -0
  36. package/admin/js/views/menus.js +5 -0
  37. package/admin/js/views/page-editor.js +24 -24
  38. package/admin/js/views/pages.js +3 -3
  39. package/admin/js/views/project-detail.js +4 -0
  40. package/admin/js/views/project-editor.js +1 -0
  41. package/admin/js/views/project-settings.js +1 -0
  42. package/admin/js/views/projects.js +7 -0
  43. package/admin/js/views/role-editor.js +1 -1
  44. package/admin/js/views/roles.js +3 -3
  45. package/admin/js/views/tutorials.js +1 -1
  46. package/admin/js/views/user-editor.js +1 -1
  47. package/admin/js/views/users.js +3 -3
  48. package/admin/js/views/view-editor.js +1 -1
  49. package/admin/js/views/views-list.js +1 -1
  50. package/config/menu-locations.json +5 -0
  51. package/config/menus/admin-sidebar.json +185 -0
  52. package/config/menus/footer.json +33 -0
  53. package/config/menus/main.json +35 -0
  54. package/config/menus/sproj-1779696558011-menu.json +17 -0
  55. package/config/menus/sproj-1779696960337-menu.json +18 -0
  56. package/config/menus/sproj-1779696985353-menu.json +18 -0
  57. package/config/site.json +6 -22
  58. package/package.json +4 -3
  59. package/plugins/analytics/daily.json +3 -0
  60. package/plugins/analytics/journeys.json +8 -0
  61. package/plugins/analytics/lifetime.json +1 -1
  62. package/public/css/site.css +1 -1
  63. package/public/js/collection-browser.js +4 -0
  64. package/public/js/forms.js +1 -1
  65. package/public/js/site.js +1 -1
  66. package/server/middleware/auth.js +88 -22
  67. package/server/routes/api/actions.js +58 -5
  68. package/server/routes/api/auth.js +2 -2
  69. package/server/routes/api/blocks.js +18 -3
  70. package/server/routes/api/collections.js +201 -8
  71. package/server/routes/api/forms.js +266 -21
  72. package/server/routes/api/menu-locations.js +46 -0
  73. package/server/routes/api/menus.js +115 -0
  74. package/server/routes/api/pages.js +1 -1
  75. package/server/routes/api/projects.js +107 -0
  76. package/server/routes/api/scaffold.js +86 -0
  77. package/server/routes/api/sidebar.js +23 -0
  78. package/server/routes/api/users.js +32 -7
  79. package/server/routes/api/views.js +10 -2
  80. package/server/routes/public.js +79 -6
  81. package/server/server.js +38 -0
  82. package/server/services/actions.js +137 -8
  83. package/server/services/adapters/FileAdapter.js +23 -8
  84. package/server/services/adapters/MongoAdapter.js +36 -18
  85. package/server/services/blocks.js +20 -8
  86. package/server/services/collections.js +85 -8
  87. package/server/services/content.js +23 -9
  88. package/server/services/filterEngine.js +281 -0
  89. package/server/services/hooks.js +48 -0
  90. package/server/services/markdown.js +686 -109
  91. package/server/services/menus-migration.js +107 -0
  92. package/server/services/menus.js +422 -0
  93. package/server/services/permissionRegistry.js +26 -0
  94. package/server/services/plugins.js +9 -2
  95. package/server/services/presetCollections.js +22 -0
  96. package/server/services/projects.js +429 -0
  97. package/server/services/recipes/contact-list.json +78 -0
  98. package/server/services/recipes/onboarding.json +426 -0
  99. package/server/services/references.js +174 -0
  100. package/server/services/renderer.js +237 -40
  101. package/server/services/roles.js +6 -1
  102. package/server/services/rowAccess.js +86 -13
  103. package/server/services/scaffolder.js +465 -0
  104. package/server/services/sidebar-migration.js +117 -0
  105. package/server/services/sitemap.js +112 -0
  106. package/server/services/userRoles.js +86 -0
  107. package/server/services/users.js +23 -2
  108. package/server/services/views.js +15 -4
  109. package/server/templates/page.html +7 -2
  110. /package/config/{navigation.json → navigation.json.bak} +0 -0
package/server/server.js CHANGED
@@ -12,6 +12,7 @@ import cors from '@fastify/cors';
12
12
  import multipart from '@fastify/multipart';
13
13
  import rateLimit from '@fastify/rate-limit';
14
14
  import helmet from '@fastify/helmet';
15
+ import compress from '@fastify/compress';
15
16
  import path from 'path';
16
17
  import fs from 'fs/promises';
17
18
  import {fileURLToPath} from 'url';
@@ -105,6 +106,14 @@ await app.register(rateLimit, {
105
106
  allowList: ['127.0.0.1', '::1', '::ffff:127.0.0.1'],
106
107
  });
107
108
 
109
+ // Negotiates brotli/gzip/deflate per Accept-Encoding. Skips already-compressed
110
+ // content types (images, fonts, video) and responses under threshold.
111
+ await app.register(compress, {
112
+ global: true,
113
+ threshold: 1024,
114
+ encodings: ['br', 'gzip', 'deflate']
115
+ });
116
+
108
117
  // ---------------------------------------------------------------------------
109
118
  // Static Assets
110
119
  // ---------------------------------------------------------------------------
@@ -179,6 +188,24 @@ await seedDefaultBlocks();
179
188
  await seedDefaultComponents();
180
189
  await refreshComponentTagAllowlist();
181
190
 
191
+ // ---------------------------------------------------------------------------
192
+ // Menus — one-shot migration from legacy navigation.json + site footer links
193
+ // ---------------------------------------------------------------------------
194
+
195
+ try {
196
+ const {runMigration: runMenusMigration} = await import('./services/menus-migration.js');
197
+ await runMenusMigration();
198
+ } catch (err) {
199
+ app.log.warn(`[menus] Migration skipped: ${err.message}`);
200
+ }
201
+
202
+ try {
203
+ const {runMigration: runSidebarMigration} = await import('./services/sidebar-migration.js');
204
+ await runSidebarMigration();
205
+ } catch (err) {
206
+ app.log.warn(`[admin-sidebar] Migration skipped: ${err.message}`);
207
+ }
208
+
182
209
  // Initialise response cache (Memory/None/Redis driver per config.cache).
183
210
  await cache.initCache(config.cache);
184
211
  console.log(`[cache] driver=${cache.isEnabled() ? config.cache.driver : 'disabled'}`);
@@ -247,6 +274,10 @@ const { pagesRoutes } = await import('./routes/api/pages.js');
247
274
  const { settingsRoutes } = await import('./routes/api/settings.js');
248
275
  const { layoutsRoutes } = await import('./routes/api/layouts.js');
249
276
  const { navigationRoutes } = await import('./routes/api/navigation.js');
277
+ const {menusRoutes} = await import('./routes/api/menus.js');
278
+ const {menuLocationsRoutes} = await import('./routes/api/menu-locations.js');
279
+ const {projectsRoutes} = await import('./routes/api/projects.js');
280
+ const {sidebarRoutes} = await import('./routes/api/sidebar.js');
250
281
  const { mediaRoutes } = await import('./routes/api/media.js');
251
282
  const { usersRoutes } = await import('./routes/api/users.js');
252
283
  const { pluginsRoutes } = await import('./routes/api/plugins.js');
@@ -262,11 +293,16 @@ const {effectsRoutes} = await import('./routes/api/effects.js');
262
293
  const {notificationsRoutes} = await import('./routes/api/notifications.js');
263
294
  const {dashboardRoutes} = await import('./routes/api/dashboard.js');
264
295
  const {cacheRoutes} = await import('./routes/api/cache.js');
296
+ const {scaffoldRoutes} = await import('./routes/api/scaffold.js');
265
297
 
266
298
  await app.register(pagesRoutes, { prefix: '/api' });
267
299
  await app.register(settingsRoutes, { prefix: '/api' });
268
300
  await app.register(layoutsRoutes, { prefix: '/api' });
269
301
  await app.register(navigationRoutes, { prefix: '/api' });
302
+ await app.register(menusRoutes, {prefix: '/api'});
303
+ await app.register(menuLocationsRoutes, {prefix: '/api'});
304
+ await app.register(projectsRoutes, {prefix: '/api'});
305
+ await app.register(sidebarRoutes, {prefix: '/api'});
270
306
  await app.register(mediaRoutes, { prefix: '/api' });
271
307
  await app.register(usersRoutes, { prefix: '/api' });
272
308
  await app.register(pluginsRoutes, { prefix: '/api' });
@@ -282,6 +318,7 @@ await app.register(effectsRoutes, {prefix: '/api'});
282
318
  await app.register(notificationsRoutes, {prefix: '/api'});
283
319
  await app.register(dashboardRoutes, {prefix: '/api'});
284
320
  await app.register(cacheRoutes, {prefix: '/api'});
321
+ await app.register(scaffoldRoutes, {prefix: '/api'});
285
322
 
286
323
  // ---------------------------------------------------------------------------
287
324
  // CMS Plugins (server-side Fastify plugins from plugins/ directory)
@@ -338,3 +375,4 @@ try {
338
375
  app.log.error(err);
339
376
  process.exit(1);
340
377
  }
378
+ // scaffold-restart 1779615684
@@ -4,13 +4,23 @@
4
4
  * Action configs are stored in MongoDB `cms__actions` on the 'default' connection.
5
5
  * Executing an action runs a sequence of steps against a target collection entry.
6
6
  *
7
- * Phase 1 step types:
8
- * updateField, deleteEntry, moveToCollection, webhook, email
7
+ * Supported step types:
8
+ * updateField overwrite one field on the source entry
9
+ * deleteEntry — remove the source entry
10
+ * moveToCollection — copy source entry into target collection AND delete source
11
+ * createInCollection — create a new entry in another collection; source untouched
12
+ * (used for apply/follow/bookmark/upvote-style relations)
13
+ * webhook — POST/PUT/GET to an external URL with interpolated body
14
+ * email — send a transactional email via configured SMTP
9
15
  *
10
16
  * Template interpolation in step configs:
11
- * {{entry.data.field}} → entry field value
17
+ * {{entry.id}} source entry id
18
+ * {{entry.data.field}} → source entry field value
12
19
  * {{now}} → current ISO timestamp
20
+ * {{user.id}} → executing user's id
13
21
  * {{user.name}} → executing user's name
22
+ * {{user.email}} → executing user's email
23
+ * {{user.role}} → executing user's role
14
24
  * {{env.CMS_PUBLIC_*}} → environment variables with CMS_PUBLIC_ prefix only
15
25
  *
16
26
  * Note: execution is not transactional — partial results are returned on failure
@@ -140,6 +150,35 @@ async function executeStep(step, collection, entry, context) {
140
150
  return { moved: entry.id, to: target };
141
151
  }
142
152
 
153
+ /*
154
+ * createInCollection — append an entry to another collection without
155
+ * touching the source. Designed for relational patterns like:
156
+ * - Apply for job → create entry in `applications` { jobId, candidateId, status }
157
+ * - Bookmark page → create entry in `bookmarks` { pageId, userId }
158
+ * - Upvote item → create entry in `votes` { itemId, userId, value: 1 }
159
+ *
160
+ * Config:
161
+ * targetCollection {string} required — slug of the collection to append to
162
+ * data {object} required — field map; values are template-interpolated
163
+ * createdBy {string} optional — '{{user.id}}' is the conventional value;
164
+ * enables row-level "mine" filtering downstream
165
+ *
166
+ * Returns: { created: <new entry id>, in: <targetCollection> }
167
+ */
168
+ case 'createInCollection': {
169
+ const target = cfg.targetCollection;
170
+ if (!target) throw new Error('createInCollection: targetCollection is required');
171
+ if (!cfg.data || typeof cfg.data !== 'object') {
172
+ throw new Error('createInCollection: data object is required');
173
+ }
174
+ const data = interpolateValue(cfg.data, context);
175
+ const createdBy = cfg.createdBy
176
+ ? interpolate(String(cfg.createdBy), context)
177
+ : (context.user?.id || null);
178
+ const created = await createEntry(target, data, { createdBy, source: 'action' });
179
+ return { created: created.id, in: target };
180
+ }
181
+
143
182
  case 'webhook': {
144
183
  const url = interpolate(cfg.url || '', context);
145
184
  const method = (cfg.method || 'POST').toUpperCase();
@@ -239,7 +278,7 @@ export async function getAction(slug) {
239
278
  */
240
279
  export async function createAction(data, userId = null) {
241
280
  const db = await getMetaDb();
242
- const { title, description = '', collection, trigger, steps = [], access } = data;
281
+ const { title, description = '', collection, trigger, steps = [], access, transition, meta: inputMeta } = data;
243
282
 
244
283
  if (!title) throw new Error('title is required');
245
284
  if (!collection) throw new Error('collection is required');
@@ -264,11 +303,25 @@ export async function createAction(data, userId = null) {
264
303
  confirmMessage: trigger?.confirmMessage || null
265
304
  },
266
305
  steps,
306
+ // State-machine transition — when set, the action is gated by the
307
+ // entry's current value of `field` matching one of the `from` values,
308
+ // and is advertised as an "available transition" for that state.
309
+ // Optional `to` is informational only (steps still do the actual write).
310
+ transition: transition ? {
311
+ field: transition.field,
312
+ from: Array.isArray(transition.from) ? transition.from : [transition.from].filter(Boolean),
313
+ to: transition.to
314
+ } : null,
267
315
  access: {
268
316
  roles: access?.roles || ['admin', 'super-admin'],
269
317
  rowLevel: access?.rowLevel || null
270
318
  },
271
- meta: { createdAt: now, updatedAt: now, createdBy: userId }
319
+ meta: {
320
+ createdAt: now,
321
+ updatedAt: now,
322
+ createdBy: userId,
323
+ ...(inputMeta?.project != null && {project: inputMeta.project})
324
+ }
272
325
  };
273
326
 
274
327
  await db.collection(ACTIONS_COLLECTION).insertOne({ ...action });
@@ -288,12 +341,18 @@ export async function updateAction(slug, data) {
288
341
  const existing = await db.collection(ACTIONS_COLLECTION).findOne({ slug });
289
342
  if (!existing) throw new Error(`Action "${slug}" not found`);
290
343
 
291
- const { _id, id, meta, ...rest } = data;
344
+ const { _id, id, meta: inputMeta, ...rest } = data;
292
345
  const updated = {
293
346
  ...existing,
294
347
  ...rest,
295
348
  slug,
296
- meta: { ...existing.meta, updatedAt: new Date().toISOString() }
349
+ meta: {
350
+ ...existing.meta,
351
+ ...(inputMeta && typeof inputMeta === 'object'
352
+ ? ('project' in inputMeta ? {project: inputMeta.project} : {})
353
+ : {}),
354
+ updatedAt: new Date().toISOString()
355
+ }
297
356
  };
298
357
 
299
358
  await db.collection(ACTIONS_COLLECTION).replaceOne({ slug }, { ...updated });
@@ -328,6 +387,59 @@ export async function listActionsForCollection(collectionSlug) {
328
387
  .toArray();
329
388
  }
330
389
 
390
+ /**
391
+ * List the actions that represent valid next-state transitions for a given
392
+ * entry as seen by a given user. Used by the per-row transitions UI in the
393
+ * Collection Browser and the `[transitions]` shortcode.
394
+ *
395
+ * Criteria for inclusion:
396
+ * 1. action.collection matches the entry's collection
397
+ * 2. action.transition is set and action.transition.from includes the
398
+ * entry's current value of action.transition.field
399
+ * 3. action.access.roles allows the user (compared by role level so
400
+ * higher-privileged roles inherit access automatically)
401
+ *
402
+ * Returns a stripped projection — slug, title, trigger config, transition —
403
+ * so the client can render buttons without leaking step internals.
404
+ *
405
+ * @param {string} collectionSlug
406
+ * @param {object} entry - The entry being inspected (needed for field-value match)
407
+ * @param {object} [user] - { role } — anonymous if missing
408
+ * @returns {Promise<object[]>}
409
+ */
410
+ export async function listTransitionsForEntry(collectionSlug, entry, user = null) {
411
+ const all = await listActionsForCollection(collectionSlug);
412
+ if (!Array.isArray(all) || !all.length) return [];
413
+
414
+ const { getRoleLevel } = await import('./roles.js');
415
+ const { getEffectiveLevel } = await import('./userRoles.js');
416
+ // Use effective level so multi-role users see transitions any of their
417
+ // roles permit — e.g. a user who's primarily a candidate but also an
418
+ // admin sees BOTH the candidate-only "withdraw" AND the admin-only
419
+ // "approve" transitions on the same entry.
420
+ const userLevel = getEffectiveLevel(user);
421
+
422
+ return all
423
+ .filter(a => {
424
+ if (!a.transition) return false;
425
+ const allowedFrom = Array.isArray(a.transition.from) ? a.transition.from : [a.transition.from].filter(Boolean);
426
+ if (allowedFrom.length) {
427
+ const current = entry.data?.[a.transition.field];
428
+ if (!allowedFrom.includes(current)) return false;
429
+ }
430
+ const roles = a.access?.roles || ['admin', 'super-admin'];
431
+ const minLevel = Math.min(...roles.map(r => getRoleLevel(r)));
432
+ if (!(userLevel <= minLevel)) return false;
433
+ return true;
434
+ })
435
+ .map(a => ({
436
+ slug: a.slug,
437
+ title: a.title,
438
+ trigger: a.trigger,
439
+ transition: a.transition
440
+ }));
441
+ }
442
+
331
443
  // ---------------------------------------------------------------------------
332
444
  // Execution
333
445
  // ---------------------------------------------------------------------------
@@ -351,12 +463,29 @@ export async function executeAction(slug, entryId, { user = null } = {}) {
351
463
  const entry = await getEntry(action.collection, entryId);
352
464
  if (!entry) throw new Error(`Entry "${entryId}" not found in collection "${action.collection}"`);
353
465
 
354
- if (!checkEntryAccess(entry, user, action.access?.rowLevel)) {
466
+ if (!(await checkEntryAccess(entry, user, action.access?.rowLevel))) {
355
467
  const err = new Error('Row-level access denied');
356
468
  err.statusCode = 403;
357
469
  throw err;
358
470
  }
359
471
 
472
+ // State-machine guard — an action with a transition config only runs when
473
+ // the entry's current value of `field` is in `from`. Stops illegal moves
474
+ // like "withdraw" on an already-rejected application; the API returns 409
475
+ // so the client can present a meaningful "no longer available" message
476
+ // (we use 409 = Conflict rather than 403 because the request itself was
477
+ // authorised, just inconsistent with current state).
478
+ if (action.transition) {
479
+ const t = action.transition;
480
+ const currentValue = entry.data?.[t.field];
481
+ const allowed = Array.isArray(t.from) ? t.from : (t.from ? [t.from] : []);
482
+ if (allowed.length && !allowed.includes(currentValue)) {
483
+ const err = new Error(`Cannot apply "${action.title}" — current ${t.field} is "${currentValue ?? '(none)'}", allowed: ${allowed.join(', ')}`);
484
+ err.statusCode = 409;
485
+ throw err;
486
+ }
487
+ }
488
+
360
489
  const context = {
361
490
  entry,
362
491
  now: new Date().toISOString(),
@@ -19,6 +19,7 @@
19
19
  import fs from 'fs/promises';
20
20
  import path from 'path';
21
21
  import { config } from '../../config.js';
22
+ import { applyFilter } from '../filterEngine.js';
22
23
 
23
24
  const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
24
25
 
@@ -50,18 +51,25 @@ async function write(slug, entries) {
50
51
  export class FileAdapter {
51
52
 
52
53
  /**
53
- * List entries with optional pagination, sorting, and search.
54
+ * List entries with optional pagination, sorting, free-text search,
55
+ * and structured filtering.
56
+ *
57
+ * `search` is a simple substring match across all field values (legacy
58
+ * behaviour, kept for the admin search box). `filter` is the structured
59
+ * DSL — see `filterEngine.js` for operator syntax. Both may be combined;
60
+ * search runs first, then filter.
54
61
  *
55
62
  * @param {string} slug
56
63
  * @param {object} [opts]
57
- * @param {number} [opts.page=1]
58
- * @param {number} [opts.limit=50]
59
- * @param {string} [opts.sort='createdAt']
60
- * @param {string} [opts.order='desc']
61
- * @param {string} [opts.search]
64
+ * @param {number} [opts.page=1]
65
+ * @param {number} [opts.limit=50] - Use 0 for "no limit" (returns all)
66
+ * @param {string} [opts.sort='createdAt']
67
+ * @param {string} [opts.order='desc'] - 'asc' | 'desc'
68
+ * @param {string} [opts.search] - Free-text substring across all fields
69
+ * @param {Record<string, unknown>} [opts.filter] - Structured filter; see filterEngine.js
62
70
  * @returns {Promise<{ entries: object[], total: number, page: number, limit: number }>}
63
71
  */
64
- async list(slug, { page = 1, limit = 50, sort = 'createdAt', order = 'desc', search } = {}) {
72
+ async list(slug, { page = 1, limit = 50, sort = 'createdAt', order = 'desc', search, filter } = {}) {
65
73
  let entries = await read(slug);
66
74
 
67
75
  if (search) {
@@ -71,6 +79,10 @@ export class FileAdapter {
71
79
  );
72
80
  }
73
81
 
82
+ if (filter) {
83
+ entries = applyFilter(entries, filter);
84
+ }
85
+
74
86
  entries.sort((a, b) => {
75
87
  const aVal = sort === 'createdAt' ? a.meta?.createdAt : (a.data?.[sort] ?? '');
76
88
  const bVal = sort === 'createdAt' ? b.meta?.createdAt : (b.data?.[sort] ?? '');
@@ -78,7 +90,10 @@ export class FileAdapter {
78
90
  return order === 'desc' ? -cmp : cmp;
79
91
  });
80
92
 
81
- const total = entries.length;
93
+ const total = entries.length;
94
+ if (limit === 0 || limit == null) {
95
+ return { entries, total, page: 1, limit: total };
96
+ }
82
97
  const offset = (page - 1) * limit;
83
98
  return { entries: entries.slice(offset, offset + limit), total, page, limit };
84
99
  }
@@ -18,6 +18,7 @@
18
18
  * insertMany(slug, entries) → { imported, skipped, errors }
19
19
  * count(slug) → number
20
20
  */
21
+ import { toMongoQuery } from '../filterEngine.js';
21
22
 
22
23
  /** Prefix applied to all CMS collection names in MongoDB to avoid collisions. */
23
24
  const PREFIX = 'cms_';
@@ -61,32 +62,39 @@ export class MongoAdapter {
61
62
  }
62
63
 
63
64
  /**
64
- * List entries with optional pagination, sorting, and search.
65
+ * List entries with optional pagination, sorting, free-text search,
66
+ * and structured filtering.
65
67
  *
66
- * When no search term is provided, pagination and sorting are pushed to MongoDB
67
- * for efficiency. When a search term is provided, all documents are fetched and
68
- * filtered in memory identical behaviour to FileAdapter. For large collections
69
- * requiring efficient full-text search, configure a MongoDB Atlas Search index.
68
+ * `filter` is translated to native MongoDB query operators via
69
+ * `filterEngine.toMongoQuery()` and pushed down to the database page
70
+ * authors writing `[collection where_salary_gte="50000"]` get index-aware
71
+ * queries automatically. When `search` is present, all documents are
72
+ * fetched and filtered in memory (same behaviour as FileAdapter, kept
73
+ * for parity); combine with `filter` to keep the in-memory set small.
70
74
  *
71
75
  * @param {string} slug
72
76
  * @param {object} [opts]
73
- * @param {number} [opts.page=1]
74
- * @param {number} [opts.limit=50]
75
- * @param {string} [opts.sort='createdAt']
76
- * @param {string} [opts.order='desc']
77
- * @param {string} [opts.search]
77
+ * @param {number} [opts.page=1]
78
+ * @param {number} [opts.limit=50] - Use 0 for "no limit"
79
+ * @param {string} [opts.sort='createdAt']
80
+ * @param {string} [opts.order='desc'] - 'asc' | 'desc'
81
+ * @param {string} [opts.search] - Free-text substring across all fields
82
+ * @param {Record<string, unknown>} [opts.filter] - Structured filter; see filterEngine.js
78
83
  * @returns {Promise<{ entries: object[], total: number, page: number, limit: number }>}
79
84
  */
80
- async list(slug, { page = 1, limit = 50, sort = 'createdAt', order = 'desc', search } = {}) {
85
+ async list(slug, { page = 1, limit = 50, sort = 'createdAt', order = 'desc', search, filter } = {}) {
81
86
  const col = await this._col(slug);
82
87
 
83
88
  const sortField = sort === 'createdAt' ? 'meta.createdAt' : `data.${sort}`;
84
89
  const sortDir = order === 'desc' ? -1 : 1;
90
+ const query = filter ? toMongoQuery(filter) : {};
85
91
 
86
92
  if (search) {
87
- // Fetch all and filter in memory to avoid $where (often disabled in Atlas).
93
+ // Fetch matching (by filter) docs and substring-match in memory.
94
+ // Atlas Search is the right tool for large datasets; this is the
95
+ // portable fallback that matches FileAdapter semantics exactly.
88
96
  const all = await col
89
- .find({}, { projection: { _id: 0 } })
97
+ .find(query, { projection: { _id: 0 } })
90
98
  .sort({ [sortField]: sortDir })
91
99
  .toArray();
92
100
 
@@ -95,16 +103,26 @@ export class MongoAdapter {
95
103
  Object.values(entry.data || {}).some(v => String(v).toLowerCase().includes(term))
96
104
  );
97
105
 
98
- const total = matched.length;
106
+ const total = matched.length;
107
+ if (limit === 0 || limit == null) {
108
+ return { entries: matched, total, page: 1, limit: total };
109
+ }
99
110
  const offset = (page - 1) * limit;
100
111
  return { entries: matched.slice(offset, offset + limit), total, page, limit };
101
112
  }
102
113
 
103
- const total = await col.countDocuments({});
104
- const offset = (page - 1) * limit;
114
+ const total = await col.countDocuments(query);
115
+ if (limit === 0 || limit == null) {
116
+ const docs = await col
117
+ .find(query, { projection: { _id: 0 } })
118
+ .sort({ [sortField]: sortDir })
119
+ .toArray();
120
+ return { entries: docs, total, page: 1, limit: total };
121
+ }
105
122
 
106
- const docs = await col
107
- .find({}, { projection: { _id: 0 } })
123
+ const offset = (page - 1) * limit;
124
+ const docs = await col
125
+ .find(query, { projection: { _id: 0 } })
108
126
  .sort({ [sortField]: sortDir })
109
127
  .skip(offset)
110
128
  .limit(limit)
@@ -258,9 +258,12 @@ export async function listBlocks() {
258
258
  const name = file.slice(0, -5);
259
259
  const fileStat = await fs.stat(path.join(BLOCKS_DIR, file)).catch(() => null);
260
260
  let bundled = false;
261
+ let meta = null;
261
262
  try {
262
- const meta = JSON.parse(await fs.readFile(path.join(BLOCKS_DIR, `${name}.meta.json`), 'utf8'));
263
- bundled = !!meta.bundled;
263
+ const m = JSON.parse(await fs.readFile(path.join(BLOCKS_DIR, `${name}.meta.json`), 'utf8'));
264
+ bundled = !!m.bundled;
265
+ // Surface the full sidecar so callers can filter by meta.project etc.
266
+ meta = m;
264
267
  } catch { /* no meta file */
265
268
  }
266
269
  blocks.push({
@@ -268,6 +271,7 @@ export async function listBlocks() {
268
271
  size: fileStat?.size ?? 0,
269
272
  updatedAt: fileStat?.mtime?.toISOString() ?? null,
270
273
  bundled,
274
+ ...(meta && {meta}),
271
275
  });
272
276
  }
273
277
  return blocks.sort((a, b) => a.name.localeCompare(b.name));
@@ -285,9 +289,12 @@ export async function getBlock(name) {
285
289
  try {
286
290
  const content = await fs.readFile(blockFilePath(name), 'utf8');
287
291
  let bundled = false;
292
+ let meta = null;
288
293
  try {
289
- const meta = JSON.parse(await fs.readFile(blockMetaPath(name), 'utf8'));
290
- bundled = !!meta.bundled;
294
+ const metaFile = JSON.parse(await fs.readFile(blockMetaPath(name), 'utf8'));
295
+ bundled = !!metaFile.bundled;
296
+ // Preserve the full meta sidecar so callers can read meta.project etc.
297
+ meta = metaFile;
291
298
  } catch { /* no meta file */
292
299
  }
293
300
  let css = '';
@@ -295,7 +302,7 @@ export async function getBlock(name) {
295
302
  css = await fs.readFile(blockCssPath(name), 'utf8');
296
303
  } catch { /* no CSS file — treat as empty */
297
304
  }
298
- return {name, content, css, bundled};
305
+ return {name, content, css, bundled, ...(meta && {meta})};
299
306
  } catch (err) {
300
307
  if (err.code === 'ENOENT') {
301
308
  const notFound = new Error('Block not found');
@@ -317,7 +324,7 @@ export async function getBlock(name) {
317
324
  * @returns {Promise<{success: boolean, name: string}>}
318
325
  * @throws {Error} With code INVALID_NAME on bad name, or CSS_TOO_LARGE when CSS exceeds cap
319
326
  */
320
- export async function saveBlock(name, content, {bundled, css} = {}) {
327
+ export async function saveBlock(name, content, {bundled, css, meta} = {}) {
321
328
  assertValidName(name);
322
329
  if (typeof css === 'string' && css.length > MAX_CSS_SIZE) {
323
330
  const err = new Error(`CSS exceeds ${MAX_CSS_SIZE} byte limit`);
@@ -327,8 +334,13 @@ export async function saveBlock(name, content, {bundled, css} = {}) {
327
334
  await fs.mkdir(BLOCKS_DIR, {recursive: true});
328
335
  await fs.writeFile(blockFilePath(name), content, 'utf8');
329
336
  const metaPath = blockMetaPath(name);
330
- if (bundled) {
331
- await fs.writeFile(metaPath, JSON.stringify({bundled: true}, null, 2) + '\n', 'utf8');
337
+ // Merge sidecar meta — bundled flag plus any caller-supplied fields (eg meta.project).
338
+ const sidecar = {
339
+ ...(meta && typeof meta === 'object' ? meta : {}),
340
+ ...(bundled ? {bundled: true} : {})
341
+ };
342
+ if (Object.keys(sidecar).length > 0) {
343
+ await fs.writeFile(metaPath, JSON.stringify(sidecar, null, 2) + '\n', 'utf8');
332
344
  } else {
333
345
  await fs.unlink(metaPath).catch(() => {
334
346
  });