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