domma-cms 0.14.1 → 0.14.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.14.1",
3
+ "version": "0.14.3",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -71,11 +71,11 @@
71
71
  "@fastify/jwt": "^10.0.0",
72
72
  "@fastify/multipart": "^9.3.0",
73
73
  "@fastify/rate-limit": "^10.3.0",
74
- "@fastify/static": "^8.1.0",
74
+ "@fastify/static": "9.1.1",
75
75
  "bcryptjs": "^3.0.3",
76
- "domma-js": "^0.22.3",
76
+ "domma-js": "^0.22.6",
77
77
  "dotenv": "^17.2.3",
78
- "fastify": "5.8.3",
78
+ "fastify": "5.8.5",
79
79
  "gray-matter": "^4.0.3",
80
80
  "marked": "^15.0.0",
81
81
  "nodemailer": "8.0.5",
@@ -98,7 +98,10 @@ export function requirePermission(resource, action) {
98
98
  export {getPermissionsForRole};
99
99
 
100
100
  /**
101
- * Shorthand preHandler — level-0 role (admin) only.
101
+ * Shorthand preHandler — admin-tier role (level ≤ 1) or above.
102
+ * Matches the base role hierarchy documented in roles.js:
103
+ * super-admin (0), admin (1), user (2).
104
+ * Both super-admin and admin pass; regular users and anything below do not.
102
105
  *
103
106
  * @param {FastifyRequest} request
104
107
  * @param {FastifyReply} reply
@@ -108,7 +111,7 @@ export async function requireAdmin(request, reply) {
108
111
  if (!request.user) {
109
112
  return reply.code(401).send({ statusCode: 401, error: 'Unauthorised', message: 'Authentication required' });
110
113
  }
111
- if (getRoleLevel(request.user.role) !== 0) {
114
+ if (getRoleLevel(request.user.role) > 1) {
112
115
  return reply.code(403).send({ statusCode: 403, error: 'Forbidden', message: 'Admin access required' });
113
116
  }
114
117
  }
@@ -413,6 +413,43 @@ function renderCollectionAccordion(entries, titleField, bodyField, multiple, emp
413
413
  return `<div class="dm-collection-display accordion"${multiAttr}>\n${items}\n</div>`;
414
414
  }
415
415
 
416
+ /**
417
+ * Render a collection as a Domma progression (timeline / roadmap).
418
+ * Output mirrors processTimelineBlocks so the same styling applies.
419
+ *
420
+ * @param {object[]} entries
421
+ * @param {object} opts
422
+ * @param {string} opts.titleField - entry data field used as item title
423
+ * @param {string} [opts.dateField] - optional field surfaced as data-date
424
+ * @param {string} [opts.statusField]- optional field surfaced as data-status
425
+ * @param {string} [opts.iconField] - optional field surfaced as data-icon
426
+ * @param {string} [opts.bodyField] - optional Markdown body field
427
+ * @param {string} opts.layout - vertical | centred | horizontal
428
+ * @param {string} opts.theme - minimal | corporate | modern
429
+ * @param {string} opts.mode - timeline | roadmap
430
+ * @param {string} opts.emptyMsg
431
+ * @returns {string}
432
+ */
433
+ function renderCollectionTimeline(entries, opts) {
434
+ const {titleField, dateField, statusField, iconField, bodyField, layout, theme, mode, emptyMsg} = opts;
435
+ if (!entries.length) {
436
+ return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
437
+ }
438
+
439
+ const items = entries.map(e => {
440
+ const title = escapeHtmlText(String(e.data?.[titleField] ?? e.id ?? '(untitled)'));
441
+ const date = dateField && e.data?.[dateField] != null ? ` data-date="${escapeAttr(String(e.data[dateField]))}"` : '';
442
+ const status = statusField && e.data?.[statusField] != null ? ` data-status="${escapeAttr(String(e.data[statusField]))}"` : '';
443
+ const icon = iconField && e.data?.[iconField] != null ? ` data-icon="${escapeAttr(String(e.data[iconField]))}"` : '';
444
+ const bodyHtml = bodyField
445
+ ? marked.parse(String(e.data?.[bodyField] ?? ''))
446
+ : '';
447
+ return `<div class="dm-progression-item"${date}${status}${icon}><div class="dm-progression-item-title">${title}</div><div class="dm-progression-item-body">${bodyHtml}</div></div>`;
448
+ }).join('\n');
449
+
450
+ return `<div class="dm-collection-display dm-progression" data-layout="${layout}" data-theme="${theme}" data-mode="${mode}">\n${items}\n</div>`;
451
+ }
452
+
416
453
  /**
417
454
  * Process [view slug="..." display="table|cards|list" /] shortcodes.
418
455
  * Executes the View's aggregation pipeline and renders results using the
@@ -488,6 +525,21 @@ async function processViewBlocks(markdown) {
488
525
  const bodyField = attrs['body-field'] || 'description';
489
526
  const multiple = attrs.multiple === 'true';
490
527
  replacement = renderCollectionAccordion(entries, accordionTitleField, bodyField, multiple, emptyMsg);
528
+ } else if (display === 'timeline') {
529
+ const timelineLayout = ['vertical', 'centred', 'horizontal'].includes(attrs.layout) ? attrs.layout : 'vertical';
530
+ const timelineTheme = ['minimal', 'corporate', 'modern'].includes(attrs.theme) ? attrs.theme : 'minimal';
531
+ const timelineMode = ['timeline', 'roadmap'].includes(attrs.mode) ? attrs.mode : 'timeline';
532
+ replacement = renderCollectionTimeline(entries, {
533
+ titleField: attrs['title-field'] || 'title',
534
+ dateField: attrs['date-field'] || '',
535
+ statusField: attrs['status-field'] || '',
536
+ iconField: attrs['icon-field'] || '',
537
+ bodyField: attrs['body-field'] || '',
538
+ layout: timelineLayout,
539
+ theme: timelineTheme,
540
+ mode: timelineMode,
541
+ emptyMsg,
542
+ });
491
543
  } else if (display === 'block') {
492
544
  const blockName = attrs.block || viewConfig?.display?.block || '';
493
545
  if (blockName) {
@@ -607,6 +659,21 @@ async function processCollectionBlocks(markdown) {
607
659
  const bodyField = attrs['body-field'] || 'description';
608
660
  const multiple = attrs.multiple === 'true';
609
661
  replacement = renderCollectionAccordion(entries, accordionTitleField, bodyField, multiple, emptyMsg);
662
+ } else if (display === 'timeline') {
663
+ const timelineLayout = ['vertical', 'centred', 'horizontal'].includes(attrs.layout) ? attrs.layout : 'vertical';
664
+ const timelineTheme = ['minimal', 'corporate', 'modern'].includes(attrs.theme) ? attrs.theme : 'minimal';
665
+ const timelineMode = ['timeline', 'roadmap'].includes(attrs.mode) ? attrs.mode : 'timeline';
666
+ replacement = renderCollectionTimeline(entries, {
667
+ titleField: attrs['title-field'] || 'title',
668
+ dateField: attrs['date-field'] || '',
669
+ statusField: attrs['status-field'] || '',
670
+ iconField: attrs['icon-field'] || '',
671
+ bodyField: attrs['body-field'] || '',
672
+ layout: timelineLayout,
673
+ theme: timelineTheme,
674
+ mode: timelineMode,
675
+ emptyMsg,
676
+ });
610
677
  } else if (display === 'block') {
611
678
  const blockName = attrs.block || '';
612
679
  if (blockName) {
@@ -2265,22 +2332,31 @@ function processTableBlocks(markdown) {
2265
2332
  * [/hero]
2266
2333
  *
2267
2334
  * Supported attributes:
2268
- * title - Hero heading (.hero-title)
2269
- * tagline - Subtitle text (.hero-subtitle)
2270
- * size - "sm", "lg", "full" → .hero-sm / .hero-lg / .hero-full
2271
- * variant - "dark", "primary",
2272
- * upstream 8: "gradient-purple", "gradient-blue", "gradient-green",
2273
- * "gradient-sunset", "gradient-ocean", "gradient-rose",
2274
- * "gradient-forest", "gradient-night"
2275
- * theme-specific (26): "gradient-{theme}-{mode}" where theme is one of
2276
- * ocean, forest, sunset, royal, lemon, silver, charcoal, christmas,
2277
- * unicorn, dreamy, grayve, mint, wedding — and mode is light or dark
2278
- * → .hero-{variant}
2279
- * image - URL for background-image + adds .hero-cover
2280
- * overlay - "light", "dark", "darker", "gradient", "gradient-reverse" → .hero-overlay-{overlay}
2281
- * align - "center" (default) or "left" → .hero-center / .hero-left
2282
- * class - Extra classes appended to .hero
2283
- * id - Element id attribute
2335
+ * title - Hero heading (.hero-title)
2336
+ * tagline - Subtitle text (.hero-subtitle)
2337
+ * size - "sm", "lg", "full" → .hero-sm / .hero-lg / .hero-full
2338
+ * variant - "dark", "primary",
2339
+ * upstream 8: "gradient-purple", "gradient-blue", "gradient-green",
2340
+ * "gradient-sunset", "gradient-ocean", "gradient-rose",
2341
+ * "gradient-forest", "gradient-night"
2342
+ * theme-specific (26): "gradient-{theme}-{mode}" where theme is one of
2343
+ * ocean, forest, sunset, royal, lemon, silver, charcoal, christmas,
2344
+ * unicorn, dreamy, grayve, mint, wedding — and mode is light or dark
2345
+ * → .hero-{variant}
2346
+ * image - URL for background-image + adds .hero-cover
2347
+ * overlay - "light", "dark", "darker", "gradient", "gradient-reverse" → .hero-overlay-{overlay}
2348
+ * align - "center" (default) or "left" → .hero-center / .hero-left
2349
+ * bg / color - Background colour (any safe CSS colour value)
2350
+ * min-height - Minimum height (px, em, rem, vh, vw, %)
2351
+ * fullwidth - "true" breaks out of the page container to span the full viewport width (adds .hero-breakout).
2352
+ * Must be the literal string "true" — the attribute is otherwise ignored.
2353
+ * twinkle - Flag attribute — adds particle overlay (requires Effects plugin)
2354
+ * twinkle-count - Number of twinkle particles
2355
+ * twinkle-colour - Particle colour (CSS value)
2356
+ * blobs - Flag attribute — adds ambient blob background
2357
+ * blobs-type - Blob animation type (default: "float-blobs")
2358
+ * class - Extra classes appended to .hero
2359
+ * id - Element id attribute
2284
2360
  *
2285
2361
  * @param {string} markdown
2286
2362
  * @returns {string}
@@ -2391,8 +2467,12 @@ function processCenterBlocks(markdown) {
2391
2467
  (_, attrStr, body) => {
2392
2468
  const attrs = parseShortcodeAttrs(attrStr);
2393
2469
  const classAttr = attrs.class ? ` class="${escapeAttr(attrs.class)}"` : '';
2394
- const bodyHtml = marked.parse(processCardBlocks(processGridBlocks(restore(body.trim()))));
2395
- return `<div style="text-align:center;"${classAttr}>${bodyHtml}</div>\n`;
2470
+ // Emit the wrapper with blank lines around the body so CommonMark
2471
+ // parses markdown inside the div. DO NOT eagerly call marked.parse
2472
+ // here — that HTML-escapes attribute quotes on any unprocessed
2473
+ // shortcodes inside (e.g. [button href="..."] becomes href=&quot;...),
2474
+ // which breaks every later shortcode processor in the pipeline.
2475
+ return `\n<div style="text-align:center;"${classAttr}>\n\n${body.trim()}\n\n</div>\n`;
2396
2476
  }
2397
2477
  ));
2398
2478
  }