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.
Files changed (136) hide show
  1. package/CLAUDE.md +39 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/css/dashboard.css +1 -1
  4. package/admin/index.html +2 -2
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/config/sidebar-config.js +1 -1
  8. package/admin/js/lib/card-builder.js +3 -3
  9. package/admin/js/lib/crud-tutorial.js +1 -0
  10. package/admin/js/lib/effects-builder.js +1 -1
  11. package/admin/js/lib/markdown-toolbar.js +6 -6
  12. package/admin/js/lib/project-context.js +1 -0
  13. package/admin/js/lib/sidebar-renderer.js +4 -0
  14. package/admin/js/templates/action-editor.html +7 -0
  15. package/admin/js/templates/block-editor.html +7 -0
  16. package/admin/js/templates/collection-editor.html +9 -0
  17. package/admin/js/templates/dashboard/cache.html +32 -0
  18. package/admin/js/templates/dashboard.html +4 -0
  19. package/admin/js/templates/form-editor.html +9 -0
  20. package/admin/js/templates/menu-editor.html +98 -0
  21. package/admin/js/templates/menu-locations.html +14 -0
  22. package/admin/js/templates/menus.html +14 -0
  23. package/admin/js/templates/page-editor.html +9 -2
  24. package/admin/js/templates/project-detail.html +50 -0
  25. package/admin/js/templates/project-editor.html +45 -0
  26. package/admin/js/templates/project-settings.html +60 -0
  27. package/admin/js/templates/projects.html +13 -0
  28. package/admin/js/templates/role-editor.html +11 -0
  29. package/admin/js/templates/settings.html +26 -0
  30. package/admin/js/templates/tutorials.html +335 -2
  31. package/admin/js/templates/view-editor.html +7 -0
  32. package/admin/js/views/action-editor.js +1 -1
  33. package/admin/js/views/actions-list.js +1 -1
  34. package/admin/js/views/block-editor-enhance.js +1 -1
  35. package/admin/js/views/block-editor.js +8 -8
  36. package/admin/js/views/blocks.js +2 -2
  37. package/admin/js/views/collection-editor.js +4 -4
  38. package/admin/js/views/collections.js +1 -1
  39. package/admin/js/views/dashboard/widgets/activity-feed.js +1 -1
  40. package/admin/js/views/dashboard/widgets/cache.js +1 -0
  41. package/admin/js/views/dashboard/widgets/journeys.js +1 -1
  42. package/admin/js/views/dashboard/widgets/spike-feed.js +1 -1
  43. package/admin/js/views/dashboard/widgets/top-pages.js +1 -1
  44. package/admin/js/views/dashboard.js +1 -1
  45. package/admin/js/views/form-editor.js +6 -6
  46. package/admin/js/views/forms.js +1 -1
  47. package/admin/js/views/index.js +1 -1
  48. package/admin/js/views/menu-editor.js +19 -0
  49. package/admin/js/views/menu-locations.js +1 -0
  50. package/admin/js/views/menus.js +5 -0
  51. package/admin/js/views/page-editor.js +41 -36
  52. package/admin/js/views/pages.js +3 -3
  53. package/admin/js/views/project-detail.js +4 -0
  54. package/admin/js/views/project-editor.js +1 -0
  55. package/admin/js/views/project-settings.js +1 -0
  56. package/admin/js/views/projects.js +7 -0
  57. package/admin/js/views/role-editor.js +1 -1
  58. package/admin/js/views/roles.js +3 -3
  59. package/admin/js/views/settings.js +3 -3
  60. package/admin/js/views/tutorials.js +1 -1
  61. package/admin/js/views/user-editor.js +1 -1
  62. package/admin/js/views/users.js +3 -3
  63. package/admin/js/views/view-editor.js +1 -1
  64. package/admin/js/views/views-list.js +1 -1
  65. package/config/cache.json +4 -0
  66. package/config/cache.json.example +12 -0
  67. package/config/menu-locations.json +5 -0
  68. package/config/menus/admin-sidebar.json +185 -0
  69. package/config/menus/footer.json +33 -0
  70. package/config/menus/main.json +35 -0
  71. package/config/menus/sproj-1779696558011-menu.json +17 -0
  72. package/config/menus/sproj-1779696960337-menu.json +18 -0
  73. package/config/menus/sproj-1779696985353-menu.json +18 -0
  74. package/config/site.json +6 -22
  75. package/package.json +4 -3
  76. package/plugins/analytics/daily.json +3 -0
  77. package/plugins/analytics/journeys.json +8 -0
  78. package/plugins/analytics/lifetime.json +1 -1
  79. package/public/css/site.css +1 -1
  80. package/public/js/collection-browser.js +4 -0
  81. package/public/js/forms.js +1 -1
  82. package/public/js/site.js +1 -1
  83. package/server/config.js +12 -1
  84. package/server/middleware/auth.js +88 -22
  85. package/server/routes/api/actions.js +58 -5
  86. package/server/routes/api/auth.js +2 -2
  87. package/server/routes/api/blocks.js +18 -3
  88. package/server/routes/api/cache.js +57 -0
  89. package/server/routes/api/collections.js +201 -8
  90. package/server/routes/api/forms.js +266 -21
  91. package/server/routes/api/menu-locations.js +46 -0
  92. package/server/routes/api/menus.js +115 -0
  93. package/server/routes/api/navigation.js +2 -0
  94. package/server/routes/api/pages.js +1 -1
  95. package/server/routes/api/projects.js +107 -0
  96. package/server/routes/api/scaffold.js +86 -0
  97. package/server/routes/api/settings.js +3 -0
  98. package/server/routes/api/sidebar.js +23 -0
  99. package/server/routes/api/users.js +32 -7
  100. package/server/routes/api/views.js +10 -2
  101. package/server/routes/public.js +88 -7
  102. package/server/server.js +54 -3
  103. package/server/services/actions.js +137 -8
  104. package/server/services/adapters/FileAdapter.js +23 -8
  105. package/server/services/adapters/MongoAdapter.js +36 -18
  106. package/server/services/blocks.js +23 -8
  107. package/server/services/cache/drivers/MemoryDriver.js +118 -0
  108. package/server/services/cache/drivers/NoneDriver.js +12 -0
  109. package/server/services/cache/index.js +229 -0
  110. package/server/services/cache/lru.js +61 -0
  111. package/server/services/collections.js +102 -12
  112. package/server/services/content.js +25 -6
  113. package/server/services/filterEngine.js +281 -0
  114. package/server/services/forms.js +3 -0
  115. package/server/services/hooks.js +48 -0
  116. package/server/services/markdown.js +711 -124
  117. package/server/services/menus-migration.js +107 -0
  118. package/server/services/menus.js +422 -0
  119. package/server/services/permissionRegistry.js +26 -0
  120. package/server/services/plugins.js +9 -2
  121. package/server/services/presetCollections.js +22 -0
  122. package/server/services/projects.js +429 -0
  123. package/server/services/recipes/contact-list.json +78 -0
  124. package/server/services/recipes/onboarding.json +426 -0
  125. package/server/services/references.js +174 -0
  126. package/server/services/renderer.js +237 -40
  127. package/server/services/roles.js +6 -1
  128. package/server/services/rowAccess.js +86 -13
  129. package/server/services/scaffolder.js +465 -0
  130. package/server/services/sidebar-migration.js +117 -0
  131. package/server/services/sitemap.js +112 -0
  132. package/server/services/userRoles.js +86 -0
  133. package/server/services/users.js +23 -2
  134. package/server/services/views.js +19 -4
  135. package/server/templates/page.html +135 -130
  136. /package/config/{navigation.json → navigation.json.bak} +0 -0
