domma-cms 0.13.7 → 0.14.1

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 (35) hide show
  1. package/admin/css/admin.css +1 -1
  2. package/admin/js/api.js +1 -1
  3. package/admin/js/app.js +2 -2
  4. package/admin/js/config/sidebar-config.js +1 -1
  5. package/admin/js/lib/markdown-toolbar.js +24 -18
  6. package/admin/js/lib/scribe-composer.js +4 -0
  7. package/admin/js/lib/simple-editor.js +49 -0
  8. package/admin/js/templates/block-editor.html +76 -18
  9. package/admin/js/templates/blocks.html +18 -8
  10. package/admin/js/templates/component-editor.html +141 -0
  11. package/admin/js/templates/components.html +18 -0
  12. package/admin/js/views/block-editor-enhance.js +1 -0
  13. package/admin/js/views/block-editor.js +8 -8
  14. package/admin/js/views/blocks.js +11 -4
  15. package/admin/js/views/component-editor.js +28 -0
  16. package/admin/js/views/components.js +11 -0
  17. package/admin/js/views/index.js +1 -1
  18. package/admin/js/views/page-editor.js +6 -6
  19. package/admin/js/views/pages.js +5 -2
  20. package/config/site.json +1 -1
  21. package/package.json +2 -2
  22. package/public/css/site.css +1 -1
  23. package/server/routes/api/blocks.js +128 -60
  24. package/server/routes/api/components.js +115 -0
  25. package/server/routes/api/pages.js +135 -132
  26. package/server/routes/api/versions.js +16 -0
  27. package/server/server.js +6 -0
  28. package/server/services/blocks.js +387 -284
  29. package/server/services/components.js +653 -0
  30. package/server/services/content.js +334 -334
  31. package/server/services/hooks.js +28 -0
  32. package/server/services/markdown.js +2838 -2629
  33. package/server/services/permissionRegistry.js +13 -0
  34. package/server/services/renderer.js +10 -2
  35. package/server/services/versions.js +37 -0
