domma-cms 0.10.0 → 0.13.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 (121) hide show
  1. package/CLAUDE.md +248 -159
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/api.js +1 -1
  4. package/admin/js/app.js +7 -3
  5. package/admin/js/config/sidebar-config.js +1 -1
  6. package/admin/js/http-interceptor.js +1 -0
  7. package/admin/js/lib/safe-html.js +1 -0
  8. package/admin/js/templates/documentation.html +611 -2
  9. package/admin/js/templates/layouts.html +5 -4
  10. package/admin/js/templates/notifications.html +14 -0
  11. package/admin/js/templates/plugin-marketplace.html +16 -0
  12. package/admin/js/templates/plugins.html +17 -5
  13. package/admin/js/views/index.js +1 -1
  14. package/admin/js/views/layouts.js +1 -16
  15. package/admin/js/views/notifications.js +1 -0
  16. package/admin/js/views/plugin-marketplace.js +1 -0
  17. package/admin/js/views/plugins.js +16 -16
  18. package/config/navigation.json +5 -72
  19. package/config/plugins.json +10 -14
  20. package/config/presets.json +50 -13
  21. package/config/site.json +11 -63
  22. package/package.json +2 -1
  23. package/plugins/_template/admin/templates/index.html +17 -0
  24. package/plugins/_template/admin/views/index.js +19 -0
  25. package/plugins/_template/config.js +8 -0
  26. package/plugins/_template/plugin.js +23 -0
  27. package/plugins/_template/plugin.json +34 -0
  28. package/plugins/analytics/plugin.json +41 -31
  29. package/plugins/blog/admin/templates/blog.html +22 -0
  30. package/plugins/blog/admin/templates/categories.html +7 -0
  31. package/plugins/blog/admin/templates/comments.html +11 -0
  32. package/plugins/blog/admin/templates/post-editor.html +97 -0
  33. package/plugins/blog/admin/templates/settings.html +11 -0
  34. package/plugins/blog/admin/views/blog.js +183 -0
  35. package/plugins/blog/admin/views/categories.js +235 -0
  36. package/plugins/blog/admin/views/comments.js +187 -0
  37. package/plugins/blog/admin/views/post-editor.js +291 -0
  38. package/plugins/blog/admin/views/settings.js +100 -0
  39. package/plugins/blog/collections/categories/schema.json +12 -0
  40. package/plugins/blog/collections/comments/schema.json +16 -0
  41. package/plugins/blog/collections/posts/schema.json +19 -0
  42. package/plugins/blog/config.js +8 -0
  43. package/plugins/blog/plugin.js +352 -0
  44. package/plugins/blog/plugin.json +96 -0
  45. package/plugins/blog/roles/blog-author.json +10 -0
  46. package/plugins/blog/roles/blog-editor.json +12 -0
  47. package/plugins/blog/templates/author.html +9 -0
  48. package/plugins/blog/templates/category.html +9 -0
  49. package/plugins/blog/templates/index.html +9 -0
  50. package/plugins/blog/templates/post.html +17 -0
  51. package/plugins/blog/templates/tag.html +9 -0
  52. package/plugins/contacts/collections/user-contact-groups/schema.json +1 -1
  53. package/plugins/contacts/collections/user-contacts/schema.json +1 -1
  54. package/plugins/contacts/plugin.js +4 -10
  55. package/plugins/contacts/plugin.json +13 -3
  56. package/plugins/notes/collections/user-notes/schema.json +1 -1
  57. package/plugins/notes/plugin.js +3 -9
  58. package/plugins/notes/plugin.json +13 -3
  59. package/plugins/site-search/plugin.json +5 -2
  60. package/plugins/theme-switcher/plugin.json +1 -1
  61. package/plugins/todo/collections/todos/schema.json +1 -1
  62. package/plugins/todo/plugin.js +3 -9
  63. package/plugins/todo/plugin.json +13 -3
  64. package/public/css/site.css +1 -1
  65. package/scripts/build.js +48 -0
  66. package/scripts/create-plugin.js +113 -0
  67. package/scripts/fresh.js +6 -7
  68. package/scripts/gen-instance-secret.js +46 -0
  69. package/scripts/reset.js +3 -3
  70. package/scripts/setup.js +31 -13
  71. package/server/middleware/auth.js +48 -0
  72. package/server/middleware/managerAuth.js +36 -0
  73. package/server/routes/api/actions.js +1 -1
  74. package/server/routes/api/auth.js +4 -3
  75. package/server/routes/api/layouts.js +173 -49
  76. package/server/routes/api/notifications.js +155 -0
  77. package/server/routes/api/plugin-marketplace.js +75 -0
  78. package/server/routes/api/users.js +1 -1
  79. package/server/routes/api/views.js +1 -1
  80. package/server/routes/public.js +4 -9
  81. package/server/server.js +32 -3
  82. package/server/services/actions.js +1 -1
  83. package/server/services/managerClient.js +182 -0
  84. package/server/services/markdown.js +52 -14
  85. package/server/services/permissionRegistry.js +245 -173
  86. package/server/services/pluginInstaller.js +301 -0
  87. package/server/services/plugins.js +117 -10
  88. package/server/services/presetCollections.js +66 -251
  89. package/server/services/renderer.js +99 -0
  90. package/server/services/roles.js +191 -39
  91. package/server/services/users.js +1 -1
  92. package/server/services/views.js +1 -1
  93. package/server/templates/page.html +2 -2
  94. package/plugins/docs/admin/templates/docs.html +0 -69
  95. package/plugins/docs/admin/views/docs.js +0 -276
  96. package/plugins/docs/config.js +0 -8
  97. package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +0 -11
  98. package/plugins/docs/data/folders.json +0 -9
  99. package/plugins/docs/data/templates.json +0 -1
  100. package/plugins/docs/data/versions/57e003f0-68f2-47dc-9c36-ed4b10ed3deb/1.json +0 -5
  101. package/plugins/docs/plugin.js +0 -375
  102. package/plugins/docs/plugin.json +0 -23
  103. package/plugins/form-builder/data/forms/contacts.json +0 -66
  104. package/plugins/form-builder/data/forms/enquiries.json +0 -103
  105. package/plugins/form-builder/data/forms/feedback.json +0 -131
  106. package/plugins/form-builder/data/forms/notes.json +0 -79
  107. package/plugins/form-builder/data/forms/to-do.json +0 -100
  108. package/plugins/form-builder/data/submissions/contacts.json +0 -1
  109. package/plugins/form-builder/data/submissions/enquiries.json +0 -1
  110. package/plugins/form-builder/data/submissions/feedback.json +0 -1
  111. package/plugins/form-builder/data/submissions/notes.json +0 -1
  112. package/plugins/form-builder/data/submissions/to-do.json +0 -1
  113. package/plugins/garage/admin/templates/garage.html +0 -111
  114. package/plugins/garage/admin/views/garage.js +0 -622
  115. package/plugins/garage/collections/garage-vehicles/schema.json +0 -101
  116. package/plugins/garage/config.js +0 -18
  117. package/plugins/garage/data/vehicles.json +0 -70
  118. package/plugins/garage/plugin.js +0 -398
  119. package/plugins/garage/plugin.json +0 -33
  120. package/scripts/seed.js +0 -1996
  121. package/server/services/userTypes.js +0 -227