@@ -0,0 +1,426 @@
1
+ {
2
+ "slug": "onboarding",
3
+ "name": "Employee Onboarding",
4
+ "description": "Collects new-starter details and tracks them through a Pending \u2192 Reviewing \u2192 Approved/Rejected workflow. Includes a right-to-work file upload and HR review actions.",
5
+ "icon": "user-plus",
6
+ "roles": [
7
+ {
8
+ "name": "hr",
9
+ "label": "HR Reviewer",
10
+ "level": 2,
11
+ "permissions": [
12
+ "notifications"
13
+ ],
14
+ "badgeClass": "badge-info"
15
+ }
16
+ ],
17
+ "options": [
18
+ {
19
+ "name": "namespace",
20
+ "label": "Namespace",
21
+ "default": "onboarding",
22
+ "hint": "Single name that cascades into every slug below. Change this once and the collection, form, and actions all rename in lockstep — or override the individual slugs below to break the chain."
23
+ },
24
+ {
25
+ "name": "collectionSlug",
26
+ "label": "Collection slug",
27
+ "default": "{{namespace}}",
28
+ "hint": "Where submitted starter records are stored"
29
+ },
30
+ {
31
+ "name": "formSlug",
32
+ "label": "Form slug",
33
+ "default": "{{namespace}}-form",
34
+ "hint": "Embedded via [form name=\"...\" /]"
35
+ },
36
+ {
37
+ "name": "actionPrefix",
38
+ "label": "Action slug prefix",
39
+ "default": "{{namespace}}",
40
+ "hint": "Per-action slugs: <prefix>-review, <prefix>-approve, <prefix>-reject"
41
+ },
42
+ {
43
+ "name": "hrEmail",
44
+ "label": "HR email (optional)",
45
+ "default": "",
46
+ "hint": "If set, scaffolds an HR user account that can review and approve onboarding submissions. Leave blank to skip."
47
+ },
48
+ {
49
+ "name": "hrPassword",
50
+ "label": "HR initial password (optional)",
51
+ "default": "",
52
+ "hint": "Required when HR email is set. The HR user can change this immediately via their profile after first login."
53
+ }
54
+ ],
55
+ "collection": {
56
+ "title": "Onboarding",
57
+ "description": "New-starter onboarding submissions",
58
+ "fields": [
59
+ {
60
+ "name": "fullName",
61
+ "label": "Full name",
62
+ "type": "text",
63
+ "required": true
64
+ },
65
+ {
66
+ "name": "email",
67
+ "label": "Work email",
68
+ "type": "email",
69
+ "required": true
70
+ },
71
+ {
72
+ "name": "phone",
73
+ "label": "Phone number",
74
+ "type": "text"
75
+ },
76
+ {
77
+ "name": "startDate",
78
+ "label": "Start date",
79
+ "type": "date",
80
+ "required": true
81
+ },
82
+ {
83
+ "name": "role",
84
+ "label": "Role / position",
85
+ "type": "text",
86
+ "required": true
87
+ },
88
+ {
89
+ "name": "manager",
90
+ "label": "Manager",
91
+ "type": "text"
92
+ },
93
+ {
94
+ "name": "residencyStatus",
95
+ "label": "Right-to-work status",
96
+ "type": "multiselect",
97
+ "required": true,
98
+ "options": [
99
+ {
100
+ "value": "uk-national",
101
+ "label": "UK national"
102
+ },
103
+ {
104
+ "value": "settled-status",
105
+ "label": "Settled status"
106
+ },
107
+ {
108
+ "value": "visa-required",
109
+ "label": "Visa required"
110
+ },
111
+ {
112
+ "value": "other",
113
+ "label": "Other"
114
+ }
115
+ ]
116
+ },
117
+ {
118
+ "name": "rightToWorkDoc",
119
+ "label": "Right-to-work document",
120
+ "type": "file",
121
+ "required": false,
122
+ "file": {
123
+ "accept": "application/pdf,image/jpeg,image/png",
124
+ "maxSize": 5242880
125
+ }
126
+ },
127
+ {
128
+ "name": "references",
129
+ "label": "Reference contacts",
130
+ "type": "textarea",
131
+ "required": false,
132
+ "placeholder": "Name, email, relationship \u2014 one per line"
133
+ },
134
+ {
135
+ "name": "notes",
136
+ "label": "Notes for HR",
137
+ "type": "textarea"
138
+ },
139
+ {
140
+ "name": "status",
141
+ "label": "Status",
142
+ "type": "select",
143
+ "required": false,
144
+ "options": [
145
+ {
146
+ "value": "pending",
147
+ "label": "Pending review"
148
+ },
149
+ {
150
+ "value": "reviewing",
151
+ "label": "Reviewing"
152
+ },
153
+ {
154
+ "value": "approved",
155
+ "label": "Approved"
156
+ },
157
+ {
158
+ "value": "rejected",
159
+ "label": "Rejected"
160
+ }
161
+ ]
162
+ }
163
+ ],
164
+ "api": {
165
+ "read": {
166
+ "enabled": false
167
+ },
168
+ "create": {
169
+ "enabled": false
170
+ },
171
+ "update": {
172
+ "enabled": false
173
+ },
174
+ "delete": {
175
+ "enabled": false
176
+ }
177
+ },
178
+ "rowAccess": {
179
+ "update": {
180
+ "ownerField": "createdBy",
181
+ "fallbackRoles": [
182
+ "admin",
183
+ "super-admin"
184
+ ]
185
+ },
186
+ "delete": {
187
+ "ownerField": "createdBy",
188
+ "fallbackRoles": [
189
+ "admin",
190
+ "super-admin"
191
+ ]
192
+ }
193
+ }
194
+ },
195
+ "form": {
196
+ "title": "Employee Onboarding",
197
+ "description": "Welcome aboard \u2014 let's get you set up.",
198
+ "fields": [
199
+ {
200
+ "name": "fullName",
201
+ "label": "Full name",
202
+ "type": "text",
203
+ "required": true,
204
+ "placeholder": "Alex Morgan"
205
+ },
206
+ {
207
+ "name": "email",
208
+ "label": "Work email",
209
+ "type": "email",
210
+ "required": true,
211
+ "placeholder": "alex@company.com"
212
+ },
213
+ {
214
+ "name": "phone",
215
+ "label": "Phone number",
216
+ "type": "text",
217
+ "required": false
218
+ },
219
+ {
220
+ "name": "startDate",
221
+ "label": "Start date",
222
+ "type": "date",
223
+ "required": true
224
+ },
225
+ {
226
+ "name": "role",
227
+ "label": "Role you're starting",
228
+ "type": "text",
229
+ "required": true
230
+ },
231
+ {
232
+ "name": "manager",
233
+ "label": "Your manager",
234
+ "type": "text",
235
+ "required": false
236
+ },
237
+ {
238
+ "name": "residencyStatus",
239
+ "label": "Right-to-work status",
240
+ "type": "select",
241
+ "required": true,
242
+ "options": [
243
+ {
244
+ "value": "uk-national",
245
+ "label": "UK national"
246
+ },
247
+ {
248
+ "value": "settled-status",
249
+ "label": "Settled status"
250
+ },
251
+ {
252
+ "value": "visa-required",
253
+ "label": "Visa required"
254
+ },
255
+ {
256
+ "value": "other",
257
+ "label": "Other"
258
+ }
259
+ ]
260
+ },
261
+ {
262
+ "name": "rightToWorkDoc",
263
+ "label": "Right-to-work document",
264
+ "type": "file",
265
+ "required": true,
266
+ "file": {
267
+ "accept": "application/pdf,image/jpeg,image/png",
268
+ "maxSize": 5242880
269
+ },
270
+ "helper": "PDF, JPEG or PNG \u2014 up to 5 MB"
271
+ },
272
+ {
273
+ "name": "references",
274
+ "label": "Reference contacts",
275
+ "type": "textarea",
276
+ "required": false,
277
+ "placeholder": "Name, email, relationship \u2014 one per line"
278
+ }
279
+ ],
280
+ "settings": {
281
+ "submitText": "Send to HR",
282
+ "successMessage": "Thanks \u2014 HR will review your details shortly.",
283
+ "honeypot": true,
284
+ "rateLimitPerMinute": 3,
285
+ "actionSlug": ""
286
+ },
287
+ "actions": {
288
+ "collection": {
289
+ "enabled": true,
290
+ "slug": ""
291
+ },
292
+ "email": {
293
+ "enabled": false
294
+ },
295
+ "webhook": {
296
+ "enabled": false
297
+ }
298
+ }
299
+ },
300
+ "users": [
301
+ {
302
+ "email": "{{hrEmail}}",
303
+ "name": "HR Lead",
304
+ "password": "{{hrPassword}}",
305
+ "role": "hr",
306
+ "additionalRoles": []
307
+ }
308
+ ],
309
+ "actions": [
310
+ {
311
+ "slug": "{{actionPrefix}}-review",
312
+ "title": "Move to reviewing",
313
+ "description": "HR has picked this up and started checks.",
314
+ "collection": "{{collectionSlug}}",
315
+ "trigger": {
316
+ "label": "Mark as reviewing",
317
+ "icon": "eye",
318
+ "style": "primary"
319
+ },
320
+ "transition": {
321
+ "field": "status",
322
+ "from": [
323
+ "pending"
324
+ ],
325
+ "to": "reviewing"
326
+ },
327
+ "access": {
328
+ "roles": [
329
+ "hr",
330
+ "admin",
331
+ "super-admin"
332
+ ]
333
+ },
334
+ "steps": [
335
+ {
336
+ "type": "updateField",
337
+ "config": {
338
+ "field": "status",
339
+ "value": "reviewing"
340
+ }
341
+ }
342
+ ]
343
+ },
344
+ {
345
+ "slug": "{{actionPrefix}}-approve",
346
+ "title": "Approve",
347
+ "description": "Right-to-work and references checked; cleared to start.",
348
+ "collection": "{{collectionSlug}}",
349
+ "trigger": {
350
+ "label": "Approve",
351
+ "icon": "check-circle",
352
+ "style": "success",
353
+ "confirmMessage": "Mark this starter as approved?"
354
+ },
355
+ "transition": {
356
+ "field": "status",
357
+ "from": [
358
+ "reviewing"
359
+ ],
360
+ "to": "approved"
361
+ },
362
+ "access": {
363
+ "roles": [
364
+ "hr",
365
+ "admin",
366
+ "super-admin"
367
+ ]
368
+ },
369
+ "steps": [
370
+ {
371
+ "type": "updateField",
372
+ "config": {
373
+ "field": "status",
374
+ "value": "approved"
375
+ }
376
+ },
377
+ {
378
+ "type": "email",
379
+ "config": {
380
+ "to": "{{entry.data.email}}",
381
+ "subject": "Welcome \u2014 you're cleared to start",
382
+ "template": "Hi {{entry.data.fullName}},\n\nGreat news \u2014 your onboarding paperwork has been approved. Your first day is {{entry.data.startDate}}.\n\nIf you have any questions before then, just reply to this email.\n\nThe HR team"
383
+ }
384
+ }
385
+ ]
386
+ },
387
+ {
388
+ "slug": "{{actionPrefix}}-reject",
389
+ "title": "Reject",
390
+ "description": "Information missing or right-to-work check failed.",
391
+ "collection": "{{collectionSlug}}",
392
+ "trigger": {
393
+ "label": "Reject",
394
+ "icon": "x-circle",
395
+ "style": "danger",
396
+ "confirmMessage": "Reject this onboarding submission?"
397
+ },
398
+ "transition": {
399
+ "field": "status",
400
+ "from": [
401
+ "pending",
402
+ "reviewing"
403
+ ],
404
+ "to": "rejected"
405
+ },
406
+ "access": {
407
+ "roles": [
408
+ "hr",
409
+ "admin",
410
+ "super-admin"
411
+ ]
412
+ },
413
+ "steps": [
414
+ {
415
+ "type": "updateField",
416
+ "config": {
417
+ "field": "status",
418
+ "value": "rejected"
419
+ }
420
+ }
421
+ ]
422
+ }
423
+ ],
424
+ "wireForm": true,
425
+ "snippet": "[form name=\"{{formSlug}}\" /]\n\n## All onboarding submissions\n\n[collection slug=\"{{collectionSlug}}\" display=\"cards\" columns=\"2\" title-field=\"fullName\" searchable filterable=\"status,residencyStatus\" sortable transitions /]"
426
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Reference resolver — turn raw foreign-key ids stored on entries into
3
+ * displayable values from the target collections.
4
+ *
5
+ * Reference field schema shape:
6
+ * {
7
+ * name: "jobId",
8
+ * label: "Job",
9
+ * type: "reference",
10
+ * reference: {
11
+ * collection: "jobs", // target collection slug (required)
12
+ * displayField: "title", // field on the target to show (default: "title")
13
+ * searchField: "title", // field to search on in picker UI (default: displayField)
14
+ * linkTemplate: "/jobs/{{id}}" // optional link template — `{{id}}` substituted
15
+ * }
16
+ * }
17
+ *
18
+ * Storage stays the same: `entry.data.jobId` is the target's id (or an array
19
+ * of ids for one-to-many references). The resolver attaches a parallel
20
+ * `entry._refs` map that callers can use to render labels without mutating
21
+ * the raw data shape.
22
+ *
23
+ * Batching: when resolving N entries that each have a `jobId`, we collect
24
+ * all distinct ids once, fetch the target collection in a single pass, then
25
+ * decorate every entry from the lookup map — one adapter call per *target
26
+ * collection* per resolve, not per entry.
27
+ *
28
+ * Missing-target handling: if a referenced id no longer exists (the target
29
+ * was deleted), the entry gets `_refs.<field> = { id, display: null, missing: true }`
30
+ * so callers can render a graceful "(deleted)" indicator instead of crashing.
31
+ */
32
+ import {getAdapter} from './adapterRegistry.js';
33
+
34
+ /**
35
+ * Extract the list of reference field definitions from a schema.
36
+ *
37
+ * @param {object} schema
38
+ * @returns {Array<{name:string, label:string, reference:object}>}
39
+ */
40
+ export function referenceFields(schema) {
41
+ return (schema?.fields || []).filter(f => f.type === 'reference' && f.reference?.collection);
42
+ }
43
+
44
+ /**
45
+ * Resolve all reference fields on a batch of entries.
46
+ *
47
+ * Each entry is mutated in place to add `_refs`:
48
+ * entry._refs = {
49
+ * jobId: { id: 'abc', display: 'Senior Engineer', missing: false, link: '/jobs/abc' }
50
+ * }
51
+ *
52
+ * For one-to-many (array) references the shape is:
53
+ * entry._refs = {
54
+ * skillIds: [{id, display, link, missing}, ...]
55
+ * }
56
+ *
57
+ * Returns the entries (same reference) for chainable use.
58
+ *
59
+ * @param {object} schema - Source collection schema
60
+ * @param {object[]} entries - Source entries; each may be mutated
61
+ * @param {object} [opts]
62
+ * @param {Function} [opts.adapterResolver] - Optional async (slug) → adapter — for tests
63
+ * @returns {Promise<object[]>}
64
+ */
65
+ export async function resolveReferences(schema, entries, opts = {}) {
66
+ if (!Array.isArray(entries) || !entries.length) return entries;
67
+
68
+ const refs = referenceFields(schema);
69
+ if (!refs.length) return entries;
70
+
71
+ const resolver = opts.adapterResolver || getAdapter;
72
+
73
+ // Group distinct ids per target collection so we make one adapter call
74
+ // per collection regardless of how many fields reference it.
75
+ /** @type {Map<string, Set<string>>} */
76
+ const idsByCollection = new Map();
77
+ for (const field of refs) {
78
+ const target = field.reference.collection;
79
+ if (!idsByCollection.has(target)) idsByCollection.set(target, new Set());
80
+ const bucket = idsByCollection.get(target);
81
+ for (const e of entries) {
82
+ const raw = e.data?.[field.name];
83
+ if (raw == null || raw === '') continue;
84
+ if (Array.isArray(raw)) raw.forEach(v => v != null && bucket.add(String(v)));
85
+ else bucket.add(String(raw));
86
+ }
87
+ }
88
+
89
+ // Fetch each target collection once, build a lookup map { collectionSlug → Map<id, entry> }
90
+ /** @type {Map<string, Map<string, object>>} */
91
+ const lookups = new Map();
92
+ for (const [collectionSlug, idSet] of idsByCollection) {
93
+ if (!idSet.size) { lookups.set(collectionSlug, new Map()); continue; }
94
+ try {
95
+ const adapter = await resolver(collectionSlug);
96
+ // FileAdapter: cheap to read all then filter; MongoAdapter: $in is the
97
+ // right call but we don't have a list-by-ids API yet. For now, get()
98
+ // each id in parallel — N requests but small N (distinct refs only).
99
+ const map = new Map();
100
+ await Promise.all([...idSet].map(async id => {
101
+ try {
102
+ const target = await adapter.get(collectionSlug, id);
103
+ if (target) map.set(id, target);
104
+ } catch { /* missing target — left out of the map */ }
105
+ }));
106
+ lookups.set(collectionSlug, map);
107
+ } catch {
108
+ lookups.set(collectionSlug, new Map()); // collection gone — all refs render as missing
109
+ }
110
+ }
111
+
112
+ // Decorate every entry with resolved _refs
113
+ for (const entry of entries) {
114
+ entry._refs = entry._refs || {};
115
+ for (const field of refs) {
116
+ const raw = entry.data?.[field.name];
117
+ if (raw == null || raw === '') continue;
118
+ const map = lookups.get(field.reference.collection) || new Map();
119
+ if (Array.isArray(raw)) {
120
+ entry._refs[field.name] = raw.filter(v => v != null).map(id => buildRef(field, String(id), map));
121
+ } else {
122
+ entry._refs[field.name] = buildRef(field, String(raw), map);
123
+ }
124
+ }
125
+ }
126
+
127
+ return entries;
128
+ }
129
+
130
+ /**
131
+ * Build a single resolved-ref record for an id.
132
+ *
133
+ * @param {object} field
134
+ * @param {string} id
135
+ * @param {Map<string, object>} lookupMap
136
+ * @returns {{id:string, display:string|null, missing:boolean, link:string|null}}
137
+ */
138
+ function buildRef(field, id, lookupMap) {
139
+ const target = lookupMap.get(id);
140
+ if (!target) {
141
+ return { id, display: null, missing: true, link: null };
142
+ }
143
+ const displayField = field.reference.displayField || 'title';
144
+ const raw = target.data?.[displayField];
145
+ const display = (raw == null || raw === '') ? id : String(raw);
146
+ let link = null;
147
+ if (field.reference.linkTemplate) {
148
+ link = String(field.reference.linkTemplate).replace(/\{\{\s*id\s*\}\}/g, encodeURIComponent(id));
149
+ }
150
+ return { id, display, missing: false, link };
151
+ }
152
+
153
+ /**
154
+ * Convenience helper for renderers — get the display label for a reference
155
+ * field on a single entry, falling back to the raw value when no resolution
156
+ * was performed (e.g. a caller skipped `resolveRefs: true`).
157
+ *
158
+ * Handles arrays by joining with ", ".
159
+ *
160
+ * @param {object} entry
161
+ * @param {string} fieldName
162
+ * @returns {string}
163
+ */
164
+ export function refDisplay(entry, fieldName) {
165
+ const r = entry?._refs?.[fieldName];
166
+ if (r == null) {
167
+ const raw = entry?.data?.[fieldName];
168
+ return raw == null ? '' : (Array.isArray(raw) ? raw.join(', ') : String(raw));
169
+ }
170
+ if (Array.isArray(r)) {
171
+ return r.map(x => x.missing ? `${x.id} (missing)` : x.display).join(', ');
172
+ }
173
+ return r.missing ? `${r.id} (missing)` : r.display;
174
+ }