domma-cms 0.9.10 → 0.12.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 (125) 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/card-builder.js +2 -2
  8. package/admin/js/lib/markdown-toolbar.js +5 -5
  9. package/admin/js/lib/safe-html.js +1 -0
  10. package/admin/js/lib/shortcode-modal.js +1 -0
  11. package/admin/js/templates/layouts.html +5 -4
  12. package/admin/js/templates/notifications.html +14 -0
  13. package/admin/js/templates/plugin-marketplace.html +16 -0
  14. package/admin/js/templates/plugins.html +17 -5
  15. package/admin/js/views/index.js +1 -1
  16. package/admin/js/views/layouts.js +1 -16
  17. package/admin/js/views/notifications.js +1 -0
  18. package/admin/js/views/page-editor.js +37 -33
  19. package/admin/js/views/plugin-marketplace.js +1 -0
  20. package/admin/js/views/plugins.js +16 -16
  21. package/config/navigation.json +5 -72
  22. package/config/plugins.json +10 -14
  23. package/config/presets.json +50 -13
  24. package/config/site.json +11 -63
  25. package/package.json +2 -1
  26. package/plugins/_template/admin/templates/index.html +17 -0
  27. package/plugins/_template/admin/views/index.js +19 -0
  28. package/plugins/_template/config.js +8 -0
  29. package/plugins/_template/plugin.js +23 -0
  30. package/plugins/_template/plugin.json +34 -0
  31. package/plugins/analytics/plugin.json +41 -31
  32. package/plugins/blog/admin/templates/blog.html +22 -0
  33. package/plugins/blog/admin/templates/categories.html +7 -0
  34. package/plugins/blog/admin/templates/comments.html +11 -0
  35. package/plugins/blog/admin/templates/post-editor.html +97 -0
  36. package/plugins/blog/admin/templates/settings.html +11 -0
  37. package/plugins/blog/admin/views/blog.js +183 -0
  38. package/plugins/blog/admin/views/categories.js +235 -0
  39. package/plugins/blog/admin/views/comments.js +187 -0
  40. package/plugins/blog/admin/views/post-editor.js +291 -0
  41. package/plugins/blog/admin/views/settings.js +100 -0
  42. package/plugins/blog/collections/categories/schema.json +12 -0
  43. package/plugins/blog/collections/comments/schema.json +16 -0
  44. package/plugins/blog/collections/posts/schema.json +19 -0
  45. package/plugins/blog/config.js +8 -0
  46. package/plugins/blog/plugin.js +352 -0
  47. package/plugins/blog/plugin.json +96 -0
  48. package/plugins/blog/roles/blog-author.json +10 -0
  49. package/plugins/blog/roles/blog-editor.json +12 -0
  50. package/plugins/blog/templates/author.html +9 -0
  51. package/plugins/blog/templates/category.html +9 -0
  52. package/plugins/blog/templates/index.html +9 -0
  53. package/plugins/blog/templates/post.html +17 -0
  54. package/plugins/blog/templates/tag.html +9 -0
  55. package/plugins/contacts/collections/user-contact-groups/schema.json +1 -1
  56. package/plugins/contacts/collections/user-contacts/schema.json +1 -1
  57. package/plugins/contacts/plugin.js +4 -10
  58. package/plugins/contacts/plugin.json +13 -3
  59. package/plugins/notes/collections/user-notes/schema.json +1 -1
  60. package/plugins/notes/plugin.js +3 -9
  61. package/plugins/notes/plugin.json +13 -3
  62. package/plugins/site-search/plugin.json +5 -2
  63. package/plugins/theme-switcher/plugin.json +1 -1
  64. package/plugins/todo/collections/todos/schema.json +1 -1
  65. package/plugins/todo/plugin.js +3 -9
  66. package/plugins/todo/plugin.json +13 -3
  67. package/public/css/site.css +1 -1
  68. package/public/js/site.js +1 -1
  69. package/scripts/build.js +48 -0
  70. package/scripts/create-plugin.js +113 -0
  71. package/scripts/fresh.js +6 -7
  72. package/scripts/gen-instance-secret.js +46 -0
  73. package/scripts/reset.js +3 -3
  74. package/scripts/setup.js +31 -13
  75. package/server/middleware/auth.js +48 -0
  76. package/server/middleware/managerAuth.js +36 -0
  77. package/server/routes/api/actions.js +1 -1
  78. package/server/routes/api/auth.js +4 -3
  79. package/server/routes/api/layouts.js +173 -49
  80. package/server/routes/api/notifications.js +155 -0
  81. package/server/routes/api/plugin-marketplace.js +75 -0
  82. package/server/routes/api/users.js +1 -1
  83. package/server/routes/api/views.js +1 -1
  84. package/server/routes/public.js +4 -9
  85. package/server/server.js +32 -3
  86. package/server/services/actions.js +1 -1
  87. package/server/services/managerClient.js +182 -0
  88. package/server/services/markdown.js +76 -9
  89. package/server/services/permissionRegistry.js +245 -173
  90. package/server/services/pluginInstaller.js +301 -0
  91. package/server/services/plugins.js +117 -10
  92. package/server/services/presetCollections.js +66 -251
  93. package/server/services/renderer.js +99 -0
  94. package/server/services/roles.js +191 -39
  95. package/server/services/users.js +1 -1
  96. package/server/services/views.js +1 -1
  97. package/server/templates/page.html +2 -2
  98. package/plugins/docs/admin/templates/docs.html +0 -69
  99. package/plugins/docs/admin/views/docs.js +0 -276
  100. package/plugins/docs/config.js +0 -8
  101. package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +0 -11
  102. package/plugins/docs/data/folders.json +0 -9
  103. package/plugins/docs/data/templates.json +0 -1
  104. package/plugins/docs/data/versions/57e003f0-68f2-47dc-9c36-ed4b10ed3deb/1.json +0 -5
  105. package/plugins/docs/plugin.js +0 -375
  106. package/plugins/docs/plugin.json +0 -23
  107. package/plugins/form-builder/data/forms/contacts.json +0 -66
  108. package/plugins/form-builder/data/forms/enquiries.json +0 -103
  109. package/plugins/form-builder/data/forms/feedback.json +0 -131
  110. package/plugins/form-builder/data/forms/notes.json +0 -79
  111. package/plugins/form-builder/data/forms/to-do.json +0 -100
  112. package/plugins/form-builder/data/submissions/contacts.json +0 -1
  113. package/plugins/form-builder/data/submissions/enquiries.json +0 -1
  114. package/plugins/form-builder/data/submissions/feedback.json +0 -1
  115. package/plugins/form-builder/data/submissions/notes.json +0 -1
  116. package/plugins/form-builder/data/submissions/to-do.json +0 -1
  117. package/plugins/garage/admin/templates/garage.html +0 -111
  118. package/plugins/garage/admin/views/garage.js +0 -622
  119. package/plugins/garage/collections/garage-vehicles/schema.json +0 -101
  120. package/plugins/garage/config.js +0 -18
  121. package/plugins/garage/data/vehicles.json +0 -70
  122. package/plugins/garage/plugin.js +0 -398
  123. package/plugins/garage/plugin.json +0 -33
  124. package/scripts/seed.js +0 -1996
  125. package/server/services/userTypes.js +0 -227
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Plugin Marketplace API
3
+ * GET /api/plugins/marketplace — fetch catalogue from manager (requires MANAGER_URL)
4
+ * POST /api/plugins/marketplace/install — download + install a plugin bundle
5
+ * DELETE /api/plugins/marketplace/:slug — uninstall an installed plugin
6
+ *
7
+ * All routes require authentication + admin role.
8
+ */
9
+ import {authenticate, requireAdmin} from '../../middleware/auth.js';
10
+ import {fetchCatalogue, fetchPluginBundle} from '../../services/managerClient.js';
11
+ import {installPlugin, uninstallPlugin} from '../../services/pluginInstaller.js';
12
+
13
+ export async function pluginMarketplaceRoutes(fastify) {
14
+ const adminOnly = { preHandler: [authenticate, requireAdmin] };
15
+
16
+ // GET /plugins/marketplace
17
+ fastify.get('/plugins/marketplace', adminOnly, async (_request, reply) => {
18
+ if (!process.env.MANAGER_URL) {
19
+ return reply.send({ available: false, catalogue: [] });
20
+ }
21
+
22
+ const licenceToken = process.env.LICENCE_TOKEN;
23
+ const catalogue = await fetchCatalogue(licenceToken);
24
+ return reply.send({ available: true, catalogue });
25
+ });
26
+
27
+ // POST /plugins/marketplace/install
28
+ fastify.post('/plugins/marketplace/install', adminOnly, async (request, reply) => {
29
+ const { slug, version } = request.body || {};
30
+
31
+ if (!slug || !/^[a-z0-9][a-z0-9-_]{0,63}$/.test(slug)) {
32
+ return reply.code(400).send({ error: 'Invalid plugin slug.' });
33
+ }
34
+ if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
35
+ return reply.code(400).send({ error: 'Invalid version format. Expected semver (e.g. 1.0.0).' });
36
+ }
37
+
38
+ const licenceToken = process.env.LICENCE_TOKEN;
39
+ const bundle = await fetchPluginBundle(slug, version, licenceToken);
40
+
41
+ if (!bundle) {
42
+ return reply.code(503).send({ error: 'Manager unavailable' });
43
+ }
44
+
45
+ try {
46
+ await installPlugin(slug, bundle);
47
+ } catch (err) {
48
+ fastify.log.error({ err }, '[marketplace] install failed');
49
+ // Known safe error messages from pluginInstaller (for admin clarity)
50
+ const safeMessages = ['Plugin already installed', 'Plugin signature invalid', 'Unknown public key', 'Invalid plugin slug'];
51
+ const safe = safeMessages.some(m => err.message.startsWith(m));
52
+ return reply.code(400).send({ error: safe ? err.message : 'Installation failed.' });
53
+ }
54
+
55
+ return reply.send({ success: true });
56
+ });
57
+
58
+ // DELETE /plugins/marketplace/:slug
59
+ fastify.delete('/plugins/marketplace/:slug', adminOnly, async (request, reply) => {
60
+ const { slug } = request.params;
61
+
62
+ if (!slug || !/^[a-z0-9][a-z0-9-_]{0,63}$/.test(slug)) {
63
+ return reply.code(400).send({ error: 'Invalid plugin slug.' });
64
+ }
65
+
66
+ try {
67
+ await uninstallPlugin(slug);
68
+ } catch (err) {
69
+ fastify.log.error({ err }, '[marketplace] uninstall failed');
70
+ return reply.code(400).send({ error: 'Uninstall failed.' });
71
+ }
72
+
73
+ return reply.send({ success: true });
74
+ });
75
+ }
@@ -54,7 +54,7 @@ export async function usersRoutes(fastify) {
54
54
  return reply.status(400).send({ error: 'Password must be at least 8 characters' });
55
55
  }