package/server/server.js CHANGED
@@ -17,7 +17,7 @@ import fs from 'fs/promises';
17
17
  import {fileURLToPath} from 'url';
18
18
  import {createRequire} from 'module';
19
19
  import {config, getConfig} from './config.js';
20
- import {registerPlugins} from './services/plugins.js';
20
+ import {getLoadedPlugins, getPluginSettings, registerPlugins} from './services/plugins.js';
21
21
  import {load as loadRoles, seed as seedRoles} from './services/roles.js';
22
22
  import {ensureAllProfiles, seed as seedUserProfiles} from './services/userProfiles.js';
23
23
  import {seedAll as seedPresetCollections} from './services/presetCollections.js';
@@ -44,6 +44,13 @@ if (!JWT_SECRET || JWT_SECRET === 'CHANGE_ME' || JWT_SECRET.length < 32) {
44
44
  process.exit(1);
45
45
  }
46
46
 
47
+ // MANAGER_SECRET is optional — only needed when domma-cms-manager pushes notifications.
48
+ // Warn if set but insecure; silently accept if absent (manager push is disabled).
49
+ const MANAGER_SECRET = process.env.MANAGER_SECRET;
50
+ if (MANAGER_SECRET && MANAGER_SECRET.length < 32) {
51
+ console.warn(' WARNING: MANAGER_SECRET is set but too short (minimum 32 characters). Manager notifications disabled until fixed.');
52
+ }
53
+
47
54
  const app = Fastify({
48
55
  logger: {level: process.env.NODE_ENV === 'development' ? 'info' : 'warn'},
49
56
  // When running behind a reverse proxy (e.g. domma-cms-manager), trust the
@@ -227,7 +234,8 @@ const { layoutsRoutes } = await import('./routes/api/layouts.js');
227
234
  const { navigationRoutes } = await import('./routes/api/navigation.js');
228
235
  const { mediaRoutes } = await import('./routes/api/media.js');
229
236
  const { usersRoutes } = await import('./routes/api/users.js');
230
- const { pluginsRoutes } = await import('./routes/api/plugins.js');
237
+ const { pluginsRoutes } = await import('./routes/api/plugins.js');
238
+ const { pluginMarketplaceRoutes } = await import('./routes/api/plugin-marketplace.js');
231
239
  const { collectionsRoutes } = await import('./routes/api/collections.js');
232
240
  const { formsRoutes } = await import('./routes/api/forms.js');
233
241
  const { viewsRoutes } = await import('./routes/api/views.js');
@@ -242,7 +250,8 @@ await app.register(layoutsRoutes, { prefix: '/api' });
242
250
  await app.register(navigationRoutes, { prefix: '/api' });
243
251
  await app.register(mediaRoutes, { prefix: '/api' });
244
252
  await app.register(usersRoutes, { prefix: '/api' });
245
- await app.register(pluginsRoutes, { prefix: '/api' });
253
+ await app.register(pluginsRoutes, { prefix: '/api' });
254
+ await app.register(pluginMarketplaceRoutes, { prefix: '/api' });
246
255
  await app.register(collectionsRoutes, { prefix: '/api' });
247
256
  await app.register(formsRoutes, { prefix: '/api' });
248
257
  await app.register(viewsRoutes, { prefix: '/api' });
@@ -257,6 +266,26 @@ await app.register(effectsRoutes, {prefix: '/api'});
257
266
 
258
267
  await registerPlugins(app);
259
268
 
269
+ // ---------------------------------------------------------------------------
270
+ // Public Plugin Routes (root-level, before catch-all)
271
+ // ---------------------------------------------------------------------------
272
+
273
+ const { authenticate: _authenticate, requireAdmin: _requireAdmin, requireVisibility: _requireVisibility } = await import('./middleware/auth.js');
274
+ for (const [name, plugin] of Object.entries(getLoadedPlugins())) {
275
+ if (!plugin.enabled || !plugin.publicEntry) continue;
276
+ try {
277
+ const { default: publicPlugin } = await import(plugin.publicEntry);
278
+ const settings = await getPluginSettings(name);
279
+ await app.register(publicPlugin, {
280
+ settings,
281
+ auth: { authenticate: _authenticate, requireAdmin: _requireAdmin, requireVisibility: _requireVisibility },
282
+ config: getConfig()
283
+ });
284
+ } catch (err) {
285
+ app.log.error(`[plugins] Failed to register public plugin ${name}: ${err.message}`);
286
+ }
287
+ }
288
+
260
289
  // ---------------------------------------------------------------------------
261
290
  // Public Site (catch-all — must be last)
262
291
  // ---------------------------------------------------------------------------
@@ -265,7 +265,7 @@ export async function createAction(data, userId = null) {
265
265
  },
266
266
  steps,
267
267
  access: {
268
- roles: access?.roles || ['admin'],
268
+ roles: access?.roles || ['admin', 'super-admin'],
269
269
  rowLevel: access?.rowLevel || null
270
270
  },
271
271
  meta: { createdAt: now, updatedAt: now, createdBy: userId }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Client for outbound calls from domma-cms → domma-cms-manager.
3
+ * Reads MANAGER_URL and INSTANCE_SECRET from env at call time.
4
+ * All functions return null/[] gracefully if MANAGER_URL is unset.
5
+ */
6
+
7
+ import {createHmac} from 'node:crypto';
8
+ import {readFile} from 'node:fs/promises';
9
+ import {fileURLToPath} from 'node:url';
10
+ import {dirname, join} from 'node:path';
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+
14
+ /**
15
+ * Read the CMS version from the root package.json.
16
+ * @returns {Promise<string>} The version string, or '0.0.0' on error.
17
+ */
18
+ async function getCmsVersion() {
19
+ try {
20
+ const pkgPath = join(__dirname, '../../package.json');
21
+ const raw = await readFile(pkgPath, 'utf-8');
22
+ return JSON.parse(raw).version ?? '0.0.0';
23
+ } catch {
24
+ return '0.0.0';
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Register this CMS instance with domma-cms-manager.
30
+ *
31
+ * @param {string} fingerprint - Unique instance fingerprint.
32
+ * @returns {Promise<{licenseToken: string, instanceId: string}|null>}
33
+ */
34
+ export async function registerInstance(fingerprint) {
35
+ const MANAGER_URL = process.env.MANAGER_URL;
36
+ const INSTANCE_SECRET = process.env.INSTANCE_SECRET;
37
+
38
+ if (!MANAGER_URL) return null;
39
+ if (!INSTANCE_SECRET) {
40
+ console.warn('[managerClient] INSTANCE_SECRET not set — skipping registerInstance');
41
+ return null;
42
+ }
43
+
44
+ try {
45
+ const cmsVersion = await getCmsVersion();
46
+ const hostname = process.env.HOSTNAME ?? 'unknown';
47
+
48
+ const token = createHmac('sha256', INSTANCE_SECRET)
49
+ .update(fingerprint)
50
+ .digest('hex');
51
+
52
+ const res = await fetch(`${MANAGER_URL}/api/instances/register`, {
53
+ signal: AbortSignal.timeout(10_000),
54
+ method: 'POST',
55
+ headers: {
56
+ 'Content-Type': 'application/json',
57
+ 'X-Instance-Token': token,
58
+ },
59
+ body: JSON.stringify({ fingerprint, cmsVersion, hostname }),
60
+ });
61
+
62
+ if (!res.ok) {
63
+ console.warn(`[managerClient] registerInstance failed: ${res.status} ${res.statusText}`);
64
+ return null;
65
+ }
66
+
67
+ return await res.json();
68
+ } catch (err) {
69
+ console.warn('[managerClient] registerInstance error:', err.message);
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Fetch the plugin catalogue from domma-cms-manager.
76
+ *
77
+ * @param {string} licenseToken - License token obtained from registerInstance.
78
+ * @returns {Promise<Array<{slug: string, version: string, displayName: string, description: string, price: number, entitled: boolean}>>}
79
+ */
80
+ export async function fetchCatalogue(licenseToken) {
81
+ const MANAGER_URL = process.env.MANAGER_URL;
82
+
83
+ if (!MANAGER_URL) return [];
84
+
85
+ try {
86
+ const res = await fetch(`${MANAGER_URL}/api/plugins/catalogue`, {
87
+ signal: AbortSignal.timeout(10_000),
88
+ headers: {
89
+ Authorization: `Bearer ${licenseToken}`,
90
+ },
91
+ });
92
+
93
+ if (!res.ok) {
94
+ console.warn(`[managerClient] fetchCatalogue failed: ${res.status} ${res.statusText}`);
95
+ return [];
96
+ }
97
+
98
+ return await res.json();
99
+ } catch (err) {
100
+ console.warn('[managerClient] fetchCatalogue error:', err.message);
101
+ return [];
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Fetch a plugin bundle (tarball + signature) from domma-cms-manager.
107
+ *
108
+ * @param {string} slug - Plugin slug.
109
+ * @param {string} version - Plugin version.
110
+ * @param {string} licenseToken - License token obtained from registerInstance.
111
+ * @returns {Promise<{tarball: Buffer, signature: string, publicKeyId: string}|null>}
112
+ */
113
+ export async function fetchPluginBundle(slug, version, licenseToken) {
114
+ const MANAGER_URL = process.env.MANAGER_URL;
115
+
116
+ if (!MANAGER_URL) return null;
117
+
118
+ try {
119
+ const res = await fetch(`${MANAGER_URL}/api/plugins/install/${slug}/${version}`, {
120
+ signal: AbortSignal.timeout(10_000),
121
+ headers: {
122
+ Authorization: `Bearer ${licenseToken}`,
123
+ },
124
+ });
125
+
126
+ if (!res.ok) {
127
+ console.warn(`[managerClient] fetchPluginBundle failed: ${res.status} ${res.statusText}`);
128
+ return null;
129
+ }
130
+
131
+ const json = await res.json();
132
+
133
+ if (typeof json.tarball !== 'string' || json.tarball.length > 100 * 1024 * 1024) {
134
+ console.warn('[managerClient] fetchPluginBundle: tarball too large or invalid, refusing decode');
135
+ return null;
136
+ }
137
+
138
+ return {
139
+ tarball: Buffer.from(json.tarball, 'base64'),
140
+ signature: json.signature,
141
+ publicKeyId: json.publicKeyId,
142
+ };
143
+ } catch (err) {
144
+ console.warn('[managerClient] fetchPluginBundle error:', err.message);
145
+ return null;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Send a heartbeat to domma-cms-manager with the list of installed plugins.
151
+ *
152
+ * @param {string} licenseToken - License token obtained from registerInstance.
153
+ * @param {string[]} installedSlugs - Array of installed plugin slugs.
154
+ * @returns {Promise<boolean>} True on success, false on error.
155
+ */
156
+ export async function heartbeat(licenseToken, installedSlugs) {
157
+ const MANAGER_URL = process.env.MANAGER_URL;
158
+
159
+ if (!MANAGER_URL) return false;
160
+
161
+ try {
162
+ const res = await fetch(`${MANAGER_URL}/api/instances/heartbeat`, {
163
+ signal: AbortSignal.timeout(10_000),
164
+ method: 'POST',
165
+ headers: {
166
+ 'Content-Type': 'application/json',
167
+ Authorization: `Bearer ${licenseToken}`,
168
+ },
169
+ body: JSON.stringify({ installedSlugs, versions: {} }),
170
+ });
171
+
172
+ if (!res.ok) {
173
+ console.warn(`[managerClient] heartbeat failed: ${res.status} ${res.statusText}`);
174
+ return false;
175
+ }
176
+
177
+ return true;
178
+ } catch (err) {
179
+ console.warn('[managerClient] heartbeat error:', err.message);
180
+ return false;
181
+ }
182
+ }
@@ -80,7 +80,16 @@ function renderCollectionBlocks(entries, blockTemplate, emptyMsg, ctaOpts, cols)
80
80
  }
81
81
 
82
82
  const items = entries.map(e => {
83
- let html = blockTemplate.replace(/\{\{([\w_]+)\}\}/g, (_, key) => {
83
+ // Triple-brace `{{{field}}}` — raw HTML pass-through (no escape).
84
+ // Use for fields that contain pre-rendered HTML (bullet lists, code
85
+ // blocks, embedded elements). Must be processed BEFORE double-brace
86
+ // so the outer `{` and `}` of a triple match aren't consumed by the
87
+ // double-brace regex.
88
+ let html = blockTemplate.replace(/\{\{\{(\w+)\}\}\}/g, (_, key) => {
89
+ return String(e.data?.[key] ?? '');
90
+ });
91
+ // Double-brace `{{field}}` — HTML-escaped (default, safe).
92
+ html = html.replace(/\{\{([\w_]+)\}\}/g, (_, key) => {
84
93
  if (key === '_id') return escapeHtmlText(e.id ?? '');
85
94
  if (key === '_createdAt') return escapeHtmlText(e.meta?.createdAt ?? '');
86
95
  if (key === '_updatedAt') return escapeHtmlText(e.meta?.updatedAt ?? '');
@@ -107,7 +116,9 @@ function renderCollectionBlocks(entries, blockTemplate, emptyMsg, ctaOpts, cols)
107
116
  }
108
117
 
109
118
  function renderBlockFromAttrs(tmpl, attrs) {
110
- const rendered = tmpl.replace(/\{\{([\w_]+)\}\}/g, (_, key) => escapeHtmlText(attrs[key] ?? ''));
119
+ // Triple-brace raw pass-through first, then double-brace escaped.
120
+ let rendered = tmpl.replace(/\{\{\{(\w+)\}\}\}/g, (_, key) => String(attrs[key] ?? ''));
121
+ rendered = rendered.replace(/\{\{([\w_]+)\}\}/g, (_, key) => escapeHtmlText(attrs[key] ?? ''));
111
122
  const extraClass = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
112
123
  const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
113
124
  return `<div class="dm-static-block${extraClass}"${idAttr}>${rendered}</div>`;
@@ -442,6 +453,22 @@ async function processCollectionBlocks(markdown) {
442
453
  if (!schema) throw new Error('not found');
443
454
 
444
455
  let {entries} = await listEntries(slug);
456
+
457
+ // Row-level filter: where="field=value" (simple equality only).
458
+ // Comma-separate multiple predicates, all AND'd together.
459
+ // Example: where="tab=developers" or where="tab=developers,status=live"
460
+ const whereAttr = typeof attrs.where === 'string' ? attrs.where.trim() : '';
461
+ if (whereAttr) {
462
+ const predicates = whereAttr.split(',').map(p => p.trim()).filter(Boolean).map(p => {
463
+ const eq = p.indexOf('=');
464
+ if (eq === -1) return null;
465
+ return { key: p.slice(0, eq).trim(), val: p.slice(eq + 1).trim() };
466
+ }).filter(Boolean);
467
+ if (predicates.length) {
468
+ entries = entries.filter(e => predicates.every(({key, val}) => String(e.data?.[key] ?? '') === val));
469
+ }
470
+ }
471
+
445
472
  entries = sortEntries(entries, sort, order);
446
473
  if (limitAttr > 0) entries = entries.slice(0, limitAttr);
447
474
 
@@ -755,7 +782,12 @@ function processGridBlocks(markdown) {
755
782
  const attrs = parseShortcodeAttrs(attrStr);
756
783
  const cls = attrs.span ? ` class="col-span-${attrs.span}"` : ' class="col"';
757
784
  const id = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
758
- return `<div${cls}${id}>${marked.parse(processCardBlocks(body.trim()))}</div>`;
785
+ // Restore the col body before passing it downstream — otherwise the
786
+ // scrub placeholders from this processor's store get carried into
787
+ // processCardBlocks, which creates its own empty store and fails to
788
+ // decode them, producing the literal string "undefined" in place of
789
+ // whatever was in <pre>, ``` fences, or `inline code`.
790
+ return `<div${cls}${id}>${marked.parse(processCardBlocks(restore(body.trim())))}</div>`;
759
791
  }
760
792
  );
761
793
 
@@ -1345,16 +1377,14 @@ function renderLegacyCard(attrs, body, markedInstance, escAttr) {
1345
1377
  const icon = strAttr('icon');
1346
1378
  const footer = strAttr('footer');
1347
1379
  const collapsible = attrs.collapsible === 'true';
1348
- const hover = 'hover' in attrs;
1349
1380
  const variant = strAttr('variant');
1350
- const extraClass = strAttr('class');
1351
1381
 
1352
- // Root class list
1353
- const classes = ['card', 'mb-4'];
1382
+ // Root class list — delegate to the shared helper so variant="gradient",
1383
+ // gradient="<name>", glass/accent/dark/glow, font, shadow, rounded, etc.
1384
+ // all work on legacy cards (cards without a `layout` attribute).
1385
+ const classes = cardVariantClasses(attrs);
1386
+ // Preserve legacy support for variant="primary" (not handled by cardVariantClasses).
1354
1387
  if (variant === 'primary') classes.push('card-primary');
1355
- if (hover) classes.push('card-hover');
1356
- if (collapsible) classes.push('card-collapsible');
1357
- if (extraClass) classes.push(extraClass);
1358
1388
 
1359
1389
  const id = attrs.id ? ` id="${escAttr(attrs.id)}"` : '';
1360
1390
  const coll = collapsible ? ' data-collapsible="true"' : '';
@@ -1440,9 +1470,16 @@ function processCardBlocks(markdown) {
1440
1470
  const attrs = parseShortcodeAttrs(attrStr);
1441
1471
  const layout = typeof attrs.layout === 'string' ? attrs.layout.trim() : '';
1442
1472
  const renderer = LAYOUT_RENDERERS[layout];
1473
+ // Restore code regions INSIDE the card body before rendering so
1474
+ // marked can process fenced code blocks (```lang ... ```) normally.
1475
+ // Without this, the body contains placeholder tokens that marked
1476
+ // wraps in <p>, and the outer restore() call then substitutes raw
1477
+ // backticks into the <p>, producing literal '```json' text and
1478
+ // collapsing the HTML on any inline-backtick template literal.
1479
+ const restoredBody = restore(body);
1443
1480
  return renderer
1444
- ? renderer(attrs, body, marked, escapeAttr)
1445
- : renderLegacyCard(attrs, body, marked, escapeAttr);
1481
+ ? renderer(attrs, restoredBody, marked, escapeAttr)
1482
+ : renderLegacyCard(attrs, restoredBody, marked, escapeAttr);
1446
1483
  }
1447
1484
  ));
1448
1485
  }
@@ -1865,8 +1902,9 @@ function processTextBlocks(markdown) {
1865
1902
  styles.push('font-style:italic');
1866
1903
  }
1867
1904
 
1868
- if (attrs.color) {
1869
- styles.push(`color:${COLOR_TOKENS[attrs.color] || attrs.color}`);
1905
+ const colourVal = attrs.colour || attrs.color;
1906
+ if (colourVal) {
1907
+ styles.push(`color:${COLOR_TOKENS[colourVal] || colourVal}`);
1870
1908
  }
1871
1909
 
1872
1910
  if (attrs.font && FONT_MAP[attrs.font]) {