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.
- package/CLAUDE.md +37 -3
- package/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/config/sidebar-config.js +1 -1
- package/admin/js/lib/crud-tutorial.js +1 -0
- package/admin/js/lib/markdown-toolbar.js +5 -5
- package/admin/js/lib/project-context.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +4 -0
- package/admin/js/templates/action-editor.html +7 -0
- package/admin/js/templates/block-editor.html +7 -0
- package/admin/js/templates/collection-editor.html +9 -0
- package/admin/js/templates/form-editor.html +9 -0
- package/admin/js/templates/menu-editor.html +98 -0
- package/admin/js/templates/menu-locations.html +14 -0
- package/admin/js/templates/menus.html +14 -0
- package/admin/js/templates/page-editor.html +9 -2
- package/admin/js/templates/project-detail.html +50 -0
- package/admin/js/templates/project-editor.html +45 -0
- package/admin/js/templates/project-settings.html +60 -0
- package/admin/js/templates/projects.html +13 -0
- package/admin/js/templates/role-editor.html +11 -0
- package/admin/js/templates/tutorials.html +335 -2
- package/admin/js/templates/view-editor.html +7 -0
- package/admin/js/views/action-editor.js +1 -1
- package/admin/js/views/actions-list.js +1 -1
- package/admin/js/views/block-editor.js +8 -8
- package/admin/js/views/blocks.js +2 -2
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/collections.js +1 -1
- package/admin/js/views/form-editor.js +5 -5
- package/admin/js/views/forms.js +1 -1
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/menu-editor.js +19 -0
- package/admin/js/views/menu-locations.js +1 -0
- package/admin/js/views/menus.js +5 -0
- package/admin/js/views/page-editor.js +24 -24
- package/admin/js/views/pages.js +3 -3
- package/admin/js/views/project-detail.js +4 -0
- package/admin/js/views/project-editor.js +1 -0
- package/admin/js/views/project-settings.js +1 -0
- package/admin/js/views/projects.js +7 -0
- package/admin/js/views/role-editor.js +1 -1
- package/admin/js/views/roles.js +3 -3
- package/admin/js/views/tutorials.js +1 -1
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +3 -3
- package/admin/js/views/view-editor.js +1 -1
- package/admin/js/views/views-list.js +1 -1
- package/config/menu-locations.json +5 -0
- package/config/menus/admin-sidebar.json +185 -0
- package/config/menus/footer.json +33 -0
- package/config/menus/main.json +35 -0
- package/config/menus/sproj-1779696558011-menu.json +17 -0
- package/config/menus/sproj-1779696960337-menu.json +18 -0
- package/config/menus/sproj-1779696985353-menu.json +18 -0
- package/config/site.json +6 -22
- package/package.json +4 -3
- package/plugins/analytics/daily.json +3 -0
- package/plugins/analytics/journeys.json +8 -0
- package/plugins/analytics/lifetime.json +1 -1
- package/public/css/site.css +1 -1
- package/public/js/collection-browser.js +4 -0
- package/public/js/forms.js +1 -1
- package/public/js/site.js +1 -1
- package/server/middleware/auth.js +88 -22
- package/server/routes/api/actions.js +58 -5
- package/server/routes/api/auth.js +2 -2
- package/server/routes/api/blocks.js +18 -3
- package/server/routes/api/collections.js +201 -8
- package/server/routes/api/forms.js +266 -21
- package/server/routes/api/menu-locations.js +46 -0
- package/server/routes/api/menus.js +115 -0
- package/server/routes/api/pages.js +1 -1
- package/server/routes/api/projects.js +107 -0
- package/server/routes/api/scaffold.js +86 -0
- package/server/routes/api/sidebar.js +23 -0
- package/server/routes/api/users.js +32 -7
- package/server/routes/api/views.js +10 -2
- package/server/routes/public.js +79 -6
- package/server/server.js +38 -0
- package/server/services/actions.js +137 -8
- package/server/services/adapters/FileAdapter.js +23 -8
- package/server/services/adapters/MongoAdapter.js +36 -18
- package/server/services/blocks.js +20 -8
- package/server/services/collections.js +85 -8
- package/server/services/content.js +23 -9
- package/server/services/filterEngine.js +281 -0
- package/server/services/hooks.js +48 -0
- package/server/services/markdown.js +686 -109
- package/server/services/menus-migration.js +107 -0
- package/server/services/menus.js +422 -0
- package/server/services/permissionRegistry.js +26 -0
- package/server/services/plugins.js +9 -2
- package/server/services/presetCollections.js +22 -0
- package/server/services/projects.js +429 -0
- package/server/services/recipes/contact-list.json +78 -0
- package/server/services/recipes/onboarding.json +426 -0
- package/server/services/references.js +174 -0
- package/server/services/renderer.js +237 -40
- package/server/services/roles.js +6 -1
- package/server/services/rowAccess.js +86 -13
- package/server/services/scaffolder.js +465 -0
- package/server/services/sidebar-migration.js +117 -0
- package/server/services/sitemap.js +112 -0
- package/server/services/userRoles.js +86 -0
- package/server/services/users.js +23 -2
- package/server/services/views.js +15 -4
- package/server/templates/page.html +7 -2
- /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
|
-
*
|
|
8
|
-
* updateField
|
|
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.
|
|
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: {
|
|
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: {
|
|
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,
|
|
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}
|
|
58
|
-
* @param {number}
|
|
59
|
-
* @param {string}
|
|
60
|
-
* @param {string}
|
|
61
|
-
* @param {string}
|
|
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
|
|
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,
|
|
65
|
+
* List entries with optional pagination, sorting, free-text search,
|
|
66
|
+
* and structured filtering.
|
|
65
67
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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}
|
|
74
|
-
* @param {number}
|
|
75
|
-
* @param {string}
|
|
76
|
-
* @param {string}
|
|
77
|
-
* @param {string}
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
104
|
-
|
|
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
|
|
107
|
-
|
|
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
|
|
263
|
-
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
|
|
290
|
-
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
|
-
|
|
331
|
-
|
|
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
|
});
|