domma-cms 0.13.7 → 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.
- package/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +2 -2
- package/admin/js/config/sidebar-config.js +1 -1
- package/admin/js/lib/markdown-toolbar.js +24 -18
- package/admin/js/lib/scribe-composer.js +4 -0
- package/admin/js/lib/simple-editor.js +49 -0
- package/admin/js/templates/block-editor.html +76 -18
- package/admin/js/templates/blocks.html +18 -8
- package/admin/js/templates/component-editor.html +141 -0
- package/admin/js/templates/components.html +18 -0
- package/admin/js/views/block-editor-enhance.js +1 -0
- package/admin/js/views/block-editor.js +8 -8
- package/admin/js/views/blocks.js +11 -4
- package/admin/js/views/component-editor.js +28 -0
- package/admin/js/views/components.js +11 -0
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/page-editor.js +6 -6
- package/admin/js/views/pages.js +5 -2
- package/config/site.json +1 -1
- package/package.json +2 -2
- package/public/css/site.css +1 -1
- package/server/routes/api/blocks.js +128 -60
- package/server/routes/api/components.js +115 -0
- package/server/routes/api/pages.js +135 -132
- package/server/routes/api/versions.js +16 -0
- package/server/server.js +6 -0
- package/server/services/blocks.js +387 -284
- package/server/services/components.js +653 -0
- package/server/services/content.js +334 -334
- package/server/services/hooks.js +28 -0
- package/server/services/markdown.js +2836 -2629
- package/server/services/permissionRegistry.js +13 -0
- package/server/services/renderer.js +10 -2
- 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-
|
|
37
|
-
* Fields:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
<
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
<div
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
<div
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
<
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
*
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
*
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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-info — standalone 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}} — {{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
|
+
}
|