56
56
 
57
- const targetRole = role || 'editor';
57
+ const targetRole = role || 'user';
58
58
  if (!canManageUser(request.user.role, targetRole)) {
59
59
  return reply.code(403).send({ error: 'You cannot create a user with that role' });
60
60
  }
@@ -125,7 +125,7 @@ export async function viewsRoutes(fastify) {
125
125
  }
126
126
 
127
127
  const user = request.user;
128
- const allowedRoles = view.access?.roles || ['admin'];
128
+ const allowedRoles = view.access?.roles || ['admin', 'super-admin'];
129
129
  const userLevel = getRoleLevel(user?.role);
130
130
  const minAllowed = Math.min(...allowedRoles.map(r => getRoleLevel(r)));
131
131
 
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import {getPage} from '../services/content.js';
8
8
  import {renderPage} from '../services/renderer.js';
9
- import {getRoleLevel} from '../services/roles.js';
9
+ import {checkVisibility} from '../middleware/auth.js';
10
10
  import {hooks} from '../services/hooks.js';
11
11
 
12
12
  /**
@@ -69,15 +69,10 @@ export async function publicRoutes(fastify) {
69
69
  let userRole = null;
70
70
  try {
71
71
  const decoded = await request.jwtVerify();
72
- userRole = decoded.role;
73
- } catch { /* no token — treat as unauthenticated */
74
- }
75
-
76
- const userLevel = getRoleLevel(userRole);
77
- const visibilityLevel = getRoleLevel(page.visibility);
78
- const requiredLevel = visibilityLevel === Infinity ? 0 : visibilityLevel;
72
+ if (decoded.type === 'access') userRole = decoded.role;
73
+ } catch { /* no token — treat as unauthenticated */ }
79
74
 
