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
@@ -121,7 +121,7 @@ export async function getCollection(slug) {
121
121
  * @returns {Promise<object>} Created schema
122
122
  * @throws {Error} If a collection with that slug already exists
123
123
  */
124
- 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}) {
125
125
  await ensureDir();
126
126
  const finalSlug = slug ? slugify(slug) : slugify(title);
127
127
  if (!finalSlug) throw new Error('Could not derive a slug from the title');
@@ -144,6 +144,7 @@ export async function createCollection({title, slug, description = '', fields =
144
144
  storage: storage || {adapter: 'file'},
145
145
  ...(bundled ? {bundled: true} : {}),
146
146
  ...(plugin ? {plugin} : {}),
147
+ ...(meta != null && {meta}),
147
148
  createdAt: now,
148
149
  updatedAt: now
149
150
  };
@@ -231,6 +232,10 @@ export async function deleteCollection(slug) {
231
232
  /**
232
233
  * Validate entry data against a collection schema.
233
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
+ *
234
239
  * @param {object} schema
235
240
  * @param {object} data
236
241
  * @returns {{ valid: boolean, errors: string[] }}
@@ -247,27 +252,93 @@ export function validateEntryData(schema, data) {
247
252
  return { valid: errors.length === 0, errors };
248
253
  }
249
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
+
250
292
  // ---------------------------------------------------------------------------
251
293
  // Entry operations — delegated to storage adapter
252
294
  // ---------------------------------------------------------------------------
253
295
 
254
296
  /**
255
- * 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 }
256
319
  *
257
320
  * @param {string} slug
258
321
  * @param {object} [opts]
259
- * @param {number} [opts.page=1]
260
- * @param {number} [opts.limit=50]
261
- * @param {string} [opts.sort='createdAt']
262
- * @param {string} [opts.order='desc']
263
- * @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
264
329
  * @returns {Promise<{ entries: object[], total: number, page: number, limit: number }>}
330
+ * @throws {Error} If collection slug does not exist
265
331
  */
266
332
  export async function listEntries(slug, opts = {}) {
267
333
  const schema = await getCollection(slug);
268
334
  if (!schema) throw new Error(`Collection "${slug}" not found`);
269
335
  const adapter = await getAdapter(slug);
270
- 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;
271
342
  }
272
343
 
273
344
  /**
@@ -300,6 +371,9 @@ export async function createEntry(slug, data, { createdBy = null, source = 'admi
300
371
  const { valid, errors } = validateEntryData(schema, data);
301
372
  if (!valid) throw new Error(`Validation failed: ${errors.join('; ')}`);
302
373
 
374
+ const refErrors = await validateReferences(schema, data);
375
+ if (refErrors.length) throw new Error(`Validation failed: ${refErrors.join('; ')}`);
376
+
303
377
  const now = new Date().toISOString();
304
378
  const entry = {
305
379
  id: uuidv4(),
@@ -329,6 +403,9 @@ export async function updateEntry(slug, entryId, data) {
329
403
  const { valid, errors } = validateEntryData(schema, data);
330
404
  if (!valid) throw new Error(`Validation failed: ${errors.join('; ')}`);
331
405
 
406
+ const refErrors = await validateReferences(schema, data);
407
+ if (refErrors.length) throw new Error(`Validation failed: ${refErrors.join('; ')}`);
408
+
332
409
  const adapter = await getAdapter(slug);
333
410
  const existing = await adapter.get(slug, entryId);
334
411
  if (!existing) throw new Error('Entry not found');
@@ -28,7 +28,14 @@ const MEDIA_DIR = config.content.mediaDir;
28
28
  export async function listPages() {
29
29
  const files = await collectMdFiles(PAGES_DIR);
30
30
  const pages = await Promise.all(files.map(filePath => readPageFile(filePath)));
31
- 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;
32
39
  }
33
40
 
34
41
  /**
@@ -36,12 +43,15 @@ export async function listPages() {
36
43
  * The empty string / '/' resolves to index.md.
37
44
  *
38
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.
39
49
  * @returns {Promise<object|null>}
40
50
  */
41
- export async function getPage(urlPath) {
51
+ export async function getPage(urlPath, opts = {}) {
42
52
  const filePath = await resolveExistingFilePath(urlPath);
43
53
  try {
44
- return await readPageFile(filePath);
54
+ return await readPageFile(filePath, {user: opts.user || null});
45
55
  } catch {
46
56
  return null;
47
57
  }
@@ -78,7 +88,7 @@ export async function createPage(urlPath, frontmatter, body) {
78
88
 
79
89
  await fs.writeFile(filePath, serialiseMarkdown(meta, body || ''), 'utf8');
80
90
  const page = await readPageFile(filePath);
81
- await cache.invalidateTags([`page:${urlPath}`]);
91
+ await cache.invalidateTags([`page:${urlPath}`, 'sitemap']);
82
92
  hooks.emit('content:pageCreated', {page, urlPath});
83
93
  return page;
84
94
  }
@@ -113,7 +123,7 @@ export async function updatePage(urlPath, frontmatter, body, {author} = {}) {
113
123
 
114
124
  await fs.writeFile(filePath, serialiseMarkdown(meta, body ?? existingContent), 'utf8');
115
125
  const page = await readPageFile(filePath);
116
- await cache.invalidateTags([`page:${urlPath}`]);
126
+ await cache.invalidateTags([`page:${urlPath}`, 'sitemap']);
117
127
  hooks.emit('content:pageUpdated', {page, urlPath});
118
128
  return page;
119
129
  }
@@ -131,7 +141,7 @@ export async function renamePage(oldUrlPath, newUrlPath) {
131
141
  const newFile = urlPathToFilePath(newUrlPath);
132
142
  await fs.mkdir(path.dirname(newFile), { recursive: true });
133
143
  await fs.rename(oldFile, newFile);
134
- await cache.invalidateTags([`page:${oldUrlPath}`, `page:${newUrlPath}`]);
144
+ await cache.invalidateTags([`page:${oldUrlPath}`, `page:${newUrlPath}`, 'sitemap']);
135
145
  try {
136
146
  await renameVersionDir(oldUrlPath, newUrlPath);
137
147
  } catch {
@@ -148,7 +158,7 @@ export async function renamePage(oldUrlPath, newUrlPath) {
148
158
  export async function deletePage(urlPath) {
149
159
  const filePath = await resolveExistingFilePath(urlPath);
150
160
  await fs.unlink(filePath);
151
- await cache.invalidateTags([`page:${urlPath}`]);
161
+ await cache.invalidateTags([`page:${urlPath}`, 'sitemap']);
152
162
  hooks.emit('content:pageDeleted', {urlPath});
153
163
  try {
154
164
  await deleteAllVersions(urlPath);
@@ -289,11 +299,15 @@ async function collectMdFiles(dir) {
289
299
  * Read and parse a single .md file, injecting the derived urlPath.
290
300
  *
291
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.
292
306
  * @returns {Promise<object>}
293
307
  */
294
- async function readPageFile(filePath) {
308
+ async function readPageFile(filePath, opts = {}) {
295
309
  const raw = await fs.readFile(filePath, 'utf8');
296
- const { data, content, html, usedComponents, tags } = await parseMarkdown(raw);
310
+ const { data, content, html, usedComponents, tags } = await parseMarkdown(raw, {user: opts.user || null});
297
311
  const urlPath = filePathToUrlPath(filePath);
298
312
  return { ...data, urlPath, content, html, usedComponents, tags };
299
313
  }
@@ -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
+ }
@@ -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
  // ---------------------------------------------------------------------------