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
|
@@ -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,
|
|
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}
|
|
260
|
-
* @param {number}
|
|
261
|
-
* @param {string}
|
|
262
|
-
* @param {string}
|
|
263
|
-
* @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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
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
|
// ---------------------------------------------------------------------------
|