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
@@ -0,0 +1,653 @@
1
+ /**
2
+ * Components Service
3
+ * CRUD, compile pipeline, and seeding for .dmc component files
4
+ * stored in content/components/.
5
+ */
6
+ import fs from 'fs/promises';
7
+ import path from 'path';
8
+ import {fileURLToPath} from 'url';
9
+ import {getPluginComponents} from './hooks.js';
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const COMPONENTS_DIR = path.resolve(__dirname, '../../content/components');
13
+
14
+ /** Component names: lowercase alphanumeric + hyphens. Drives the Custom Element tag `dm-<name>`. */
15
+ const NAME_RE = /^[a-z][a-z0-9-]*$/;
16
+
17
+ function assertValidName(name) {
18
+ if (!NAME_RE.test(name)) {
19
+ const err = new Error('Invalid component name. Use a letter followed by lowercase letters, digits, and hyphens.');
20
+ err.code = 'INVALID_NAME';
21
+ throw err;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Absolute filesystem path to a component's .dmc source file.
27
+ *
28
+ * @param {string} name - Component name (without extension).
29
+ * @returns {string}
30
+ */
31
+ function componentFilePath(name) { return path.join(COMPONENTS_DIR, `${name}.dmc`); }
32
+
33
+ /**
34
+ * Absolute filesystem path to a component's companion .meta.json file.
35
+ *
36
+ * @param {string} name - Component name (without extension).
37
+ * @returns {string}
38
+ */
39
+ function componentMetaPath(name) { return path.join(COMPONENTS_DIR, `${name}.meta.json`); }
40
+
41
+ /**
42
+ * Split a .dmc source string into its four SFC blocks.
43
+ * Missing <template>, <props>, or <script> is a compile error;
44
+ * <style> is optional (returns '').
45
+ *
46
+ * @param {string} source
47
+ * @returns {{template: string, props: string, script: string, style: string}}
48
+ * @throws {Error} With code MISSING_BLOCK when a required block is absent.
49
+ */
50
+ export function parseDmcSource(source) {
51
+ const extract = (tag) => {
52
+ const re = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`);
53
+ const m = source.match(re);
54
+ return m ? m[1].trim() : null;
55
+ };
56
+ const template = extract('template');
57
+ const props = extract('props');
58
+ const script = extract('script');
59
+ const style = extract('style') ?? '';
60
+
61
+ for (const [name, value] of [['template', template], ['props', props], ['script', script]]) {
62
+ if (value === null) {
63
+ const err = new Error(`MISSING_BLOCK: required <${name}> block is absent.`);
64
+ err.code = 'MISSING_BLOCK';
65
+ err.block = name;
66
+ throw err;
67
+ }
68
+ }
69
+ return {template, props, script, style};
70
+ }
71
+
72
+ const VALID_PROP_TYPES = new Set(['string', 'number', 'boolean', 'array', 'object']);
73
+
74
+ /**
75
+ * Parse and validate a <props> block JSON body.
76
+ *
77
+ * @param {string} json
78
+ * @returns {object} Parsed props schema.
79
+ * @throws {Error} With code INVALID_PROPS_JSON on any problem.
80
+ */
81
+ export function validatePropsJson(json) {
82
+ let parsed;
83
+ try { parsed = JSON.parse(json); }
84
+ catch (e) {
85
+ const err = new Error(`INVALID_PROPS_JSON: <props> is not valid JSON: ${e.message}`);
86
+ err.code = 'INVALID_PROPS_JSON';
87
+ throw err;
88
+ }
89
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
90
+ const err = new Error('INVALID_PROPS_JSON: <props> must be a JSON object');
91
+ err.code = 'INVALID_PROPS_JSON';
92
+ throw err;
93
+ }
94
+ for (const [propName, def] of Object.entries(parsed)) {
95
+ if (!def || typeof def !== 'object' || !VALID_PROP_TYPES.has(def.type)) {
96
+ const err = new Error(`INVALID_PROPS_JSON: <props>.${propName} must declare a valid type (one of: ${[...VALID_PROP_TYPES].join(', ')})`);
97
+ err.code = 'INVALID_PROPS_JSON';
98
+ throw err;
99
+ }
100
+ }
101
+ return parsed;
102
+ }
103
+
104
+ /**
105
+ * Transform a <script> body for embedding.
106
+ * Removes the `export default` prefix and any trailing semicolon,
107
+ * leaving only the object expression.
108
+ *
109
+ * @param {string} body - Raw <script> body.
110
+ * @returns {string} The bare object expression.
111
+ * @throws {Error} With code SCRIPT_MISSING_EXPORT when no export default is found.
112
+ */
113
+ export function stripExportDefault(body) {
114
+ const m = body.match(/^\s*export\s+default\s+([\s\S]+?)\s*;?\s*$/);
115
+ if (!m) {
116
+ const err = new Error('SCRIPT_MISSING_EXPORT: <script> must have an `export default` statement.');
117
+ err.code = 'SCRIPT_MISSING_EXPORT';
118
+ throw err;
119
+ }
120
+ return m[1];
121
+ }
122
+
123
+ /**
124
+ * Escape a string for use inside a JS template literal.
125
+ * Handles backticks, backslashes, and `${` sequences.
126
+ *
127
+ * @param {string} str
128
+ * @returns {string}
129
+ */
130
+ function toBacktickLiteral(str) {
131
+ return '`' + String(str).replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${') + '`';
132
+ }
133
+
134
+ /**
135
+ * Compile a .dmc source into a self-contained JS module that registers
136
+ * the component on load via Domma.component().
137
+ *
138
+ * @param {string} source - Full .dmc file contents.
139
+ * @param {string} name - Component name (controls the Custom Element tag).
140
+ * @returns {{js: string|null, errors: Array<{type: string, message: string, block?: string}>}}
141
+ */
142
+ export function compileComponent(source, name) {
143
+ try {
144
+ assertValidName(name);
145
+ } catch (e) {
146
+ return {js: null, errors: [{type: e.code, message: e.message}]};
147
+ }
148
+
149
+ let parts;
150
+ try { parts = parseDmcSource(source); }
151
+ catch (e) { return {js: null, errors: [{type: e.code, message: e.message, block: e.block}]}; }
152
+
153
+ let propsObj;
154
+ try { propsObj = validatePropsJson(parts.props); }
155
+ catch (e) { return {js: null, errors: [{type: e.code, message: e.message}]}; }
156
+
157
+ let definitionExpr;
158
+ try { definitionExpr = stripExportDefault(parts.script); }
159
+ catch (e) { return {js: null, errors: [{type: e.code, message: e.message}]}; }
160
+
161
+ const js = [
162
+ `// Compiled from ${name}.dmc — do not edit by hand.`,
163
+ `const template = ${toBacktickLiteral(parts.template)};`,
164
+ `const style = ${toBacktickLiteral(parts.style)};`,
165
+ `const props = ${JSON.stringify(propsObj)};`,
166
+ `const definition = ${definitionExpr};`,
167
+ `Domma.component('dm-${name}', { template, style, props, ...definition });`
168
+ ].join('\n');
169
+
170
+ return {js, errors: []};
171
+ }
172
+
173
+ // ─── In-memory compile cache ──────────────────────────────────────────────
174
+
175
+ const _compileCache = new Map(); // name → {js, errors, mtime}
176
+
177
+ /**
178
+ * Drop a cached compiled output for `name`.
179
+ * Called on component save/delete to force recompilation on next request.
180
+ *
181
+ * @param {string} name
182
+ */
183
+ export function invalidateCache(name) { _compileCache.delete(name); }
184
+
185
+ /**
186
+ * Return the compiled JS for `name`, reading and compiling the source file
187
+ * lazily if the cache entry is missing or stale (mtime changed).
188
+ *
189
+ * @param {string} name
190
+ * @returns {Promise<{js: string|null, errors: Array, mtime: number}>}
191
+ * @throws {Error} With code INVALID_NAME on a bad name, or ENOENT when the
192
+ * source file does not exist.
193
+ */
194
+ export async function getCompiledJs(name) {
195
+ assertValidName(name);
196
+
197
+ // Plugin-contributed components come from memory, never disk.
198
+ const plugin = getPluginComponents().find(pc => pc.name === name);
199
+ if (plugin) {
200
+ const cached = _compileCache.get(name);
201
+ if (cached && cached.origin === 'plugin' && cached.sourceLength === plugin.source.length) return cached;
202
+ const {js, errors} = compileComponent(plugin.source, name);
203
+ const entry = {js, errors, mtime: 0, origin: 'plugin', sourceLength: plugin.source.length};
204
+ _compileCache.set(name, entry);
205
+ return entry;
206
+ }
207
+
208
+ const fp = componentFilePath(name);
209
+ const stat = await fs.stat(fp);
210
+ const cached = _compileCache.get(name);
211
+ if (cached && cached.origin === 'file' && cached.mtime === stat.mtimeMs) return cached;
212
+ const source = await fs.readFile(fp, 'utf8');
213
+ const {js, errors} = compileComponent(source, name);
214
+ const entry = {js, errors, mtime: stat.mtimeMs, origin: 'file'};
215
+ _compileCache.set(name, entry);
216
+ return entry;
217
+ }
218
+
219
+ // ─── CRUD operations ──────────────────────────────────────────────────────
220
+
221
+ /**
222
+ * List all components. Lightweight metadata — does NOT compile.
223
+ * The `props` field is parsed from each file's <props> block so the admin
224
+ * list view can show props without a follow-up fetch.
225
+ *
226
+ * @returns {Promise<Array<{name: string, size: number, updatedAt: string|null, bundled: boolean, props: object}>>}
227
+ */
228
+ export async function listComponents() {
229
+ await fs.mkdir(COMPONENTS_DIR, {recursive: true});
230
+ const files = await fs.readdir(COMPONENTS_DIR);
231
+ const entries = [];
232
+ for (const file of files.filter(f => f.endsWith('.dmc'))) {
233
+ const name = file.slice(0, -4);
234
+ const stat = await fs.stat(path.join(COMPONENTS_DIR, file)).catch(() => null);
235
+ let bundled = false;
236
+ try {
237
+ const meta = JSON.parse(await fs.readFile(componentMetaPath(name), 'utf8'));
238
+ bundled = !!meta.bundled;
239
+ } catch { /* no meta */ }
240
+ let props = {};
241
+ try {
242
+ const src = await fs.readFile(path.join(COMPONENTS_DIR, file), 'utf8');
243
+ const {props: propsBlock} = parseDmcSource(src);
244
+ props = validatePropsJson(propsBlock);
245
+ } catch { /* malformed — list still usable */ }
246
+ entries.push({
247
+ name,
248
+ size: stat?.size ?? 0,
249
+ updatedAt: stat?.mtime?.toISOString() ?? null,
250
+ bundled,
251
+ props,
252
+ origin: 'file',
253
+ });
254
+ }
255
+
256
+ // Merge plugin-contributed components (read-only, no disk file).
257
+ for (const pc of getPluginComponents()) {
258
+ let props = {};
259
+ try {
260
+ const parts = parseDmcSource(pc.source);
261
+ props = validatePropsJson(parts.props);
262
+ } catch { /* malformed plugin source — list still usable */ }
263
+ entries.push({
264
+ name: pc.name,
265
+ size: pc.source.length,
266
+ updatedAt: null,
267
+ bundled: true,
268
+ props,
269
+ origin: 'plugin',
270
+ });
271
+ }
272
+
273
+ return entries.sort((a, b) => a.name.localeCompare(b.name));
274
+ }
275
+
276
+ /**
277
+ * Read a component's raw .dmc source plus its parsed props.
278
+ *
279
+ * @param {string} name
280
+ * @returns {Promise<{name: string, source: string, props: object, bundled: boolean}>}
281
+ * @throws {Error} With code INVALID_NAME or ENOENT.
282
+ */
283
+ export async function getComponent(name) {
284
+ assertValidName(name);
285
+ try {
286
+ const source = await fs.readFile(componentFilePath(name), 'utf8');
287
+ let bundled = false;
288
+ try {
289
+ const meta = JSON.parse(await fs.readFile(componentMetaPath(name), 'utf8'));
290
+ bundled = !!meta.bundled;
291
+ } catch { /* no meta */ }
292
+ let props = {};
293
+ try {
294
+ const parts = parseDmcSource(source);
295
+ props = validatePropsJson(parts.props);
296
+ } catch { /* return malformed source for editing; UI flags it */ }
297
+ return {name, source, props, bundled};
298
+ } catch (err) {
299
+ if (err.code === 'ENOENT') {
300
+ const nf = new Error('Component not found');
301
+ nf.code = 'ENOENT';
302
+ throw nf;
303
+ }
304
+ throw err;
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Create or update a component. Compiles BEFORE writing — a malformed source
310
+ * never reaches disk. Returns the compile errors (if any) rather than throwing.
311
+ *
312
+ * @param {string} name
313
+ * @param {string} source
314
+ * @param {object} [opts]
315
+ * @param {boolean} [opts.bundled] - Mark as bundled via a .meta.json companion.
316
+ * @returns {Promise<{success: boolean, name: string, errors: Array}>}
317
+ */
318
+ export async function saveComponent(name, source, {bundled = false} = {}) {
319
+ assertValidName(name);
320
+ if (getPluginComponents().some(pc => pc.name === name)) {
321
+ const e = new Error('That name is registered by a plugin and cannot be edited from the admin.');
322
+ e.code = 'PLUGIN_OWNED';
323
+ throw e;
324
+ }
325
+ const {errors} = compileComponent(source, name);
326
+ if (errors.length) return {success: false, name, errors};
327
+
328
+ await fs.mkdir(COMPONENTS_DIR, {recursive: true});
329
+ await fs.writeFile(componentFilePath(name), source, 'utf8');
330
+ const metaPath = componentMetaPath(name);
331
+ if (bundled) await fs.writeFile(metaPath, JSON.stringify({bundled: true}, null, 2) + '\n', 'utf8');
332
+ else await fs.unlink(metaPath).catch(() => {});
333
+ invalidateCache(name);
334
+ await _refreshSanitiserAllowlist();
335
+ return {success: true, name, errors: []};
336
+ }
337
+
338
+ /** Lazy, circular-safe import — refreshes the markdown sanitiser's dm-* tag allowlist. */
339
+ async function _refreshSanitiserAllowlist() {
340
+ try {
341
+ const {refreshComponentTagAllowlist} = await import('./markdown.js');
342
+ await refreshComponentTagAllowlist();
343
+ } catch { /* no-op — markdown module not ready at first save */ }
344
+ }
345
+
346
+ /**
347
+ * Delete a component's .dmc source and its companion .meta.json.
348
+ *
349
+ * @param {string} name
350
+ * @returns {Promise<void>}
351
+ * @throws {Error} With code INVALID_NAME or ENOENT.
352
+ */
353
+ export async function deleteComponent(name) {
354
+ assertValidName(name);
355
+ if (getPluginComponents().some(pc => pc.name === name)) {
356
+ const e = new Error('That name is registered by a plugin and cannot be deleted from the admin.');
357
+ e.code = 'PLUGIN_OWNED';
358
+ throw e;
359
+ }
360
+ try { await fs.unlink(componentFilePath(name)); }
361
+ catch (err) {
362
+ if (err.code === 'ENOENT') {
363
+ const nf = new Error('Component not found');
364
+ nf.code = 'ENOENT';
365
+ throw nf;
366
+ }
367
+ throw err;
368
+ }
369
+ await fs.unlink(componentMetaPath(name)).catch(() => {});
370
+ invalidateCache(name);
371
+ await _refreshSanitiserAllowlist();
372
+ }
373
+
374
+ // ─── Export / import bundle helpers ───────────────────────────────────────
375
+
376
+ /** Current .dmcomponent.json bundle schema version. Bump when the shape changes. */
377
+ export const BUNDLE_FORMAT = 1;
378
+
379
+ /**
380
+ * Export a component as a downloadable bundle object.
381
+ *
382
+ * @param {string} name
383
+ * @returns {Promise<{format: number, name: string, source: string, bundled: boolean}>}
384
+ */
385
+ export async function exportBundle(name) {
386
+ const {source, bundled} = await getComponent(name);
387
+ return {format: BUNDLE_FORMAT, name, source, bundled};
388
+ }
389
+
390
+ /**
391
+ * Import a bundle. Throws with code CONFLICT on name collision unless
392
+ * `overwrite === true`.
393
+ *
394
+ * @param {object} bundle
395
+ * @param {object} [opts]
396
+ * @param {boolean} [opts.overwrite]
397
+ * @returns {Promise<{success: boolean, name: string, errors: Array}>}
398
+ * @throws {Error} With code INVALID_BUNDLE, INVALID_NAME, or CONFLICT.
399
+ */
400
+ export async function importBundle(bundle, {overwrite = false} = {}) {
401
+ if (!bundle || typeof bundle !== 'object') {
402
+ const e = new Error('Invalid bundle'); e.code = 'INVALID_BUNDLE'; throw e;
403
+ }
404
+ if (typeof bundle.format !== 'number' || bundle.format > BUNDLE_FORMAT) {
405
+ const e = new Error(`Unsupported bundle format (${bundle.format})`);
406
+ e.code = 'INVALID_BUNDLE';
407
+ throw e;
408
+ }
409
+ if (typeof bundle.name !== 'string' || typeof bundle.source !== 'string') {
410
+ const e = new Error('Bundle missing name or source');
411
+ e.code = 'INVALID_BUNDLE';
412
+ throw e;
413
+ }
414
+ if (!overwrite) {
415
+ try {
416
+ await getComponent(bundle.name);
417
+ const e = new Error('Component already exists');
418
+ e.code = 'CONFLICT';
419
+ e.name = bundle.name;
420
+ throw e;
421
+ } catch (err) {
422
+ if (err.code !== 'ENOENT') throw err;
423
+ }
424
+ }
425
+ return saveComponent(bundle.name, bundle.source, {bundled: !!bundle.bundled});
426
+ }
427
+
428
+ // ─── Seeded starter components ────────────────────────────────────────────
429
+
430
+ const SEED_COMPONENTS = {
431
+
432
+ counter: `
433
+ <template>
434
+ <div class="dm-counter">
435
+ <div class="value">{{count}}</div>
436
+ <div class="controls">
437
+ <button data-action="dec">−</button>
438
+ <button data-action="inc">+</button>
439
+ {{#if showReset}}<button data-action="reset">Reset</button>{{/if}}
440
+ </div>
441
+ </div>
442
+ </template>
443
+ <props>
444
+ {
445
+ "initial": { "type": "number", "default": 0, "label": "Initial value" },
446
+ "step": { "type": "number", "default": 1, "label": "Step size" },
447
+ "showReset": { "type": "boolean", "default": false, "label": "Show reset button" }
448
+ }
449
+ </props>
450
+ <script>
451
+ // NB: Domma's factory calls data() with no \`this\` binding — props must be
452
+ // read from within onMount() / methods, which ARE bound to the component context.
453
+ export default {
454
+ data() { return { count: 0 }; },
455
+ methods: {
456
+ inc() { this.set({ count: this.data.count + this.props.step }); },
457
+ dec() { this.set({ count: this.data.count - this.props.step }); },
458
+ reset() { this.set({ count: this.props.initial }); }
459
+ },
460
+ onMount() {
461
+ this.set({ count: this.props.initial });
462
+ this.el.shadowRoot.addEventListener('click', (e) => {
463
+ const action = e.target.dataset.action;
464
+ if (action && this[action]) this[action]();
465
+ });
466
+ }
467
+ };
468
+ </script>
469
+ <style>
470
+ .dm-counter { text-align: center; padding: 1rem; }
471
+ .dm-counter .value { font-size: 2.5rem; font-weight: 700; margin-bottom: .5rem; }
472
+ .dm-counter .controls button { margin: 0 .25rem; padding: .4rem .8rem; }
473
+ </style>
474
+ `,
475
+
476
+ 'copy-button': `
477
+ <template>
478
+ <button class="dm-copy-btn" data-action="copy">{{label}}</button>
479
+ </template>
480
+ <props>
481
+ {
482
+ "text": { "type": "string", "default": "", "label": "Text to copy" },
483
+ "label": { "type": "string", "default": "Copy", "label": "Button label" },
484
+ "copiedLabel": { "type": "string", "default": "Copied!","label": "Confirmation label" }
485
+ }
486
+ </props>
487
+ <script>
488
+ export default {
489
+ data() { return { label: '' }; },
490
+ methods: {
491
+ async copy() {
492
+ try {
493
+ await navigator.clipboard.writeText(this.props.text);
494
+ this.set({ label: this.props.copiedLabel });
495
+ setTimeout(() => this.set({ label: this.props.label }), 1500);
496
+ } catch {}
497
+ }
498
+ },
499
+ onMount() {
500
+ this.set({ label: this.props.label });
501
+ this.el.shadowRoot.addEventListener('click', (e) => {
502
+ if (e.target.dataset.action === 'copy') this.copy();
503
+ });
504
+ }
505
+ };
506
+ </script>
507
+ <style>
508
+ .dm-copy-btn { padding: .5rem 1rem; cursor: pointer; }
509
+ </style>
510
+ `,
511
+
512
+ 'reveal-panel': `
513
+ <template>
514
+ <div class="dm-reveal">
515
+ <button class="toggle" data-action="toggle">{{heading}}</button>
516
+ {{#if open}}<div class="body"><slot></slot></div>{{/if}}
517
+ </div>
518
+ </template>
519
+ <props>
520
+ {
521
+ "heading": { "type": "string", "default": "Show more", "label": "Toggle heading" },
522
+ "openDefault": { "type": "boolean", "default": false, "label": "Open on load" }
523
+ }
524
+ </props>
525
+ <script>
526
+ export default {
527
+ data() { return { open: false }; },
528
+ methods: { toggle() { this.set({ open: !this.data.open }); } },
529
+ onMount() {
530
+ this.set({ open: this.props.openDefault });
531
+ this.el.shadowRoot.addEventListener('click', (e) => {
532
+ if (e.target.dataset.action === 'toggle') this.toggle();
533
+ });
534
+ }
535
+ };
536
+ </script>
537
+ <style>
538
+ .dm-reveal .toggle { background: none; border: none; font-weight: 600; cursor: pointer; }
539
+ .dm-reveal .body { margin-top: .5rem; padding-top: .5rem; border-top: 1px solid currentColor; }
540
+ </style>
541
+ `,
542
+
543
+ 'testimonial-card': `
544
+ <template>
545
+ <figure class="dm-testimonial">
546
+ <blockquote class="quote">"{{quote}}"</blockquote>
547
+ <figcaption class="author">
548
+ {{#if avatar}}<img class="avatar" src="{{avatar}}" alt="">{{/if}}
549
+ <div><div class="name">{{author}}</div><div class="role">{{role}}</div></div>
550
+ </figcaption>
551
+ </figure>
552
+ </template>
553
+ <props>
554
+ {
555
+ "quote": { "type": "string", "default": "", "label": "Quote" },
556
+ "author": { "type": "string", "default": "", "label": "Author name" },
557
+ "role": { "type": "string", "default": "", "label": "Author role or company" },
558
+ "avatar": { "type": "string", "default": "", "label": "Avatar image URL" }
559
+ }
560
+ </props>
561
+ <script>
562
+ export default {};
563
+ </script>
564
+ <style>
565
+ .dm-testimonial { margin: 0; padding: 1.25rem; border-radius: 8px; background: rgba(0,0,0,.04); }
566
+ .dm-testimonial .quote { font-size: 1.05rem; font-style: italic; margin: 0 0 .75rem; }
567
+ .dm-testimonial .author { display: flex; align-items: center; gap: .75rem; font-size: .85rem; }
568
+ .dm-testimonial .avatar { width: 40px; height: 40px; border-radius: 50%; object-fit: cover; }
569
+ .dm-testimonial .name { font-weight: 600; }
570
+ .dm-testimonial .role { opacity: .7; }
571
+ </style>
572
+ `,
573
+
574
+ 'countdown-timer': `
575
+ <template>
576
+ <div class="dm-countdown-c">
577
+ <div class="part"><span class="n">{{days}}</span><span class="l">days</span></div>
578
+ <div class="part"><span class="n">{{hours}}</span><span class="l">hrs</span></div>
579
+ <div class="part"><span class="n">{{minutes}}</span><span class="l">min</span></div>
580
+ <div class="part"><span class="n">{{seconds}}</span><span class="l">sec</span></div>
581
+ </div>
582
+ </template>
583
+ <props>
584
+ {
585
+ "target": { "type": "string", "default": "", "label": "Target date (ISO 8601)" }
586
+ }
587
+ </props>
588
+ <script>
589
+ export default {
590
+ data() { return { days: 0, hours: 0, minutes: 0, seconds: 0 }; },
591
+ _timer: null,
592
+ methods: {
593
+ tick() {
594
+ const t = new Date(this.props.target).getTime() - Date.now();
595
+ if (isNaN(t) || t <= 0) { this.set({ days: 0, hours: 0, minutes: 0, seconds: 0 }); return; }
596
+ this.set({
597
+ days: Math.floor(t / 86400000),
598
+ hours: Math.floor((t % 86400000) / 3600000),
599
+ minutes: Math.floor((t % 3600000) / 60000),
600
+ seconds: Math.floor((t % 60000) / 1000)
601
+ });
602
+ }
603
+ },
604
+ onMount() { this.tick(); this._timer = setInterval(() => this.tick(), 1000); },
605
+ onUnmount() { if (this._timer) clearInterval(this._timer); }
606
+ };
607
+ </script>
608
+ <style>
609
+ .dm-countdown-c { display: flex; gap: 1rem; justify-content: center; }
610
+ .dm-countdown-c .part { display: flex; flex-direction: column; align-items: center; }
611
+ .dm-countdown-c .n { font-size: 1.8rem; font-weight: 700; }
612
+ .dm-countdown-c .l { font-size: .7rem; text-transform: uppercase; opacity: .7; }
613
+ </style>
614
+ `
615
+ };
616
+
617
+ /**
618
+ * Seed starter components into content/components/.
619
+ * Never overwrites existing files — user customisations are preserved.
620
+ * All seeded components are marked `bundled: true` via their .meta.json.
621
+ */
622
+ export async function seedDefaultComponents() {
623
+ await fs.mkdir(COMPONENTS_DIR, {recursive: true});
624
+ const seeded = [];
625
+ for (const [name, source] of Object.entries(SEED_COMPONENTS)) {
626
+ const fp = componentFilePath(name);
627
+ try { await fs.access(fp); }
628
+ catch {
629
+ await fs.writeFile(fp, source.trim() + '\n', 'utf8');
630
+ await fs.writeFile(componentMetaPath(name), JSON.stringify({bundled: true}, null, 2) + '\n', 'utf8');
631
+ seeded.push(name);
632
+ }
633
+ }
634
+ if (seeded.length) console.log(`[components] Seeded default components: ${seeded.join(', ')}`);
635
+ }
636
+
637
+ export { COMPONENTS_DIR, assertValidName, componentFilePath, componentMetaPath };
638
+
639
+ // Allow this module to be executed directly for a quick self-test of assertValidName.
640
+ if (import.meta.url === `file://${process.argv[1]}`) {
641
+ const cases = [
642
+ ['counter', true],
643
+ ['copy-button', true],
644
+ ['Counter', false],
645
+ ['1counter', false],
646
+ ['', false],
647
+ ['../etc/passwd', false]
648
+ ];
649
+ for (const [name, ok] of cases) {
650
+ try { assertValidName(name); console.log(ok ? '✓' : '✗', name, '→ valid'); }
651
+ catch { console.log(ok ? '✗' : '✓', name, '→ invalid'); }
652
+ }
653
+ }