80
- if (userLevel > requiredLevel) {
75
+ if (!checkVisibility(userRole, page.visibility)) {
81
76
  reply.status(403);
82
77
  return reply.type('text/html').send(accessDeniedHtml(urlPath));
83
78
  }
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
+ }
@@ -20,7 +20,7 @@ const BUILTIN_SHORTCODES = new Set([
20
20
  'accordion', 'item', 'carousel', 'slide', 'countdown', 'timeline',
21
21
  'event', 'spacer', 'center', 'icon', 'form', 'hero', 'table', 'badge',
22
22
  'text', 'button', 'link', 'cta', 'grid', 'row', 'col', 'card',
23
- 'slideover', 'counter', 'celebrate', 'firework', 'fireworks', 'scribe',
23
+ 'banner', 'slideover', 'counter', 'celebrate', 'firework', 'fireworks', 'scribe',
24
24
  'reveal', 'breathe', 'pulse', 'shake', 'scramble', 'ripple', 'twinkle',
25
25
  'animate', 'ambient', 'list-group',
26
26
  ]);
@@ -1473,7 +1473,10 @@ function processTabsBlocks(markdown) {
1473
1473
  const prefix = `dm-tab-${counter}`;
1474
1474
  const attrs = parseShortcodeAttrs(attrStr);
1475
1475
  const pillsClass = attrs.style === 'pills' ? ' tabs-pills' : '';
1476
+ const borderedClass = attrs.style === 'bordered' ? ' tabs--bordered' : '';
1477
+ const fadeClass = 'fade' in attrs ? ' tabs--fade' : '';
1476
1478
  const centeredClass = attrs.align === 'center' ? ' tabs-centered' : '';
1479
+ const rightClass = attrs.align === 'right' ? ' tabs--right' : '';
1477
1480
  const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
1478
1481
 
1479
1482
  // Parse inner [tab title="..."] items
@@ -1489,20 +1492,22 @@ function processTabsBlocks(markdown) {
1489
1492
  // that would confuse subsequent scrubCodeRegions calls.
1490
1493
  const restoredBody = restore(tabBody.trim());
1491
1494
  const bodyHtml = marked.parse(processCardBlocks(processGridBlocks(restoredBody)));
1492
- items.push({title, paneId, bodyHtml, first: tabIdx === 1});
1495
+ const icon = tabAttrs.icon ? tabAttrs.icon.trim() : '';
1496
+ items.push({title, icon, paneId, bodyHtml, first: tabIdx === 1});
1493
1497
  });
