domma-cms 0.13.5 → 0.14.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 (40) 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/layouts.js +1 -1
  19. package/admin/js/views/page-editor.js +6 -6
  20. package/admin/js/views/pages.js +5 -2
  21. package/config/navigation.json +5 -0
  22. package/config/plugins.json +5 -5
  23. package/config/presets.json +92 -40
  24. package/config/site.json +75 -8
  25. package/package.json +2 -2
  26. package/public/css/site.css +1 -1
  27. package/server/routes/api/blocks.js +128 -60
  28. package/server/routes/api/components.js +115 -0
  29. package/server/routes/api/layouts.js +24 -0
  30. package/server/routes/api/pages.js +135 -132
  31. package/server/routes/api/versions.js +16 -0
  32. package/server/server.js +6 -0
  33. package/server/services/blocks.js +387 -284
  34. package/server/services/components.js +653 -0
  35. package/server/services/content.js +334 -334
  36. package/server/services/hooks.js +28 -0
  37. package/server/services/markdown.js +2836 -2629
  38. package/server/services/permissionRegistry.js +13 -0
  39. package/server/services/renderer.js +13 -3
  40. package/server/services/versions.js +37 -0
@@ -1,284 +1,387 @@
1
- /**
2
- * Blocks Service
3
- * CRUD operations and seeding for reusable HTML block templates in content/blocks/.
4
- * Never overwrites existing files during seeding — user customisations are preserved.
5
- */
6
- import fs from 'fs/promises';
7
- import path from 'path';
8
- import {fileURLToPath} from 'url';
9
-
10
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
- const BLOCKS_DIR = path.resolve(__dirname, '../../content/blocks');
12
-
13
- /**
14
- * Write a block file only if it does not already exist.
15
- *
16
- * @param {string} name - Block name (without .html extension)
17
- * @param {string} content - HTML template content
18
- */
19
- async function seedBlock(name, content) {
20
- const filePath = path.join(BLOCKS_DIR, `${name}.html`);
21
- try {
22
- await fs.access(filePath);
23
- // File exists — leave it alone
24
- } catch {
25
- await fs.writeFile(filePath, content.trim() + '\n', 'utf8');
26
- }
27
- }
28
-
29
- // ---------------------------------------------------------------------------
30
- // Default block templates
31
- // ---------------------------------------------------------------------------
32
-
33
- const BLOCKS = {
34
-
35
- /**
36
- * contact-card — for the Contacts form
37
- * Fields: full_name, phone_number, email_address
38
- */
39
- 'contact-card': `
40
- <div class="card mb-3">
41
- <div class="card-body">
42
- <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;flex-wrap:wrap;">
43
- <div>
44
- <h4 style="margin:0 0 .5rem;font-size:1.05rem;">{{full_name}}</h4>
45
- <div style="display:flex;flex-direction:column;gap:.3rem;font-size:.875rem;">
46
- <span><strong>Email:</strong> {{email_address}}</span>
47
- <span><strong>Phone:</strong> {{phone_number}}</span>
48
- </div>
49
- </div>
50
- <small class="text-muted" style="white-space:nowrap;">{{_createdAt}}</small>
51
- </div>
52
- </div>
53
- </div>
54
- `,
55
-
56
- /**
57
- * enquiry-card — for the Enquiries form
58
- * Fields: full_name, email, phone, subject, message
59
- */
60
- 'enquiry-card': `
61
- <div class="card mb-3">
62
- <div class="card-header" style="display:flex;justify-content:space-between;align-items:center;gap:.75rem;flex-wrap:wrap;">
63
- <div>
64
- <strong style="font-size:1rem;">{{subject}}</strong>
65
- <span class="text-muted" style="font-size:.8rem;margin-left:.6rem;">from {{full_name}}</span>
66
- </div>
67
- <small class="text-muted">{{_createdAt}}</small>
68
- </div>
69
- <div class="card-body">
70
- <p style="margin:0 0 1rem;line-height:1.65;">{{message}}</p>
71
- <div style="display:flex;gap:1.5rem;font-size:.8rem;color:var(--dm-text-muted,#888);flex-wrap:wrap;">
72
- <span><strong>Email:</strong> {{email}}</span>
73
- <span><strong>Phone:</strong> {{phone}}</span>
74
- </div>
75
- </div>
76
- </div>
77
- `,
78
-
79
- /**
80
- * feedback-card — for the Feedback form
81
- * Fields: name, email, rating, category, subject, message, recommend
82
- */
83
- 'feedback-card': `
84
- <div class="card mb-3">
85
- <div class="card-header" style="display:flex;justify-content:space-between;align-items:center;gap:.75rem;flex-wrap:wrap;">
86
- <div style="display:flex;align-items:center;gap:.65rem;flex-wrap:wrap;">
87
- <strong>{{subject}}</strong>
88
- <span class="badge badge-secondary" style="text-transform:capitalize;">{{category}}</span>
89
- <span class="badge badge-info" style="text-transform:capitalize;">{{rating}}</span>
90
- </div>
91
- <small class="text-muted">{{_createdAt}}</small>
92
- </div>
93
- <div class="card-body">
94
- <p style="margin:0 0 1rem;line-height:1.65;">{{message}}</p>
95
- <div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:.5rem;font-size:.8rem;color:var(--dm-text-muted,#888);">
96
- <span>{{name}} &mdash; {{email}}</span>
97
- <span>Would recommend: <strong>{{recommend}}</strong></span>
98
- </div>
99
- </div>
100
- </div>
101
- `,
102
-
103
- /**
104
- * note-card — for the Notes form
105
- * Fields: title, content, category, tags
106
- */
107
- 'note-card': `
108
- <div class="card mb-3">
109
- <div class="card-header" style="display:flex;justify-content:space-between;align-items:center;gap:.75rem;flex-wrap:wrap;">
110
- <div style="display:flex;align-items:center;gap:.6rem;flex-wrap:wrap;">
111
- <strong style="font-size:1rem;">{{title}}</strong>
112
- <span class="badge badge-secondary" style="text-transform:capitalize;">{{category}}</span>
113
- </div>
114
- <small class="text-muted">{{_createdAt}}</small>
115
- </div>
116
- <div class="card-body">
117
- <p style="margin:0 0 .75rem;line-height:1.7;white-space:pre-line;">{{content}}</p>
118
- <small class="text-muted">Tags: {{tags}}</small>
119
- </div>
120
- </div>
121
- `,
122
-
123
- /**
124
- * todo-item — for the To-Do form
125
- * Fields: title, description, status, priority, due_date, assigned_to
126
- */
127
- 'todo-item': `
128
- <div class="card mb-2">
129
- <div class="card-body" style="display:flex;align-items:flex-start;gap:1rem;flex-wrap:wrap;">
130
- <div style="flex:1;min-width:0;">
131
- <div style="display:flex;align-items:center;gap:.6rem;flex-wrap:wrap;margin-bottom:.4rem;">
132
- <strong style="font-size:.975rem;">{{title}}</strong>
133
- <span class="badge badge-warning" style="text-transform:capitalize;">{{priority}}</span>
134
- <span class="badge badge-success" style="text-transform:capitalize;">{{status}}</span>
135
- </div>
136
- <p style="margin:0 0 .5rem;font-size:.875rem;line-height:1.55;color:var(--dm-text-muted,#888);">{{description}}</p>
137
- <div style="display:flex;gap:1.5rem;font-size:.8rem;color:var(--dm-text-muted,#888);flex-wrap:wrap;">
138
- <span><strong>Due:</strong> {{due_date}}</span>
139
- <span><strong>Assigned to:</strong> {{assigned_to}}</span>
140
- </div>
141
- </div>
142
- </div>
143
- </div>
144
- `,
145
-
146
- };
147
-
148
- // ---------------------------------------------------------------------------
149
- // Validation
150
- // ---------------------------------------------------------------------------
151
-
152
- /** Block names must be lowercase alphanumeric + hyphens, no path traversal. */
153
- const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
154
-
155
- function assertValidName(name) {
156
- if (!NAME_RE.test(name)) {
157
- const err = new Error('Invalid block name. Use lowercase letters, digits, and hyphens only.');
158
- err.code = 'INVALID_NAME';
159
- throw err;
160
- }
161
- }
162
-
163
- function blockFilePath(name) {
164
- return path.join(BLOCKS_DIR, `${name}.html`);
165
- }
166
-
167
- // ---------------------------------------------------------------------------
168
- // CRUD service functions
169
- // ---------------------------------------------------------------------------
170
-
171
- /**
172
- * List all blocks in the blocks directory.
173
- *
174
- * @returns {Promise<Array<{name: string, size: number, updatedAt: string|null}>>}
175
- */
176
- export async function listBlocks() {
177
- await fs.mkdir(BLOCKS_DIR, {recursive: true});
178
- const files = await fs.readdir(BLOCKS_DIR);
179
- const blocks = [];
180
- for (const file of files.filter(f => f.endsWith('.html'))) {
181
- const name = file.slice(0, -5);
182
- const fileStat = await fs.stat(path.join(BLOCKS_DIR, file)).catch(() => null);
183
- let bundled = false;
184
- try {
185
- const meta = JSON.parse(await fs.readFile(path.join(BLOCKS_DIR, `${name}.meta.json`), 'utf8'));
186
- bundled = !!meta.bundled;
187
- } catch { /* no meta file */
188
- }
189
- blocks.push({
190
- name,
191
- size: fileStat?.size ?? 0,
192
- updatedAt: fileStat?.mtime?.toISOString() ?? null,
193
- bundled,
194
- });
195
- }
196
- return blocks.sort((a, b) => a.name.localeCompare(b.name));
197
- }
198
-
199
- /**
200
- * Read a single block's content by name.
201
- *
202
- * @param {string} name - Block name (without .html extension)
203
- * @returns {Promise<{name: string, content: string}>}
204
- * @throws {Error} With code INVALID_NAME or ENOENT when not found
205
- */
206
- export async function getBlock(name) {
207
- assertValidName(name);
208
- try {
209
- const content = await fs.readFile(blockFilePath(name), 'utf8');
210
- let bundled = false;
211
- try {
212
- const meta = JSON.parse(await fs.readFile(path.join(BLOCKS_DIR, `${name}.meta.json`), 'utf8'));
213
- bundled = !!meta.bundled;
214
- } catch { /* no meta file */
215
- }
216
- return {name, content, bundled};
217
- } catch (err) {
218
- if (err.code === 'ENOENT') {
219
- const notFound = new Error('Block not found');
220
- notFound.code = 'ENOENT';
221
- throw notFound;
222
- }
223
- throw err;
224
- }
225
- }
226
-
227
- /**
228
- * Create or update a block file (upsert).
229
- *
230
- * @param {string} name - Block name (without .html extension)
231
- * @param {string} content - HTML template content
232
- * @returns {Promise<{success: boolean, name: string}>}
233
- * @throws {Error} With code INVALID_NAME on bad name
234
- */
235
- export async function saveBlock(name, content, {bundled} = {}) {
236
- assertValidName(name);
237
- await fs.mkdir(BLOCKS_DIR, {recursive: true});
238
- await fs.writeFile(blockFilePath(name), content, 'utf8');
239
- const metaPath = path.join(BLOCKS_DIR, `${name}.meta.json`);
240
- if (bundled) {
241
- await fs.writeFile(metaPath, JSON.stringify({bundled: true}, null, 2) + '\n', 'utf8');
242
- } else {
243
- await fs.unlink(metaPath).catch(() => {
244
- });
245
- }
246
- return {success: true, name};
247
- }
248
-
249
- /**
250
- * Delete a block file.
251
- *
252
- * @param {string} name - Block name (without .html extension)
253
- * @returns {Promise<void>}
254
- * @throws {Error} With code INVALID_NAME or ENOENT when not found
255
- */
256
- export async function deleteBlock(name) {
257
- assertValidName(name);
258
- try {
259
- await fs.unlink(blockFilePath(name));
260
- } catch (err) {
261
- if (err.code === 'ENOENT') {
262
- const notFound = new Error('Block not found');
263
- notFound.code = 'ENOENT';
264
- throw notFound;
265
- }
266
- throw err;
267
- }
268
- }
269
-
270
- // ---------------------------------------------------------------------------
271
- // Public API
272
- // ---------------------------------------------------------------------------
273
-
274
- /**
275
- * Seed all default block templates into content/blocks/.
276
- * Skips any file that already exists to preserve user customisations.
277
- */
278
- export async function seedDefaultBlocks() {
279
- for (const [name, content] of Object.entries(BLOCKS)) {
280
- await seedBlock(name, content);
281
- }
282
- const names = Object.keys(BLOCKS).join(', ');
283
- console.log(`[blocks] Seeded default blocks: ${names}`);
284
- }
1
+ /**
2
+ * Blocks Service
3
+ * CRUD operations and seeding for reusable HTML block templates in content/blocks/.
4
+ * Never overwrites existing files during seeding — user customisations are preserved.
5
+ */
6
+ import fs from 'fs/promises';
7
+ import path from 'path';
8
+ import {fileURLToPath} from 'url';
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ const BLOCKS_DIR = path.resolve(__dirname, '../../content/blocks');
12
+
13
+ /**
14
+ * Write a block file only if it does not already exist.
15
+ *
16
+ * @param {string} name - Block name (without .html extension)
17
+ * @param {string} content - HTML template content
18
+ */
19
+ async function seedBlock(name, content) {
20
+ const filePath = path.join(BLOCKS_DIR, `${name}.html`);
21
+ try {
22
+ await fs.access(filePath);
23
+ // File exists — leave it alone
24
+ } catch {
25
+ await fs.writeFile(filePath, content.trim() + '\n', 'utf8');
26
+ }
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Default block templates
31
+ // ---------------------------------------------------------------------------
32
+
33
+ const BLOCKS = {
34
+
35
+ /**
36
+ * contact-infostandalone contact details panel for landing pages.
37
+ * Fields: heading, intro, phone, phoneHref, email, address, hours,
38
+ * facebook, facebookLabel, instagram, instagramLabel,
39
+ * website, websiteLabel, footnote
40
+ */
41
+ 'contact-info': `
42
+ <div class="contact-info-card">
43
+ <h3 class="contact-info-heading">{{heading}}</h3>
44
+ <p class="contact-info-intro">{{intro}}</p>
45
+ <ul class="contact-info-list">
46
+ <li class="contact-info-row" data-field="phone">
47
+ <span class="contact-info-icon" data-icon="phone" aria-hidden="true"></span>
48
+ <div class="contact-info-body">
49
+ <span class="contact-info-label">Phone</span>
50
+ <a class="contact-info-value" href="tel:{{phoneHref}}">{{phone}}</a>
51
+ </div>
52
+ </li>
53
+ <li class="contact-info-row" data-field="email">
54
+ <span class="contact-info-icon" data-icon="mail" aria-hidden="true"></span>
55
+ <div class="contact-info-body">
56
+ <span class="contact-info-label">Email</span>
57
+ <a class="contact-info-value" href="mailto:{{email}}">{{email}}</a>
58
+ </div>
59
+ </li>
60
+ <li class="contact-info-row" data-field="address">
61
+ <span class="contact-info-icon" data-icon="map-pin" aria-hidden="true"></span>
62
+ <div class="contact-info-body">
63
+ <span class="contact-info-label">Based in</span>
64
+ <span class="contact-info-value">{{address}}</span>
65
+ </div>
66
+ </li>
67
+ <li class="contact-info-row" data-field="hours">
68
+ <span class="contact-info-icon" data-icon="clock" aria-hidden="true"></span>
69
+ <div class="contact-info-body">
70
+ <span class="contact-info-label">Hours</span>
71
+ <span class="contact-info-value">{{hours}}</span>
72
+ </div>
73
+ </li>
74
+ <li class="contact-info-row" data-field="facebook">
75
+ <span class="contact-info-icon" data-icon="facebook" aria-hidden="true"></span>
76
+ <div class="contact-info-body">
77
+ <span class="contact-info-label">Facebook</span>
78
+ <a class="contact-info-value" href="{{facebook}}" target="_blank" rel="noopener noreferrer">{{facebookLabel}}</a>
79
+ </div>
80
+ </li>
81
+ <li class="contact-info-row" data-field="instagram">
82
+ <span class="contact-info-icon" data-icon="instagram" aria-hidden="true"></span>
83
+ <div class="contact-info-body">
84
+ <span class="contact-info-label">Instagram</span>
85
+ <a class="contact-info-value" href="{{instagram}}" target="_blank" rel="noopener noreferrer">{{instagramLabel}}</a>
86
+ </div>
87
+ </li>
88
+ <li class="contact-info-row" data-field="website">
89
+ <span class="contact-info-icon" data-icon="globe" aria-hidden="true"></span>
90
+ <div class="contact-info-body">
91
+ <span class="contact-info-label">Website</span>
92
+ <a class="contact-info-value" href="{{website}}" target="_blank" rel="noopener noreferrer">{{websiteLabel}}</a>
93
+ </div>
94
+ </li>
95
+ </ul>
96
+ <p class="contact-info-footnote">{{footnote}}</p>
97
+ </div>
98
+ `,
99
+
100
+ /**
101
+ * contact-card — for the Contacts form
102
+ * Fields: full_name, phone_number, email_address
103
+ */
104
+ 'contact-card': `
105
+ <div class="card mb-3">
106
+ <div class="card-body">
107
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;flex-wrap:wrap;">
108
+ <div>
109
+ <h4 style="margin:0 0 .5rem;font-size:1.05rem;">{{full_name}}</h4>
110
+ <div style="display:flex;flex-direction:column;gap:.3rem;font-size:.875rem;">
111
+ <span><strong>Email:</strong> {{email_address}}</span>
112
+ <span><strong>Phone:</strong> {{phone_number}}</span>
113
+ </div>
114
+ </div>
115
+ <small class="text-muted" style="white-space:nowrap;">{{_createdAt}}</small>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ `,
120
+
121
+ /**
122
+ * enquiry-card — for the Enquiries form
123
+ * Fields: full_name, email, phone, subject, message
124
+ */
125
+ 'enquiry-card': `
126
+ <div class="card mb-3">
127
+ <div class="card-header" style="display:flex;justify-content:space-between;align-items:center;gap:.75rem;flex-wrap:wrap;">
128
+ <div>
129
+ <strong style="font-size:1rem;">{{subject}}</strong>
130
+ <span class="text-muted" style="font-size:.8rem;margin-left:.6rem;">from {{full_name}}</span>
131
+ </div>
132
+ <small class="text-muted">{{_createdAt}}</small>
133
+ </div>
134
+ <div class="card-body">
135
+ <p style="margin:0 0 1rem;line-height:1.65;">{{message}}</p>
136
+ <div style="display:flex;gap:1.5rem;font-size:.8rem;color:var(--dm-text-muted,#888);flex-wrap:wrap;">
137
+ <span><strong>Email:</strong> {{email}}</span>
138
+ <span><strong>Phone:</strong> {{phone}}</span>
139
+ </div>
140
+ </div>
141
+ </div>
142
+ `,
143
+
144
+ /**
145
+ * feedback-card — for the Feedback form
146
+ * Fields: name, email, rating, category, subject, message, recommend
147
+ */
148
+ 'feedback-card': `
149
+ <div class="card mb-3">
150
+ <div class="card-header" style="display:flex;justify-content:space-between;align-items:center;gap:.75rem;flex-wrap:wrap;">
151
+ <div style="display:flex;align-items:center;gap:.65rem;flex-wrap:wrap;">
152
+ <strong>{{subject}}</strong>
153
+ <span class="badge badge-secondary" style="text-transform:capitalize;">{{category}}</span>
154
+ <span class="badge badge-info" style="text-transform:capitalize;">{{rating}}</span>
155
+ </div>
156
+ <small class="text-muted">{{_createdAt}}</small>
157
+ </div>
158
+ <div class="card-body">
159
+ <p style="margin:0 0 1rem;line-height:1.65;">{{message}}</p>
160
+ <div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:.5rem;font-size:.8rem;color:var(--dm-text-muted,#888);">
161
+ <span>{{name}} &mdash; {{email}}</span>
162
+ <span>Would recommend: <strong>{{recommend}}</strong></span>
163
+ </div>
164
+ </div>
165
+ </div>
166
+ `,
167
+
168
+ /**
169
+ * note-card — for the Notes form
170
+ * Fields: title, content, category, tags
171
+ */
172
+ 'note-card': `
173
+ <div class="card mb-3">
174
+ <div class="card-header" style="display:flex;justify-content:space-between;align-items:center;gap:.75rem;flex-wrap:wrap;">
175
+ <div style="display:flex;align-items:center;gap:.6rem;flex-wrap:wrap;">
176
+ <strong style="font-size:1rem;">{{title}}</strong>
177
+ <span class="badge badge-secondary" style="text-transform:capitalize;">{{category}}</span>
178
+ </div>
179
+ <small class="text-muted">{{_createdAt}}</small>
180
+ </div>
181
+ <div class="card-body">
182
+ <p style="margin:0 0 .75rem;line-height:1.7;white-space:pre-line;">{{content}}</p>
183
+ <small class="text-muted">Tags: {{tags}}</small>
184
+ </div>
185
+ </div>
186
+ `,
187
+
188
+ /**
189
+ * todo-item — for the To-Do form
190
+ * Fields: title, description, status, priority, due_date, assigned_to
191
+ */
192
+ 'todo-item': `
193
+ <div class="card mb-2">
194
+ <div class="card-body" style="display:flex;align-items:flex-start;gap:1rem;flex-wrap:wrap;">
195
+ <div style="flex:1;min-width:0;">
196
+ <div style="display:flex;align-items:center;gap:.6rem;flex-wrap:wrap;margin-bottom:.4rem;">
197
+ <strong style="font-size:.975rem;">{{title}}</strong>
198
+ <span class="badge badge-warning" style="text-transform:capitalize;">{{priority}}</span>
199
+ <span class="badge badge-success" style="text-transform:capitalize;">{{status}}</span>
200
+ </div>
201
+ <p style="margin:0 0 .5rem;font-size:.875rem;line-height:1.55;color:var(--dm-text-muted,#888);">{{description}}</p>
202
+ <div style="display:flex;gap:1.5rem;font-size:.8rem;color:var(--dm-text-muted,#888);flex-wrap:wrap;">
203
+ <span><strong>Due:</strong> {{due_date}}</span>
204
+ <span><strong>Assigned to:</strong> {{assigned_to}}</span>
205
+ </div>
206
+ </div>
207
+ </div>
208
+ </div>
209
+ `,
210
+
211
+ };
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Validation
215
+ // ---------------------------------------------------------------------------
216
+
217
+ /** Block names must be lowercase alphanumeric + hyphens, no path traversal. */
218
+ const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
219
+
220
+ function assertValidName(name) {
221
+ if (!NAME_RE.test(name)) {
222
+ const err = new Error('Invalid block name. Use lowercase letters, digits, and hyphens only.');
223
+ err.code = 'INVALID_NAME';
224
+ throw err;
225
+ }
226
+ }
227
+
228
+ function blockFilePath(name) {
229
+ return path.join(BLOCKS_DIR, `${name}.html`);
230
+ }
231
+
232
+ function blockCssPath(name) {
233
+ return path.join(BLOCKS_DIR, `${name}.css`);
234
+ }
235
+
236
+ function blockMetaPath(name) {
237
+ return path.join(BLOCKS_DIR, `${name}.meta.json`);
238
+ }
239
+
240
+ /** Per-block CSS size cap — matches the 100 KB site-wide cap, scaled down. */
241
+ const MAX_CSS_SIZE = 50 * 1024;
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // CRUD service functions
245
+ // ---------------------------------------------------------------------------
246
+
247
+ /**
248
+ * List all blocks in the blocks directory.
249
+ *
250
+ * @returns {Promise<Array<{name: string, size: number, updatedAt: string|null}>>}
251
+ */
252
+ export async function listBlocks() {
253
+ await fs.mkdir(BLOCKS_DIR, {recursive: true});
254
+ const files = await fs.readdir(BLOCKS_DIR);
255
+ const blocks = [];
256
+ for (const file of files.filter(f => f.endsWith('.html'))) {
257
+ const name = file.slice(0, -5);
258
+ const fileStat = await fs.stat(path.join(BLOCKS_DIR, file)).catch(() => null);
259
+ let bundled = false;
260
+ try {
261
+ const meta = JSON.parse(await fs.readFile(path.join(BLOCKS_DIR, `${name}.meta.json`), 'utf8'));
262
+ bundled = !!meta.bundled;
263
+ } catch { /* no meta file */
264
+ }
265
+ blocks.push({
266
+ name,
267
+ size: fileStat?.size ?? 0,
268
+ updatedAt: fileStat?.mtime?.toISOString() ?? null,
269
+ bundled,
270
+ });
271
+ }
272
+ return blocks.sort((a, b) => a.name.localeCompare(b.name));
273
+ }
274
+
275
+ /**
276
+ * Read a single block's content by name.
277
+ *
278
+ * @param {string} name - Block name (without .html extension)
279
+ * @returns {Promise<{name: string, content: string, css: string, bundled: boolean}>}
280
+ * @throws {Error} With code INVALID_NAME or ENOENT when not found
281
+ */
282
+ export async function getBlock(name) {
283
+ assertValidName(name);
284
+ try {
285
+ const content = await fs.readFile(blockFilePath(name), 'utf8');
286
+ let bundled = false;
287
+ try {
288
+ const meta = JSON.parse(await fs.readFile(blockMetaPath(name), 'utf8'));
289
+ bundled = !!meta.bundled;
290
+ } catch { /* no meta file */
291
+ }
292
+ let css = '';
293
+ try {
294
+ css = await fs.readFile(blockCssPath(name), 'utf8');
295
+ } catch { /* no CSS file — treat as empty */
296
+ }
297
+ return {name, content, css, bundled};
298
+ } catch (err) {
299
+ if (err.code === 'ENOENT') {
300
+ const notFound = new Error('Block not found');
301
+ notFound.code = 'ENOENT';
302
+ throw notFound;
303
+ }
304
+ throw err;
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Create or update a block file (upsert).
310
+ *
311
+ * @param {string} name - Block name (without .html extension)
312
+ * @param {string} content - HTML template content
313
+ * @param {object} [opts]
314
+ * @param {boolean} [opts.bundled] - Include in fresh-install seed list
315
+ * @param {string} [opts.css] - Companion CSS body; empty string removes the file
316
+ * @returns {Promise<{success: boolean, name: string}>}
317
+ * @throws {Error} With code INVALID_NAME on bad name, or CSS_TOO_LARGE when CSS exceeds cap
318
+ */
319
+ export async function saveBlock(name, content, {bundled, css} = {}) {
320
+ assertValidName(name);
321
+ if (typeof css === 'string' && css.length > MAX_CSS_SIZE) {
322
+ const err = new Error(`CSS exceeds ${MAX_CSS_SIZE} byte limit`);
323
+ err.code = 'CSS_TOO_LARGE';
324
+ throw err;
325
+ }
326
+ await fs.mkdir(BLOCKS_DIR, {recursive: true});
327
+ await fs.writeFile(blockFilePath(name), content, 'utf8');
328
+ const metaPath = blockMetaPath(name);
329
+ if (bundled) {
330
+ await fs.writeFile(metaPath, JSON.stringify({bundled: true}, null, 2) + '\n', 'utf8');
331
+ } else {
332
+ await fs.unlink(metaPath).catch(() => {
333
+ });
334
+ }
335
+ // CSS is optional — only touch the file when the caller passed the field explicitly.
336
+ if (typeof css === 'string') {
337
+ if (css.trim().length > 0) {
338
+ await fs.writeFile(blockCssPath(name), css, 'utf8');
339
+ } else {
340
+ await fs.unlink(blockCssPath(name)).catch(() => {
341
+ });
342
+ }
343
+ }
344
+ return {success: true, name};
345
+ }
346
+
347
+ /**
348
+ * Delete a block and its companion files (.css, .meta.json).
349
+ * Missing companions are ignored; only the primary .html file triggers ENOENT.
350
+ *
351
+ * @param {string} name - Block name (without .html extension)
352
+ * @returns {Promise<void>}
353
+ * @throws {Error} With code INVALID_NAME or ENOENT when the block is not found
354
+ */
355
+ export async function deleteBlock(name) {
356
+ assertValidName(name);
357
+ try {
358
+ await fs.unlink(blockFilePath(name));
359
+ } catch (err) {
360
+ if (err.code === 'ENOENT') {
361
+ const notFound = new Error('Block not found');
362
+ notFound.code = 'ENOENT';
363
+ throw notFound;
364
+ }
365
+ throw err;
366
+ }
367
+ await fs.unlink(blockCssPath(name)).catch(() => {
368
+ });
369
+ await fs.unlink(blockMetaPath(name)).catch(() => {
370
+ });
371
+ }
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // Public API
375
+ // ---------------------------------------------------------------------------
376
+
377
+ /**
378
+ * Seed all default block templates into content/blocks/.
379
+ * Skips any file that already exists to preserve user customisations.
380
+ */
381
+ export async function seedDefaultBlocks() {
382
+ for (const [name, content] of Object.entries(BLOCKS)) {
383
+ await seedBlock(name, content);
384
+ }
385
+ const names = Object.keys(BLOCKS).join(', ');
386
+ console.log(`[blocks] Seeded default blocks: ${names}`);
387
+ }