@@ -1,132 +1,135 @@
1
- /**
2
- * Pages API
3
- * GET /api/pages - list all pages
4
- * GET /api/pages/* - get single page by URL path
5
- * POST /api/pages - create page
6
- * PUT /api/pages/* - update page
7
- * DELETE /api/pages/* - delete page
8
- */
9
- import {createPage, deletePage, getPage, listPages, renamePage, updatePage} from '../../services/content.js';
10
- import {parseMarkdown} from '../../services/markdown.js';
11
- import {authenticate, requirePermission} from '../../middleware/auth.js';
12
- import {getConfig, saveConfig} from '../../config.js';
13
-
14
- export async function pagesRoutes(fastify) {
15
- const canRead = {preHandler: [authenticate, requirePermission('pages', 'read')]};
16
- const canCreate = {preHandler: [authenticate, requirePermission('pages', 'create')]};
17
- const canUpdate = {preHandler: [authenticate, requirePermission('pages', 'update')]};
18
- const canDelete = {preHandler: [authenticate, requirePermission('pages', 'delete')]};
19
-
20
- // Render markdown preview (shortcodes + sanitize, no frontmatter)
21
- fastify.post('/pages/preview', canRead, async (request, reply) => {
22
- const {markdown} = request.body;
23
- if (typeof markdown !== 'string') return reply.status(400).send({error: 'markdown must be a string'});
24
- const {html} = await parseMarkdown(`---\ntitle: preview\n---\n${markdown}`);
25
- return {html};
26
- });
27
-
28
- // Aggregate unique tags from all pages — must be registered before the wildcard /pages/* route
29
- fastify.get('/pages/tags', canRead, async (request, reply) => {
30
- const pages = await listPages();
31
- const tagSet = new Set();
32
- pages.forEach(p => {
33
- if (Array.isArray(p.tags)) p.tags.forEach(t => { if (t) tagSet.add(t); });
34
- });
35
- return { tags: [...tagSet].sort() };
36
- });
37
-
38
- // List all pages
39
- fastify.get('/pages', canRead, async (request, reply) => {
40
- const pages = await listPages();
41
- return pages.map(p => ({
42
- urlPath: p.urlPath,
43
- title: p.title,
44
- slug: p.slug,
45
- status: p.status,
46
- layout: p.layout,
47
- showInNav: p.showInNav,
48
- sortOrder: p.sortOrder,
49
- category: p.category || null,
50
- visibility: p.visibility || 'public',
51
- tags: p.tags || [],
52
- updatedAt: p.updatedAt,
53
- createdAt: p.createdAt
54
- }));
55
- });
56
-
57
- // Get single page
58
- fastify.get('/pages/*', canRead, async (request, reply) => {
59
- const urlPath = '/' + request.params['*'];
60
- const page = await getPage(urlPath);
61
- if (!page) return reply.status(404).send({ error: 'Page not found' });
62
- return page;
63
- });
64
-
65
- // Create page
66
- fastify.post('/pages', canCreate, async (request, reply) => {
67
- const { urlPath, frontmatter, body } = request.body;
68
- if (!urlPath) return reply.status(400).send({ error: 'urlPath is required' });
69
-
70
- const existing = await getPage(urlPath);
71
- if (existing) return reply.status(409).send({ error: 'Page already exists at that path' });
72
-
73
- const page = await createPage(urlPath, frontmatter || {}, body || '');
74
- return reply.status(201).send(page);
75
- });
76
-
77
- // Update page (optionally rename to a new URL path)
78
- fastify.put('/pages/*', canUpdate, async (request, reply) => {
79
- const urlPath = '/' + request.params['*'];
80
- const { frontmatter, body, newUrlPath } = request.body;
81
-
82
- const existing = await getPage(urlPath);
83
- if (!existing) return reply.status(404).send({ error: 'Page not found' });
84
-
85
- if (newUrlPath && newUrlPath !== urlPath) {
86
- const conflict = await getPage(newUrlPath);
87
- if (conflict) return reply.status(409).send({ error: 'A page already exists at that path' });
88
-
89
- await renamePage(urlPath, newUrlPath);
90
- await rewriteNavLinks(urlPath, newUrlPath);
91
-
92
- const page = await updatePage(newUrlPath, frontmatter || {}, body, {author: request.user.username});
93
- return page;
94
- }
95
-
96
- const page = await updatePage(urlPath, frontmatter || {}, body, {author: request.user.username});
97
- return page;
98
- });
99
-
100
- // Delete page
101
- fastify.delete('/pages/*', canDelete, async (request, reply) => {
102
- const urlPath = '/' + request.params['*'];
103
- const existing = await getPage(urlPath);
104
- if (!existing) return reply.status(404).send({ error: 'Page not found' });
105
-
106
- await deletePage(urlPath);
107
- return { success: true };
108
- });
109
- }
110
-
111
- /**
112
- * Rewrite any navigation item URLs that match oldUrlPath to newUrlPath,
113
- * then persist the updated navigation config.
114
- *
115
- * @param {string} oldUrlPath
116
- * @param {string} newUrlPath
117
- * @returns {Promise<void>}
118
- */
119
- async function rewriteNavLinks(oldUrlPath, newUrlPath) {
120
- const nav = getConfig('navigation');
121
- if (!nav?.items?.length) return;
122
-
123
- let changed = false;
124
- for (const item of nav.items) {
125
- if (item.url === oldUrlPath) { item.url = newUrlPath; changed = true; }
126
- for (const child of item.children || []) {
127
- if (child.url === oldUrlPath) { child.url = newUrlPath; changed = true; }
128
- }
129
- }
130
-
131
- if (changed) await saveConfig('navigation', nav);
132
- }
1
+ /**
2
+ * Pages API
3
+ * GET /api/pages - list all pages
4
+ * GET /api/pages/* - get single page by URL path
5
+ * POST /api/pages - create page
6
+ * PUT /api/pages/* - update page
7
+ * DELETE /api/pages/* - delete page
8
+ */
9
+ import {createPage, deletePage, getPage, listPages, renamePage, updatePage} from '../../services/content.js';
10
+ import {parseMarkdown} from '../../services/markdown.js';
11
+ import {getVersionCount} from '../../services/versions.js';
12
+ import {authenticate, requirePermission} from '../../middleware/auth.js';
13
+ import {getConfig, saveConfig} from '../../config.js';
14
+
15
+ export async function pagesRoutes(fastify) {
16
+ const canRead = {preHandler: [authenticate, requirePermission('pages', 'read')]};
17
+ const canCreate = {preHandler: [authenticate, requirePermission('pages', 'create')]};
18
+ const canUpdate = {preHandler: [authenticate, requirePermission('pages', 'update')]};
19
+ const canDelete = {preHandler: [authenticate, requirePermission('pages', 'delete')]};
20
+
21
+ // Render markdown preview (shortcodes + sanitize, no frontmatter)
22
+ fastify.post('/pages/preview', canRead, async (request, reply) => {
23
+ const {markdown} = request.body;
24
+ if (typeof markdown !== 'string') return reply.status(400).send({error: 'markdown must be a string'});
25
+ const {html} = await parseMarkdown(`---\ntitle: preview\n---\n${markdown}`);
26
+ return {html};
27
+ });
28
+
29
+ // Aggregate unique tags from all pages must be registered before the wildcard /pages/* route
30
+ fastify.get('/pages/tags', canRead, async (request, reply) => {
31
+ const pages = await listPages();
32
+ const tagSet = new Set();
33
+ pages.forEach(p => {
34
+ if (Array.isArray(p.tags)) p.tags.forEach(t => { if (t) tagSet.add(t); });
35
+ });
36
+ return { tags: [...tagSet].sort() };
37
+ });
38
+
39
+ // List all pages
40
+ fastify.get('/pages', canRead, async (request, reply) => {
41
+ const pages = await listPages();
42
+ const counts = await Promise.all(pages.map(p => getVersionCount(p.urlPath)));
43
+ return pages.map((p, i) => ({
44
+ urlPath: p.urlPath,
45
+ title: p.title,
46
+ slug: p.slug,
47
+ status: p.status,
48
+ layout: p.layout,
49
+ showInNav: p.showInNav,
50
+ sortOrder: p.sortOrder,
51
+ category: p.category || null,
52
+ visibility: p.visibility || 'public',
53
+ tags: p.tags || [],
54
+ updatedAt: p.updatedAt,
55
+ createdAt: p.createdAt,
56
+ versionCount: counts[i]
57
+ }));
58
+ });
59
+
60
+ // Get single page
61
+ fastify.get('/pages/*', canRead, async (request, reply) => {
62
+ const urlPath = '/' + request.params['*'];
63
+ const page = await getPage(urlPath);
64
+ if (!page) return reply.status(404).send({ error: 'Page not found' });
65
+ return page;
66
+ });
67
+
68
+ // Create page
69
+ fastify.post('/pages', canCreate, async (request, reply) => {
70
+ const { urlPath, frontmatter, body } = request.body;
71
+ if (!urlPath) return reply.status(400).send({ error: 'urlPath is required' });
72
+
73
+ const existing = await getPage(urlPath);
74
+ if (existing) return reply.status(409).send({ error: 'Page already exists at that path' });
75
+
76
+ const page = await createPage(urlPath, frontmatter || {}, body || '');
77
+ return reply.status(201).send(page);
78
+ });
79
+
80
+ // Update page (optionally rename to a new URL path)
81
+ fastify.put('/pages/*', canUpdate, async (request, reply) => {
82
+ const urlPath = '/' + request.params['*'];
83
+ const { frontmatter, body, newUrlPath } = request.body;
84
+
85
+ const existing = await getPage(urlPath);
86
+ if (!existing) return reply.status(404).send({ error: 'Page not found' });
87
+
88
+ if (newUrlPath && newUrlPath !== urlPath) {
89
+ const conflict = await getPage(newUrlPath);
90
+ if (conflict) return reply.status(409).send({ error: 'A page already exists at that path' });
91
+
92
+ await renamePage(urlPath, newUrlPath);
93
+ await rewriteNavLinks(urlPath, newUrlPath);
94
+
95
+ const page = await updatePage(newUrlPath, frontmatter || {}, body, {author: request.user.username});
96
+ return page;
97
+ }
98
+
99
+ const page = await updatePage(urlPath, frontmatter || {}, body, {author: request.user.username});
100
+ return page;
101
+ });
102
+
103
+ // Delete page
104
+ fastify.delete('/pages/*', canDelete, async (request, reply) => {
105
+ const urlPath = '/' + request.params['*'];
106
+ const existing = await getPage(urlPath);
107
+ if (!existing) return reply.status(404).send({ error: 'Page not found' });
108
+
109
+ await deletePage(urlPath);
110
+ return { success: true };
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Rewrite any navigation item URLs that match oldUrlPath to newUrlPath,
116
+ * then persist the updated navigation config.
117
+ *
118
+ * @param {string} oldUrlPath
119
+ * @param {string} newUrlPath
120
+ * @returns {Promise<void>}
121
+ */
122
+ async function rewriteNavLinks(oldUrlPath, newUrlPath) {
123
+ const nav = getConfig('navigation');
124
+ if (!nav?.items?.length) return;
125
+
126
+ let changed = false;
127
+ for (const item of nav.items) {
128
+ if (item.url === oldUrlPath) { item.url = newUrlPath; changed = true; }
129
+ for (const child of item.children || []) {
130
+ if (child.url === oldUrlPath) { child.url = newUrlPath; changed = true; }
131
+ }
132
+ }
133
+
134
+ if (changed) await saveConfig('navigation', nav);
135
+ }
@@ -14,6 +14,7 @@ import {
14
14
  deleteVersions,
15
15
  getVersion,
16
16
  listVersions,
17
+ pruneVersions,
17
18
  restoreVersion
18
19
  } from '../../services/versions.js';
19
20
  import {authenticate, requirePermission} from '../../middleware/auth.js';
@@ -82,6 +83,21 @@ export async function versionsRoutes(fastify) {
82
83
  }
83
84
  });
84
85
 
86
+ // Prune versions — keep the most recent N (policy decided in pruneVersions)
87
+ fastify.post('/versions/prune/*', canDelete, async (request, reply) => {
88
+ const urlPath = '/' + request.params['*'];
89
+ const keep = Number.parseInt(request.body?.keep, 10);
90
+ if (!Number.isFinite(keep) || keep < 0) {
91
+ return reply.status(400).send({error: 'keep must be a non-negative integer'});
92
+ }
93
+ try {
94
+ const result = await pruneVersions(urlPath, {keep});
95
+ return result;
96
+ } catch (err) {
97
+ return reply.status(400).send({error: err.message});
98
+ }
99
+ });
100
+
85
101
  // Bulk delete versions (POST used as DELETE with body is poorly supported)
86
102
  fastify.post('/versions/bulk-delete/*', canDelete, async (request, reply) => {
87
103
  const urlPath = '/' + request.params['*'];
package/server/server.js CHANGED
@@ -22,6 +22,8 @@ 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';
24
24
  import {seedDefaultBlocks} from './services/blocks.js';
25
+ import {seedDefaultComponents} from './services/components.js';
26
+ import {refreshComponentTagAllowlist} from './services/markdown.js';
25
27
 
26
28
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
29
  const ROOT = path.resolve(__dirname, '..');
@@ -167,6 +169,8 @@ await seedUserProfiles();
167
169
  await ensureAllProfiles();
168
170
  await seedPresetCollections();
169
171
  await seedDefaultBlocks();
172
+ await seedDefaultComponents();
173
+ await refreshComponentTagAllowlist();
170
174
 
171
175
  // Serve uploaded media files — nosniff prevents browsers rendering spoofed content types
172
176
  await app.register(staticPlugin, {
@@ -241,6 +245,7 @@ const { formsRoutes } = await import('./routes/api/forms.js');
241
245
  const { viewsRoutes } = await import('./routes/api/views.js');
242
246
  const { actionsRoutes } = await import('./routes/api/actions.js');
243
247
  const {blocksRoutes} = await import('./routes/api/blocks.js');
248
+ const {componentsRoutes} = await import('./routes/api/components.js');
244
249
  const {versionsRoutes} = await import('./routes/api/versions.js');
245
250
  const {effectsRoutes} = await import('./routes/api/effects.js');
246
251
  const {notificationsRoutes} = await import('./routes/api/notifications.js');
@@ -258,6 +263,7 @@ await app.register(formsRoutes, { prefix: '/api' });
258
263
  await app.register(viewsRoutes, { prefix: '/api' });
259
264
  await app.register(actionsRoutes, { prefix: '/api' });
260
265
  await app.register(blocksRoutes, {prefix: '/api'});
266
+ await app.register(componentsRoutes, {prefix: '/api'});
261
267
  await app.register(versionsRoutes, {prefix: '/api'});
262
268
  await app.register(effectsRoutes, {prefix: '/api'});
263
269
  await app.register(notificationsRoutes, {prefix: '/api'});