1494
1498
 
1495
1499
  if (!items.length) return '';
1496
1500
 
1497
- const navItems = items.map(t =>
1498
- `<button class="tab-item${t.first ? ' active' : ''}">${escapeAttr(t.title)}</button>`
1499
- ).join('\n ');
1501
+ const navItems = items.map(t => {
1502
+ const iconHtml = t.icon ? `<span data-icon="${escapeAttr(t.icon)}"></span>` : '';
1503
+ return `<button class="tab-item${t.first ? ' active' : ''}">${iconHtml}${escapeAttr(t.title)}</button>`;
1504
+ }).join('\n ');
1500
1505
  const panes = items.map(t =>
1501
1506
  `<div class="tab-panel${t.first ? ' active' : ''}">${t.bodyHtml}</div>`
1502
1507
  ).join('\n ');
1503
1508
 
1504
1509
  return (
1505
- `<div class="tabs${pillsClass}${centeredClass}"${idAttr}>\n` +
1510
+ `<div class="tabs${pillsClass}${borderedClass}${fadeClass}${centeredClass}${rightClass}"${idAttr}>\n` +
1506
1511
  ` <div class="tab-list">\n ${navItems}\n </div>\n` +
1507
1512
  ` <div class="tab-content">\n ${panes}\n </div>\n` +
1508
1513
  `</div>\n`
@@ -2279,6 +2284,8 @@ function processHeroBlocks(markdown) {
2279
2284
  const cls = attrs.class || '';
2280
2285
  const id = attrs.id || '';
2281
2286
  const bg = attrs.bg || '';
2287
+ const heroColor = attrs.color ? String(attrs.color) : '';
2288
+ const minHeight = attrs['min-height'] ? String(attrs['min-height']) : '';
2282
2289
 
2283
2290
  const twinkle = 'twinkle' in attrs;
2284
2291
  const twinkleCount = attrs['twinkle-count'] || '';
@@ -2299,7 +2306,11 @@ function processHeroBlocks(markdown) {
2299
2306
 
2300
2307
  const styleParts = [];
2301
2308
  if (image) styleParts.push(`background-image:url('${escapeAttr(image)}')`);
2302
- if (bg) styleParts.push(`background-color:${escapeAttr(bg)}`);
2309
+ const resolvedBg = heroColor || bg;
2310
+ const safeBg = resolvedBg && /^[a-zA-Z0-9#(),.\s%/-]+$/.test(resolvedBg) ? resolvedBg : '';
2311
+ if (safeBg) styleParts.push(`background-color:${safeBg}`);
2312
+ const safeMinHeight = minHeight && /^[0-9]+(?:px|em|rem|vh|vw|%)$/.test(minHeight) ? minHeight : '';
2313
+ if (safeMinHeight) styleParts.push(`min-height:${safeMinHeight}`);
2303
2314
  const style = styleParts.length ? ` style="${styleParts.join(';')}"` : '';
2304
2315
  const idAttr = id ? ` id="${escapeAttr(id)}"` : '';
2305
2316
 
@@ -2337,6 +2348,61 @@ function processHeroBlocks(markdown) {
2337
2348
  * @param {string} markdown
2338
2349
  * @returns {string}
2339
2350
  */
2351
+ /**
2352
+ * Pre-process [banner] shortcodes before running through marked.
2353
+ *
2354
+ * Syntax:
2355
+ * [banner type="info" title="Note" icon="info" dismissible]
2356
+ * Body content (Markdown parsed).
2357
+ * [/banner]
2358
+ *
2359
+ * Supported attributes:
2360
+ * type - info (default) / success / warning / danger / neutral
2361
+ * title - optional heading text
2362
+ * icon - Domma icon name (renders <span data-icon="...">)
2363
+ * dismissible - bare flag; adds dismiss button
2364
+ * class - extra CSS class on the wrapper
2365
+ *
2366
+ * @param {string} markdown
2367
+ * @returns {string}
2368
+ */
2369
+ function processBannerBlocks(markdown) {
2370
+ return markdown.replace(
2371
+ /\[banner([^\]]*)\]([\s\S]*?)\[\/banner\]/gi,
2372
+ (_, attrStr, body) => {
2373
+ const attrs = parseShortcodeAttrs(attrStr);
2374
+ const VALID_BANNER_TYPES = new Set(['info', 'success', 'warning', 'danger', 'neutral']);
2375
+ const type = VALID_BANNER_TYPES.has(attrs.type) ? attrs.type : 'info';
2376
+ const title = attrs.title || '';
2377
+ const icon = attrs.icon || '';
2378
+ const dismissible = 'dismissible' in attrs;
2379
+ const extraClass = attrs.class || '';
2380
+
2381
+ const classes = [`dm-banner`, `dm-banner--${type}`];
2382
+ if (extraClass) classes.push(extraClass);
2383
+
2384
+ const iconHtml = icon
2385
+ ? `<span class="dm-banner__icon" data-icon="${escapeAttr(icon)}"></span>\n `
2386
+ : '';
2387
+ const titleHtml = title
2388
+ ? `<strong class="dm-banner__title">${escapeAttr(title)}</strong>\n `
2389
+ : '';
2390
+ const bodyHtml = marked.parse(body.trim());
2391
+ const dismissHtml = dismissible
2392
+ ? `\n <button class="dm-banner__dismiss" aria-label="Dismiss">×</button>`
2393
+ : '';
2394
+
2395
+ return (
2396
+ `<div class="${classes.join(' ')}">\n` +
2397
+ ` ${iconHtml}<div class="dm-banner__body">\n` +
2398
+ ` ${titleHtml}${bodyHtml}` +
2399
+ ` </div>${dismissHtml}\n` +
2400
+ `</div>\n`
2401
+ );
2402
+ }
2403
+ );
2404
+ }
2405
+
2340
2406
  function processSlideoverBlocks(markdown) {
2341
2407
  const {scrubbed, restore} = scrubCodeRegions(markdown);
2342
2408
  let counter = 0;
@@ -2478,7 +2544,8 @@ export async function parseMarkdown(raw) {
2478
2544
  const withCta = processCtaBlocks(withLink);
2479
2545
  const withGrid = processGridBlocks(withCta);
2480
2546
  const withCard = processCardBlocks(withGrid);
2481
- const withSlideover = processSlideoverBlocks(withCard);
2547
+ const withBanner = processBannerBlocks(withCard);
2548
+ const withSlideover = processSlideoverBlocks(withBanner);
2482
2549
  const rendered = marked.parse(withSlideover);
2483
2550
 
2484
2551
  const sanitized = sanitizeHtml(rendered, {
@@ -2501,7 +2568,7 @@ export async function parseMarkdown(raw) {
2501
2568
  select: ['name', 'required', 'disabled', 'multiple'],
2502
2569
  option: ['value', 'selected', 'disabled'],
2503
2570
  optgroup: ['label', 'disabled'],
2504
- button: ['type', 'disabled', 'data-action', 'data-entry', 'data-confirm'],
2571
+ button: ['type', 'disabled', 'aria-label', 'data-action', 'data-entry', 'data-confirm'],
2505
2572
  label: ['for'],
2506
2573
  fieldset: ['disabled'],
2507
2574
  ...extensions.attributes