domma-cms 1.6.59 → 1.7.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/CHANGELOG.md +4344 -0
- package/README.md +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +2 -2
- package/admin/js/lib/draft-owner.js +1 -0
- package/admin/js/lib/effect-defs.js +1 -1
- package/admin/js/lib/effects-builder.js +2 -2
- package/admin/js/lib/page-editor-reference.js +5 -5
- package/admin/js/lib/shortcode-tags.js +1 -1
- package/admin/js/templates/docs/usage-shortcodes.html +39 -0
- package/admin/js/views/user-editor.js +1 -1
- package/docs/DommaFeatures.md +3005 -0
- package/docs/MultiTenant.md +176 -0
- package/docs/api-reference.md +1667 -0
- package/docs/cache.md +96 -0
- package/docs/configuration.md +1811 -0
- package/docs/editions-and-licences.md +190 -0
- package/docs/getting-started.md +201 -0
- package/docs/guides/form-triggers.md +260 -0
- package/docs/guides/mongodb-storage-adapter.md +282 -0
- package/docs/guides/multi-page-forms.md +121 -0
- package/docs/images/scribe-composer.png +0 -0
- package/docs/markdown-shortcodes.md +1738 -0
- package/docs/pentest-report-2026-04-03.md +181 -0
- package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
- package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
- package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
- package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
- package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
- package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
- package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
- package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
- package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
- package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
- package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
- package/docs/plans/2026-03-05-user-types-design.md +139 -0
- package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
- package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
- package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
- package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
- package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
- package/docs/plans/2026-06-10-core-project-design.md +240 -0
- package/docs/plugin-development.md +827 -0
- package/docs/recruitment-recipe.md +501 -0
- package/docs/scaffolding.md +312 -0
- package/docs/screenshots/calculator-floating.png +0 -0
- package/docs/screenshots/calculator-standard.png +0 -0
- package/docs/screenshots/email-settings.png +0 -0
- package/docs/screenshots/messages-count.png +0 -0
- package/docs/structure/user-types.md +107 -0
- package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
- package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
- package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
- package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
- package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
- package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
- package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
- package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
- package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
- package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
- package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
- package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
- package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
- package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
- package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
- package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
- package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
- package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
- package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
- package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
- package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
- package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
- package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
- package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
- package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
- package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
- package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
- package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
- package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
- package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
- package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
- package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
- package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
- package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
- package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
- package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
- package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
- package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
- package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
- package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
- package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
- package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
- package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
- package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
- package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
- package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
- package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
- package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
- package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
- package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
- package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
- package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
- package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
- package/docs/theming.md +320 -0
- package/package.json +2 -2
- package/plugins/_lib/admin/mail/keyboard.js +49 -0
- package/plugins/_lib/admin/mail/reader-view.js +4 -2
- package/public/js/celebrations/core/canvas.js +1 -1
- package/public/js/celebrations/index.js +1 -1
- package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
- package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
- package/public/js/celebrations/message-attrs.mjs +1 -0
- package/public/js/celebrations/themes/anniversary.js +1 -0
- package/public/js/celebrations/themes/autumn.js +1 -0
- package/public/js/celebrations/themes/birthday.js +1 -0
- package/public/js/celebrations/themes/butterfly-flight.js +1 -0
- package/public/js/celebrations/themes/calendar.js +1 -0
- package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
- package/public/js/celebrations/themes/chinese-new-year.js +1 -0
- package/public/js/celebrations/themes/christmas.js +1 -1
- package/public/js/celebrations/themes/easter.js +1 -0
- package/public/js/celebrations/themes/garden-drawing.js +1 -0
- package/public/js/celebrations/themes/garden.js +1 -0
- package/public/js/celebrations/themes/graduation.js +1 -0
- package/public/js/celebrations/themes/greeting.js +1 -0
- package/public/js/celebrations/themes/make-it-rain.js +1 -0
- package/public/js/celebrations/themes/money-rain.js +1 -0
- package/public/js/celebrations/themes/new-year.js +1 -0
- package/public/js/celebrations/themes/occasion-drawing.js +1 -0
- package/public/js/celebrations/themes/occasions.js +1 -0
- package/public/js/celebrations/themes/rain-control.js +1 -0
- package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
- package/public/js/celebrations/themes/sparkler-font.js +1 -0
- package/public/js/celebrations/themes/sparkler-writing.js +1 -0
- package/public/js/celebrations/themes/summer-garden.js +1 -0
- package/public/js/celebrations/themes/winter.js +1 -0
- package/public/js/celebrations/trait-data.mjs +1 -1
- package/public/js/effects.js +1 -1
- package/scripts/build.js +4 -0
- package/server/services/markdown.js +5 -2
- package/server/templates/page.html +1 -1
|
@@ -0,0 +1,2038 @@
|
|
|
1
|
+
# Components Area Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add a new "Components" area under the admin **Data** section that lets authors create, edit, and embed Domma Custom Elements on public pages via `.dmc` SFC-style files with live preview.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Admin-authored `.dmc` files in `content/components/` are compiled on the server to ES modules that register Custom Elements via `Domma.component(tagName, {…})`. Public pages reference them via `[component name="x" …]` shortcodes or raw `<dm-x>` tags; the renderer scans for used components and injects only the scripts that page needs. Admin has a four-tab code editor + iframe-sandboxed live preview, and a slideover "Insert Component" flow in the page editor.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Fastify 5 (ESM, backend), Domma SPA + Domma component factory (admin + public), Node's built-in `node --test` for compiler unit tests, DOMPurify for HTML sanitisation.
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-04-17-components-area-design.md`
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Conventions for the implementer
|
|
16
|
+
|
|
17
|
+
- **No `git commit` steps.** The user commits separately. Commit boundaries are noted where they make sense but not emitted as shell commands.
|
|
18
|
+
- **Follow existing patterns closely.** The Blocks feature (`server/services/blocks.js`, `server/routes/api/blocks.js`, `admin/js/views/blocks.js`, `admin/js/views/block-editor.js`) is the nearest analogue. When in doubt, mirror Blocks.
|
|
19
|
+
- **Domma framework, not jQuery/vanilla.** Use `$`, `T`, `E`, `F`, `S`, `H`, `I`, `_` as per the existing admin views.
|
|
20
|
+
- **British English in user-facing text.**
|
|
21
|
+
- **Run the server** as it's already running on port 4096; do not start/stop. Use `curl` to hit endpoints.
|
|
22
|
+
- **JSDoc all new exported functions** (the project's convention).
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## File structure (maps every task to a file)
|
|
27
|
+
|
|
28
|
+
### Server (new files)
|
|
29
|
+
|
|
30
|
+
| File | Responsibility |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `server/services/components.js` | Name validation, `.dmc` parser, compiler, cache, CRUD, seed. |
|
|
33
|
+
| `server/routes/api/components.js` | HTTP surface: list, get, compile, put, delete, export, import, public `/:name.js`. |
|
|
34
|
+
| `content/components/` | Seeded `.dmc` files + `.meta.json` companions. Five starter components. |
|
|
35
|
+
|
|
36
|
+
### Server (modified)
|
|
37
|
+
|
|
38
|
+
| File | Change |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `server/server.js` | Register components route; `await seedDefaultComponents()` on startup. |
|
|
41
|
+
| `server/services/markdown.js` | New `collectComponents()` pass; DOMPurify tag + attribute allowlist. |
|
|
42
|
+
| `server/services/renderer.js` | Inject per-page `<script type="module">` tags into `bodyEndInject`. |
|
|
43
|
+
| `server/services/permissionRegistry.js` | Add `components` resource. |
|
|
44
|
+
| `server/services/hooks.js` | Add `registerComponent` hook. |
|
|
45
|
+
| `public/css/site.css` | Add `:not(:defined) { visibility: hidden; }`. |
|
|
46
|
+
|
|
47
|
+
### Admin (new files)
|
|
48
|
+
|
|
49
|
+
| File | Responsibility |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `admin/preview/component-preview.html` | Static iframe bootstrap. Loads Domma, listens for `postMessage`, mounts the component. |
|
|
52
|
+
| `admin/js/views/components.js` | List-view logic. |
|
|
53
|
+
| `admin/js/templates/components.html` | List-view template. |
|
|
54
|
+
| `admin/js/views/component-editor.js` | Editor logic - four tabs + iframe preview. |
|
|
55
|
+
| `admin/js/templates/component-editor.html` | Editor template. |
|
|
56
|
+
|
|
57
|
+
### Admin (modified)
|
|
58
|
+
|
|
59
|
+
| File | Change |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `admin/js/api.js` | Add `api.components.{list, get, compile, put, delete, exportBundle, importBundle}`. |
|
|
62
|
+
| `admin/js/views/index.js` | Register `components` + `componentEditor` views. |
|
|
63
|
+
| `admin/js/config/sidebar-config.js` | Sidebar entry under Data. |
|
|
64
|
+
| `admin/js/app.js` | Extend `fetchSidebarCounts()` with `api.components.list()`. |
|
|
65
|
+
| `admin/js/views/page-editor.js` | Add "Insert Component" toolbar button + slideover wiring. |
|
|
66
|
+
|
|
67
|
+
### Tests (new)
|
|
68
|
+
|
|
69
|
+
| File | Responsibility |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `tests/components/compile.test.js` | Unit tests for parser + compiler (`node --test`). |
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
# PHASE 1 - Server services and compiler
|
|
76
|
+
|
|
77
|
+
## Task 1: Components service skeleton
|
|
78
|
+
|
|
79
|
+
**Files:**
|
|
80
|
+
- Create: `server/services/components.js`
|
|
81
|
+
|
|
82
|
+
- [ ] **Step 1: Create the service file with constants and name validation**
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
/**
|
|
86
|
+
* Components Service
|
|
87
|
+
* CRUD, compile pipeline, and seeding for .dmc component files
|
|
88
|
+
* stored in content/components/.
|
|
89
|
+
*/
|
|
90
|
+
import fs from 'fs/promises';
|
|
91
|
+
import path from 'path';
|
|
92
|
+
import {fileURLToPath} from 'url';
|
|
93
|
+
|
|
94
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
95
|
+
const COMPONENTS_DIR = path.resolve(__dirname, '../../content/components');
|
|
96
|
+
|
|
97
|
+
/** Component names: lowercase alphanumeric + hyphens. Drives the Custom Element tag `dm-<name>`. */
|
|
98
|
+
const NAME_RE = /^[a-z][a-z0-9-]*$/;
|
|
99
|
+
|
|
100
|
+
function assertValidName(name) {
|
|
101
|
+
if (!NAME_RE.test(name)) {
|
|
102
|
+
const err = new Error('Invalid component name. Use a letter followed by lowercase letters, digits, and hyphens.');
|
|
103
|
+
err.code = 'INVALID_NAME';
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function componentFilePath(name) { return path.join(COMPONENTS_DIR, `${name}.dmc`); }
|
|
109
|
+
function componentMetaPath(name) { return path.join(COMPONENTS_DIR, `${name}.meta.json`); }
|
|
110
|
+
|
|
111
|
+
export { COMPONENTS_DIR, assertValidName, componentFilePath, componentMetaPath };
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
- [ ] **Step 2: Add a self-test harness for `assertValidName`**
|
|
115
|
+
|
|
116
|
+
Append to the end of `server/services/components.js`:
|
|
117
|
+
|
|
118
|
+
```js
|
|
119
|
+
// eslint-disable-next-line
|
|
120
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
121
|
+
const cases = [
|
|
122
|
+
['counter', true],
|
|
123
|
+
['copy-button', true],
|
|
124
|
+
['Counter', false],
|
|
125
|
+
['1counter', false],
|
|
126
|
+
['', false],
|
|
127
|
+
['../etc/passwd', false]
|
|
128
|
+
];
|
|
129
|
+
for (const [name, ok] of cases) {
|
|
130
|
+
try { assertValidName(name); console.log(ok ? '✓' : '✗', name, '→ valid'); }
|
|
131
|
+
catch { console.log(ok ? '✗' : '✓', name, '→ invalid'); }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
- [ ] **Step 3: Verify by running it directly**
|
|
137
|
+
|
|
138
|
+
Run: `node server/services/components.js`
|
|
139
|
+
Expected: six lines all prefixed `✓`.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Task 2: Block parser - split `.dmc` into four parts
|
|
144
|
+
|
|
145
|
+
**Files:**
|
|
146
|
+
- Modify: `server/services/components.js`
|
|
147
|
+
- Create: `tests/components/compile.test.js`
|
|
148
|
+
|
|
149
|
+
- [ ] **Step 1: Write a failing parser test**
|
|
150
|
+
|
|
151
|
+
Create `tests/components/compile.test.js`:
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
import {test} from 'node:test';
|
|
155
|
+
import assert from 'node:assert/strict';
|
|
156
|
+
import {parseDmcSource} from '../../server/services/components.js';
|
|
157
|
+
|
|
158
|
+
test('parseDmcSource splits the four blocks', () => {
|
|
159
|
+
const src = `<template>
|
|
160
|
+
<div>{{count}}</div>
|
|
161
|
+
</template>
|
|
162
|
+
<props>
|
|
163
|
+
{ "x": {"type":"number","default":0} }
|
|
164
|
+
</props>
|
|
165
|
+
<script>
|
|
166
|
+
export default { data() { return { count: 1 }; } };
|
|
167
|
+
</script>
|
|
168
|
+
<style>
|
|
169
|
+
div { color: red; }
|
|
170
|
+
</style>`;
|
|
171
|
+
const {template, props, script, style} = parseDmcSource(src);
|
|
172
|
+
assert.match(template, /<div>\{\{count\}\}<\/div>/);
|
|
173
|
+
assert.match(props, /"type":"number"/);
|
|
174
|
+
assert.match(script, /export default/);
|
|
175
|
+
assert.match(style, /color: red/);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('parseDmcSource tolerates missing <style>', () => {
|
|
179
|
+
const src = `<template>x</template><props>{}</props><script>export default {};</script>`;
|
|
180
|
+
const {style} = parseDmcSource(src);
|
|
181
|
+
assert.equal(style, '');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('parseDmcSource throws on missing <template>', () => {
|
|
185
|
+
const src = `<props>{}</props><script>export default {};</script>`;
|
|
186
|
+
assert.throws(() => parseDmcSource(src), /MISSING_BLOCK/);
|
|
187
|
+
});
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
- [ ] **Step 2: Run to verify it fails**
|
|
191
|
+
|
|
192
|
+
Run: `node --test tests/components/compile.test.js`
|
|
193
|
+
Expected: all three tests FAIL with `parseDmcSource is not a function`.
|
|
194
|
+
|
|
195
|
+
- [ ] **Step 3: Implement the parser in `server/services/components.js`**
|
|
196
|
+
|
|
197
|
+
Add below the existing helpers:
|
|
198
|
+
|
|
199
|
+
```js
|
|
200
|
+
/**
|
|
201
|
+
* Split a .dmc source string into its four SFC blocks.
|
|
202
|
+
* Missing <template>, <props>, or <script> is a compile error;
|
|
203
|
+
* <style> is optional (returns '').
|
|
204
|
+
*
|
|
205
|
+
* @param {string} source
|
|
206
|
+
* @returns {{template: string, props: string, script: string, style: string}}
|
|
207
|
+
* @throws {Error} With code MISSING_BLOCK when a required block is absent.
|
|
208
|
+
*/
|
|
209
|
+
export function parseDmcSource(source) {
|
|
210
|
+
const extract = (tag) => {
|
|
211
|
+
const re = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`);
|
|
212
|
+
const m = source.match(re);
|
|
213
|
+
return m ? m[1].trim() : null;
|
|
214
|
+
};
|
|
215
|
+
const template = extract('template');
|
|
216
|
+
const props = extract('props');
|
|
217
|
+
const script = extract('script');
|
|
218
|
+
const style = extract('style') ?? '';
|
|
219
|
+
|
|
220
|
+
for (const [name, value] of [['template', template], ['props', props], ['script', script]]) {
|
|
221
|
+
if (value === null) {
|
|
222
|
+
const err = new Error(`Missing required <${name}> block.`);
|
|
223
|
+
err.code = 'MISSING_BLOCK';
|
|
224
|
+
err.block = name;
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return {template, props, script, style};
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
- [ ] **Step 4: Re-run tests**
|
|
233
|
+
|
|
234
|
+
Run: `node --test tests/components/compile.test.js`
|
|
235
|
+
Expected: all three PASS.
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## Task 3: Props schema validator
|
|
240
|
+
|
|
241
|
+
**Files:**
|
|
242
|
+
- Modify: `server/services/components.js`
|
|
243
|
+
- Modify: `tests/components/compile.test.js`
|
|
244
|
+
|
|
245
|
+
- [ ] **Step 1: Add failing tests for props validation**
|
|
246
|
+
|
|
247
|
+
Append to `tests/components/compile.test.js`:
|
|
248
|
+
|
|
249
|
+
```js
|
|
250
|
+
import {validatePropsJson} from '../../server/services/components.js';
|
|
251
|
+
|
|
252
|
+
test('validatePropsJson parses valid schema', () => {
|
|
253
|
+
const p = validatePropsJson('{"x": {"type":"number","default":0}}');
|
|
254
|
+
assert.equal(p.x.type, 'number');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('validatePropsJson rejects invalid JSON', () => {
|
|
258
|
+
assert.throws(() => validatePropsJson('{not json'), /INVALID_PROPS_JSON/);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('validatePropsJson rejects missing type', () => {
|
|
262
|
+
assert.throws(() => validatePropsJson('{"x": {"default": 0}}'), /INVALID_PROPS_JSON/);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('validatePropsJson rejects unknown type', () => {
|
|
266
|
+
assert.throws(() => validatePropsJson('{"x": {"type":"bigint"}}'), /INVALID_PROPS_JSON/);
|
|
267
|
+
});
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
- [ ] **Step 2: Run to verify failure**
|
|
271
|
+
|
|
272
|
+
Run: `node --test tests/components/compile.test.js`
|
|
273
|
+
Expected: four new tests FAIL.
|
|
274
|
+
|
|
275
|
+
- [ ] **Step 3: Implement `validatePropsJson`**
|
|
276
|
+
|
|
277
|
+
Add to `server/services/components.js`:
|
|
278
|
+
|
|
279
|
+
```js
|
|
280
|
+
const VALID_PROP_TYPES = new Set(['string', 'number', 'boolean', 'array', 'object']);
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Parse and validate a <props> block JSON body.
|
|
284
|
+
*
|
|
285
|
+
* @param {string} json
|
|
286
|
+
* @returns {object} Parsed props schema.
|
|
287
|
+
* @throws {Error} With code INVALID_PROPS_JSON on any problem.
|
|
288
|
+
*/
|
|
289
|
+
export function validatePropsJson(json) {
|
|
290
|
+
let parsed;
|
|
291
|
+
try { parsed = JSON.parse(json); }
|
|
292
|
+
catch (e) {
|
|
293
|
+
const err = new Error(`<props> is not valid JSON: ${e.message}`);
|
|
294
|
+
err.code = 'INVALID_PROPS_JSON';
|
|
295
|
+
throw err;
|
|
296
|
+
}
|
|
297
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
298
|
+
const err = new Error('<props> must be a JSON object');
|
|
299
|
+
err.code = 'INVALID_PROPS_JSON';
|
|
300
|
+
throw err;
|
|
301
|
+
}
|
|
302
|
+
for (const [propName, def] of Object.entries(parsed)) {
|
|
303
|
+
if (!def || typeof def !== 'object' || !VALID_PROP_TYPES.has(def.type)) {
|
|
304
|
+
const err = new Error(`<props>.${propName} must declare a valid type (one of: ${[...VALID_PROP_TYPES].join(', ')})`);
|
|
305
|
+
err.code = 'INVALID_PROPS_JSON';
|
|
306
|
+
throw err;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return parsed;
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
- [ ] **Step 4: Re-run tests**
|
|
314
|
+
|
|
315
|
+
Run: `node --test tests/components/compile.test.js`
|
|
316
|
+
Expected: all tests PASS.
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## Task 4: `<script>` body transform (⚠️ USER CONTRIBUTION OPPORTUNITY)
|
|
321
|
+
|
|
322
|
+
**Files:**
|
|
323
|
+
- Modify: `server/services/components.js`
|
|
324
|
+
- Modify: `tests/components/compile.test.js`
|
|
325
|
+
|
|
326
|
+
> **★ Learning-mode note for the human in the loop ★**
|
|
327
|
+
>
|
|
328
|
+
> This function is a small but genuinely interesting decision point and a great place for the user to contribute ~10 lines of meaningful code.
|
|
329
|
+
>
|
|
330
|
+
> **What it has to do:** take a `<script>` body like
|
|
331
|
+
>
|
|
332
|
+
> ```js
|
|
333
|
+
> export default { data() { return { count: 1 }; }, methods: { inc() {…} } };
|
|
334
|
+
> ```
|
|
335
|
+
>
|
|
336
|
+
> and return just the object expression (no `export default`, no trailing semicolon) so the compiler can embed it as:
|
|
337
|
+
>
|
|
338
|
+
> ```js
|
|
339
|
+
> const definition = { data() { return { count: 1 }; }, methods: { inc() {…} } };
|
|
340
|
+
> ```
|
|
341
|
+
>
|
|
342
|
+
> **Trade-offs worth weighing:**
|
|
343
|
+
> - **Regex approach** (5-8 lines): strip the literal `export default` keyword, trim surrounding whitespace, drop a trailing semicolon. Tolerates 95 % of real-world inputs; can be fooled by weird cases (an `export default` inside a string literal or comment at the top of the script - extremely unlikely in practice).
|
|
344
|
+
> - **Indexed scan** (10-15 lines): find the first `export default` keyword that's *not* inside a string or comment by scanning char by char. More robust, more code.
|
|
345
|
+
> - **Full AST parse with acorn** (introduces a dependency): the most correct, but pulls in a parser just for this one transform.
|
|
346
|
+
>
|
|
347
|
+
> **Recommendation:** start with the regex - it's honest, debuggable, and the author population is trusted admins writing ordinary JS. If it ever fails in practice, revisit.
|
|
348
|
+
|
|
349
|
+
- [ ] **Step 1: Add failing tests**
|
|
350
|
+
|
|
351
|
+
Append to `tests/components/compile.test.js`:
|
|
352
|
+
|
|
353
|
+
```js
|
|
354
|
+
import {stripExportDefault} from '../../server/services/components.js';
|
|
355
|
+
|
|
356
|
+
test('stripExportDefault removes the keyword', () => {
|
|
357
|
+
const body = `export default { data() { return { count: 1 }; } };`;
|
|
358
|
+
assert.equal(
|
|
359
|
+
stripExportDefault(body).trim(),
|
|
360
|
+
`{ data() { return { count: 1 }; } }`
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('stripExportDefault handles leading whitespace and trailing semicolon', () => {
|
|
365
|
+
const body = `\n\n export default {x:1} ;\n`;
|
|
366
|
+
assert.equal(stripExportDefault(body).trim(), `{x:1}`);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('stripExportDefault throws when no export default is found', () => {
|
|
370
|
+
assert.throws(() => stripExportDefault(`const x = 1;`), /SCRIPT_MISSING_EXPORT/);
|
|
371
|
+
});
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
- [ ] **Step 2: Run to verify failure**
|
|
375
|
+
|
|
376
|
+
Run: `node --test tests/components/compile.test.js`
|
|
377
|
+
Expected: three new tests FAIL.
|
|
378
|
+
|
|
379
|
+
- [ ] **Step 3: Implement `stripExportDefault` in `server/services/components.js`**
|
|
380
|
+
|
|
381
|
+
Recommended minimal implementation (user may wish to substitute their own):
|
|
382
|
+
|
|
383
|
+
```js
|
|
384
|
+
/**
|
|
385
|
+
* Transform a <script> body for embedding.
|
|
386
|
+
* Removes the `export default` prefix and any trailing semicolon,
|
|
387
|
+
* leaving only the object expression.
|
|
388
|
+
*
|
|
389
|
+
* @param {string} body - Raw <script> body.
|
|
390
|
+
* @returns {string} The bare object expression.
|
|
391
|
+
* @throws {Error} With code SCRIPT_MISSING_EXPORT when no export default is found.
|
|
392
|
+
*/
|
|
393
|
+
export function stripExportDefault(body) {
|
|
394
|
+
const m = body.match(/^\s*export\s+default\s+([\s\S]+?)\s*;?\s*$/);
|
|
395
|
+
if (!m) {
|
|
396
|
+
const err = new Error('<script> must have an `export default` statement');
|
|
397
|
+
err.code = 'SCRIPT_MISSING_EXPORT';
|
|
398
|
+
throw err;
|
|
399
|
+
}
|
|
400
|
+
return m[1];
|
|
401
|
+
}
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
- [ ] **Step 4: Re-run tests**
|
|
405
|
+
|
|
406
|
+
Run: `node --test tests/components/compile.test.js`
|
|
407
|
+
Expected: all tests PASS.
|
|
408
|
+
|
|
409
|
+
---
|
|
410
|
+
|
|
411
|
+
## Task 5: Compile function - assemble the JS module
|
|
412
|
+
|
|
413
|
+
**Files:**
|
|
414
|
+
- Modify: `server/services/components.js`
|
|
415
|
+
- Modify: `tests/components/compile.test.js`
|
|
416
|
+
|
|
417
|
+
- [ ] **Step 1: Add a failing integration test**
|
|
418
|
+
|
|
419
|
+
Append to `tests/components/compile.test.js`:
|
|
420
|
+
|
|
421
|
+
```js
|
|
422
|
+
import {compileComponent} from '../../server/services/components.js';
|
|
423
|
+
|
|
424
|
+
test('compileComponent produces a self-contained module', () => {
|
|
425
|
+
const src = `<template><div>{{count}}</div></template>
|
|
426
|
+
<props>{"initial":{"type":"number","default":0}}</props>
|
|
427
|
+
<script>export default { data() { return { count: this.props.initial }; } };</script>
|
|
428
|
+
<style>div { color: red; }</style>`;
|
|
429
|
+
const {js, errors} = compileComponent(src, 'counter');
|
|
430
|
+
assert.deepEqual(errors, []);
|
|
431
|
+
assert.match(js, /const template = /);
|
|
432
|
+
assert.match(js, /const style = /);
|
|
433
|
+
assert.match(js, /const props = /);
|
|
434
|
+
assert.match(js, /const definition = /);
|
|
435
|
+
assert.match(js, /Domma\.component\('dm-counter'/);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test('compileComponent returns structured errors for bad props', () => {
|
|
439
|
+
const src = `<template>x</template><props>{not json}</props><script>export default {};</script>`;
|
|
440
|
+
const {js, errors} = compileComponent(src, 'bad');
|
|
441
|
+
assert.equal(js, null);
|
|
442
|
+
assert.equal(errors.length, 1);
|
|
443
|
+
assert.equal(errors[0].type, 'INVALID_PROPS_JSON');
|
|
444
|
+
});
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
- [ ] **Step 2: Run to verify failure**
|
|
448
|
+
|
|
449
|
+
Run: `node --test tests/components/compile.test.js`
|
|
450
|
+
Expected: two new tests FAIL.
|
|
451
|
+
|
|
452
|
+
- [ ] **Step 3: Implement `compileComponent`**
|
|
453
|
+
|
|
454
|
+
```js
|
|
455
|
+
/**
|
|
456
|
+
* Escape a string for use inside a JS template literal.
|
|
457
|
+
* Handles backticks, backslashes, and `${` sequences.
|
|
458
|
+
*/
|
|
459
|
+
function toBacktickLiteral(str) {
|
|
460
|
+
return '`' + String(str).replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${') + '`';
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Compile a .dmc source into a self-contained JS module that registers
|
|
465
|
+
* the component on load via Domma.component().
|
|
466
|
+
*
|
|
467
|
+
* @param {string} source - Full .dmc file contents.
|
|
468
|
+
* @param {string} name - Component name (controls the Custom Element tag).
|
|
469
|
+
* @returns {{js: string|null, errors: Array<{type: string, message: string, block?: string}>}}
|
|
470
|
+
*/
|
|
471
|
+
export function compileComponent(source, name) {
|
|
472
|
+
try {
|
|
473
|
+
assertValidName(name);
|
|
474
|
+
} catch (e) {
|
|
475
|
+
return {js: null, errors: [{type: e.code, message: e.message}]};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
let parts;
|
|
479
|
+
try { parts = parseDmcSource(source); }
|
|
480
|
+
catch (e) { return {js: null, errors: [{type: e.code, message: e.message, block: e.block}]}; }
|
|
481
|
+
|
|
482
|
+
let propsObj;
|
|
483
|
+
try { propsObj = validatePropsJson(parts.props); }
|
|
484
|
+
catch (e) { return {js: null, errors: [{type: e.code, message: e.message}]}; }
|
|
485
|
+
|
|
486
|
+
let definitionExpr;
|
|
487
|
+
try { definitionExpr = stripExportDefault(parts.script); }
|
|
488
|
+
catch (e) { return {js: null, errors: [{type: e.code, message: e.message}]}; }
|
|
489
|
+
|
|
490
|
+
const js = [
|
|
491
|
+
`// Compiled from ${name}.dmc - do not edit by hand.`,
|
|
492
|
+
`const template = ${toBacktickLiteral(parts.template)};`,
|
|
493
|
+
`const style = ${toBacktickLiteral(parts.style)};`,
|
|
494
|
+
`const props = ${JSON.stringify(propsObj)};`,
|
|
495
|
+
`const definition = ${definitionExpr};`,
|
|
496
|
+
`Domma.component('dm-${name}', { template, style, props, ...definition });`
|
|
497
|
+
].join('\n');
|
|
498
|
+
|
|
499
|
+
return {js, errors: []};
|
|
500
|
+
}
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
- [ ] **Step 4: Re-run tests**
|
|
504
|
+
|
|
505
|
+
Run: `node --test tests/components/compile.test.js`
|
|
506
|
+
Expected: all tests PASS.
|
|
507
|
+
|
|
508
|
+
---
|
|
509
|
+
|
|
510
|
+
## Task 6: In-memory compile cache
|
|
511
|
+
|
|
512
|
+
**Files:**
|
|
513
|
+
- Modify: `server/services/components.js`
|
|
514
|
+
|
|
515
|
+
- [ ] **Step 1: Add the cache primitives**
|
|
516
|
+
|
|
517
|
+
```js
|
|
518
|
+
const _compileCache = new Map(); // name → {js, mtime}
|
|
519
|
+
|
|
520
|
+
/** Drop a cached compiled output - called on save/delete. */
|
|
521
|
+
export function invalidateCache(name) { _compileCache.delete(name); }
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Return compiled JS for `name`, reading + compiling lazily if not cached.
|
|
525
|
+
* Returns { js, mtime } on success, or { errors, mtime } on compile failure.
|
|
526
|
+
*/
|
|
527
|
+
export async function getCompiledJs(name) {
|
|
528
|
+
assertValidName(name);
|
|
529
|
+
const fp = componentFilePath(name);
|
|
530
|
+
const stat = await fs.stat(fp);
|
|
531
|
+
const cached = _compileCache.get(name);
|
|
532
|
+
if (cached && cached.mtime === stat.mtimeMs) return cached;
|
|
533
|
+
const source = await fs.readFile(fp, 'utf8');
|
|
534
|
+
const {js, errors} = compileComponent(source, name);
|
|
535
|
+
const entry = {js, errors, mtime: stat.mtimeMs};
|
|
536
|
+
_compileCache.set(name, entry);
|
|
537
|
+
return entry;
|
|
538
|
+
}
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
- [ ] **Step 2: Smoke test by writing a throwaway file and calling the function**
|
|
542
|
+
|
|
543
|
+
```bash
|
|
544
|
+
mkdir -p /home/darryl/src/js/domma-cms/content/components
|
|
545
|
+
cat > /home/darryl/src/js/domma-cms/content/components/__smoke.dmc <<'EOF'
|
|
546
|
+
<template><div>hi</div></template>
|
|
547
|
+
<props>{}</props>
|
|
548
|
+
<script>export default { data() { return {}; } };</script>
|
|
549
|
+
EOF
|
|
550
|
+
node -e "import('/home/darryl/src/js/domma-cms/server/services/components.js').then(m=>m.getCompiledJs('__smoke').then(r=>console.log(r.js.slice(0,80))))"
|
|
551
|
+
rm /home/darryl/src/js/domma-cms/content/components/__smoke.dmc
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
Expected: output begins with `// Compiled from __smoke.dmc`.
|
|
555
|
+
|
|
556
|
+
---
|
|
557
|
+
|
|
558
|
+
## Task 7: CRUD operations (list, get, save, delete)
|
|
559
|
+
|
|
560
|
+
**Files:**
|
|
561
|
+
- Modify: `server/services/components.js`
|
|
562
|
+
|
|
563
|
+
- [ ] **Step 1: Implement `listComponents`**
|
|
564
|
+
|
|
565
|
+
```js
|
|
566
|
+
/**
|
|
567
|
+
* List all components. Returns lightweight metadata - does NOT compile.
|
|
568
|
+
* The `props` field is parsed from the <props> block so callers can display
|
|
569
|
+
* props in the list view without a second fetch.
|
|
570
|
+
*/
|
|
571
|
+
export async function listComponents() {
|
|
572
|
+
await fs.mkdir(COMPONENTS_DIR, {recursive: true});
|
|
573
|
+
const files = await fs.readdir(COMPONENTS_DIR);
|
|
574
|
+
const entries = [];
|
|
575
|
+
for (const file of files.filter(f => f.endsWith('.dmc'))) {
|
|
576
|
+
const name = file.slice(0, -4);
|
|
577
|
+
const stat = await fs.stat(path.join(COMPONENTS_DIR, file)).catch(() => null);
|
|
578
|
+
let bundled = false;
|
|
579
|
+
try {
|
|
580
|
+
const meta = JSON.parse(await fs.readFile(componentMetaPath(name), 'utf8'));
|
|
581
|
+
bundled = !!meta.bundled;
|
|
582
|
+
} catch { /* no meta */ }
|
|
583
|
+
let props = {};
|
|
584
|
+
try {
|
|
585
|
+
const src = await fs.readFile(path.join(COMPONENTS_DIR, file), 'utf8');
|
|
586
|
+
const {props: propsBlock} = parseDmcSource(src);
|
|
587
|
+
props = validatePropsJson(propsBlock);
|
|
588
|
+
} catch { /* malformed - list still usable */ }
|
|
589
|
+
entries.push({
|
|
590
|
+
name,
|
|
591
|
+
size: stat?.size ?? 0,
|
|
592
|
+
updatedAt: stat?.mtime?.toISOString() ?? null,
|
|
593
|
+
bundled,
|
|
594
|
+
props
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
return entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
598
|
+
}
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
- [ ] **Step 2: Implement `getComponent` (raw source + parsed props)**
|
|
602
|
+
|
|
603
|
+
```js
|
|
604
|
+
/**
|
|
605
|
+
* Read a component's raw .dmc source plus parsed props.
|
|
606
|
+
*
|
|
607
|
+
* @returns {Promise<{name: string, source: string, props: object, bundled: boolean}>}
|
|
608
|
+
*/
|
|
609
|
+
export async function getComponent(name) {
|
|
610
|
+
assertValidName(name);
|
|
611
|
+
try {
|
|
612
|
+
const source = await fs.readFile(componentFilePath(name), 'utf8');
|
|
613
|
+
let bundled = false;
|
|
614
|
+
try {
|
|
615
|
+
const meta = JSON.parse(await fs.readFile(componentMetaPath(name), 'utf8'));
|
|
616
|
+
bundled = !!meta.bundled;
|
|
617
|
+
} catch { /* no meta */ }
|
|
618
|
+
let props = {};
|
|
619
|
+
try {
|
|
620
|
+
const parts = parseDmcSource(source);
|
|
621
|
+
props = validatePropsJson(parts.props);
|
|
622
|
+
} catch { /* return malformed source for editing; UI will flag */ }
|
|
623
|
+
return {name, source, props, bundled};
|
|
624
|
+
} catch (err) {
|
|
625
|
+
if (err.code === 'ENOENT') {
|
|
626
|
+
const nf = new Error('Component not found'); nf.code = 'ENOENT'; throw nf;
|
|
627
|
+
}
|
|
628
|
+
throw err;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
- [ ] **Step 3: Implement `saveComponent` - compiles before writing**
|
|
634
|
+
|
|
635
|
+
```js
|
|
636
|
+
/**
|
|
637
|
+
* Create or update a component. Compiles BEFORE writing - a malformed
|
|
638
|
+
* source never reaches disk. Returns the compile errors if any.
|
|
639
|
+
*
|
|
640
|
+
* @returns {Promise<{success: boolean, name: string, errors: Array}>}
|
|
641
|
+
*/
|
|
642
|
+
export async function saveComponent(name, source, {bundled = false} = {}) {
|
|
643
|
+
assertValidName(name);
|
|
644
|
+
const {errors} = compileComponent(source, name);
|
|
645
|
+
if (errors.length) return {success: false, name, errors};
|
|
646
|
+
|
|
647
|
+
await fs.mkdir(COMPONENTS_DIR, {recursive: true});
|
|
648
|
+
await fs.writeFile(componentFilePath(name), source, 'utf8');
|
|
649
|
+
const metaPath = componentMetaPath(name);
|
|
650
|
+
if (bundled) await fs.writeFile(metaPath, JSON.stringify({bundled: true}, null, 2) + '\n', 'utf8');
|
|
651
|
+
else await fs.unlink(metaPath).catch(() => {});
|
|
652
|
+
invalidateCache(name);
|
|
653
|
+
return {success: true, name, errors: []};
|
|
654
|
+
}
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
- [ ] **Step 4: Implement `deleteComponent`**
|
|
658
|
+
|
|
659
|
+
```js
|
|
660
|
+
/**
|
|
661
|
+
* Delete a component's .dmc + companion .meta.json.
|
|
662
|
+
* @throws {Error} With code ENOENT when the component is not found.
|
|
663
|
+
*/
|
|
664
|
+
export async function deleteComponent(name) {
|
|
665
|
+
assertValidName(name);
|
|
666
|
+
try { await fs.unlink(componentFilePath(name)); }
|
|
667
|
+
catch (err) {
|
|
668
|
+
if (err.code === 'ENOENT') { const nf = new Error('Component not found'); nf.code = 'ENOENT'; throw nf; }
|
|
669
|
+
throw err;
|
|
670
|
+
}
|
|
671
|
+
await fs.unlink(componentMetaPath(name)).catch(() => {});
|
|
672
|
+
invalidateCache(name);
|
|
673
|
+
}
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
- [ ] **Step 5: Smoke test CRUD via a tiny throwaway script**
|
|
677
|
+
|
|
678
|
+
```bash
|
|
679
|
+
node -e "
|
|
680
|
+
const m = await import('/home/darryl/src/js/domma-cms/server/services/components.js');
|
|
681
|
+
const src = '<template><div>{{x}}</div></template><props>{\"x\":{\"type\":\"string\",\"default\":\"hi\"}}</props><script>export default { data(){ return { x: this.props.x }; } };</script>';
|
|
682
|
+
await m.saveComponent('__smoke', src);
|
|
683
|
+
console.log('list:', (await m.listComponents()).map(c=>c.name));
|
|
684
|
+
console.log('get:', (await m.getComponent('__smoke')).name);
|
|
685
|
+
await m.deleteComponent('__smoke');
|
|
686
|
+
console.log('after delete:', (await m.listComponents()).map(c=>c.name));
|
|
687
|
+
"
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
Expected: `__smoke` present in the list, then absent after delete.
|
|
691
|
+
|
|
692
|
+
---
|
|
693
|
+
|
|
694
|
+
## Task 8: Export/import bundle helpers
|
|
695
|
+
|
|
696
|
+
**Files:**
|
|
697
|
+
- Modify: `server/services/components.js`
|
|
698
|
+
|
|
699
|
+
- [ ] **Step 1: Add `BUNDLE_FORMAT` constant and helper**
|
|
700
|
+
|
|
701
|
+
```js
|
|
702
|
+
/** Current .dmcomponent.json bundle schema version. Bump when shape changes. */
|
|
703
|
+
export const BUNDLE_FORMAT = 1;
|
|
704
|
+
|
|
705
|
+
/** Export a component as a downloadable bundle object. */
|
|
706
|
+
export async function exportBundle(name) {
|
|
707
|
+
const {source, bundled} = await getComponent(name);
|
|
708
|
+
return {format: BUNDLE_FORMAT, name, source, bundled};
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Import a bundle. On name collision, throws with code CONFLICT
|
|
713
|
+
* unless overwrite === true.
|
|
714
|
+
*/
|
|
715
|
+
export async function importBundle(bundle, {overwrite = false} = {}) {
|
|
716
|
+
if (!bundle || typeof bundle !== 'object') { const e = new Error('Invalid bundle'); e.code = 'INVALID_BUNDLE'; throw e; }
|
|
717
|
+
if (typeof bundle.format !== 'number' || bundle.format > BUNDLE_FORMAT) {
|
|
718
|
+
const e = new Error(`Unsupported bundle format (${bundle.format})`); e.code = 'INVALID_BUNDLE'; throw e;
|
|
719
|
+
}
|
|
720
|
+
if (typeof bundle.name !== 'string' || typeof bundle.source !== 'string') {
|
|
721
|
+
const e = new Error('Bundle missing name or source'); e.code = 'INVALID_BUNDLE'; throw e;
|
|
722
|
+
}
|
|
723
|
+
if (!overwrite) {
|
|
724
|
+
try { await getComponent(bundle.name); const e = new Error('Component already exists'); e.code = 'CONFLICT'; e.name = bundle.name; throw e; }
|
|
725
|
+
catch (err) { if (err.code !== 'ENOENT') throw err; }
|
|
726
|
+
}
|
|
727
|
+
return saveComponent(bundle.name, bundle.source, {bundled: !!bundle.bundled});
|
|
728
|
+
}
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
- [ ] **Step 2: Smoke test round-trip**
|
|
732
|
+
|
|
733
|
+
```bash
|
|
734
|
+
node -e "
|
|
735
|
+
const m = await import('/home/darryl/src/js/domma-cms/server/services/components.js');
|
|
736
|
+
const src = '<template><div>x</div></template><props>{}</props><script>export default {};</script>';
|
|
737
|
+
await m.saveComponent('__rt', src);
|
|
738
|
+
const bundle = await m.exportBundle('__rt');
|
|
739
|
+
await m.deleteComponent('__rt');
|
|
740
|
+
await m.importBundle(bundle);
|
|
741
|
+
console.log('ok:', (await m.getComponent('__rt')).source === src);
|
|
742
|
+
await m.deleteComponent('__rt');
|
|
743
|
+
"
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
Expected: `ok: true`.
|
|
747
|
+
|
|
748
|
+
---
|
|
749
|
+
|
|
750
|
+
## Task 8b: Plugin hook - `registerComponent`
|
|
751
|
+
|
|
752
|
+
Spec Section 3.4 promises plugins can contribute read-only components. Minimal wiring:
|
|
753
|
+
|
|
754
|
+
**Files:**
|
|
755
|
+
- Modify: `server/services/hooks.js`
|
|
756
|
+
- Modify: `server/services/components.js`
|
|
757
|
+
|
|
758
|
+
- [ ] **Step 1: Add the hook registry entry in `hooks.js`**
|
|
759
|
+
|
|
760
|
+
```js
|
|
761
|
+
// Plugins call registerComponent({ name, source }) during plugin boot.
|
|
762
|
+
const _pluginComponents = new Map();
|
|
763
|
+
|
|
764
|
+
export function registerComponent({name, source}) {
|
|
765
|
+
if (typeof name !== 'string' || typeof source !== 'string') return;
|
|
766
|
+
_pluginComponents.set(name, {name, source, origin: 'plugin'});
|
|
767
|
+
}
|
|
768
|
+
export function getPluginComponents() {
|
|
769
|
+
return [..._pluginComponents.values()];
|
|
770
|
+
}
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
Export `registerComponent` from whatever `hooks.js` already exposes to plugins (it should follow the pattern of existing hook exports in that file).
|
|
774
|
+
|
|
775
|
+
- [ ] **Step 2: Merge plugin components into `listComponents()` in `components.js`**
|
|
776
|
+
|
|
777
|
+
After building the disk-backed entries, append plugin-contributed ones:
|
|
778
|
+
|
|
779
|
+
```js
|
|
780
|
+
import {getPluginComponents} from './hooks.js';
|
|
781
|
+
// …at the end of listComponents, before the sort:
|
|
782
|
+
for (const pc of getPluginComponents()) {
|
|
783
|
+
let props = {};
|
|
784
|
+
try { props = validatePropsJson(parseDmcSource(pc.source).props); } catch {}
|
|
785
|
+
entries.push({name: pc.name, size: pc.source.length, updatedAt: null, bundled: true, props, origin: 'plugin'});
|
|
786
|
+
}
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
- [ ] **Step 3: Route `getCompiledJs` to plugin sources when no file exists**
|
|
790
|
+
|
|
791
|
+
Inside `getCompiledJs`, before the `fs.stat` call, check the plugin map:
|
|
792
|
+
|
|
793
|
+
```js
|
|
794
|
+
const plugin = getPluginComponents().find(pc => pc.name === name);
|
|
795
|
+
if (plugin) {
|
|
796
|
+
const cached = _compileCache.get(name);
|
|
797
|
+
if (cached && cached.origin === 'plugin' && cached.sourceHash === plugin.source.length) return cached;
|
|
798
|
+
const {js, errors} = compileComponent(plugin.source, name);
|
|
799
|
+
const entry = {js, errors, origin: 'plugin', sourceHash: plugin.source.length, mtime: 0};
|
|
800
|
+
_compileCache.set(name, entry);
|
|
801
|
+
return entry;
|
|
802
|
+
}
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
- [ ] **Step 4: Block disk writes to plugin-provided names**
|
|
806
|
+
|
|
807
|
+
In `saveComponent` and `deleteComponent`, throw `CONFLICT` if a plugin owns the name:
|
|
808
|
+
|
|
809
|
+
```js
|
|
810
|
+
if (getPluginComponents().some(pc => pc.name === name)) {
|
|
811
|
+
const e = new Error('That name is registered by a plugin and cannot be edited from the admin.');
|
|
812
|
+
e.code = 'PLUGIN_OWNED';
|
|
813
|
+
throw e;
|
|
814
|
+
}
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
Handle `PLUGIN_OWNED` in the API as a 409.
|
|
818
|
+
|
|
819
|
+
- [ ] **Step 5: No explicit test - covered by smoke test in Task 25**
|
|
820
|
+
|
|
821
|
+
---
|
|
822
|
+
|
|
823
|
+
# PHASE 2 - Permissions and API
|
|
824
|
+
|
|
825
|
+
## Task 9: Add `components` resource to the permission registry
|
|
826
|
+
|
|
827
|
+
> **Correction vs spec:** the spec presented permissions as a `resource: {action: [roles]}` map. The actual `permissionRegistry.js` exports `REGISTRY` as an **array of descriptor objects** (see existing `blocks` entry at `permissionRegistry.js:38-50`). Role-to-action mapping lives elsewhere (in the role entries of `content/collections/user-types/`). This task only adds the descriptor so the resource is discoverable by the Roles UI and accepted by `requirePermission()`.
|
|
828
|
+
|
|
829
|
+
**Files:**
|
|
830
|
+
- Modify: `server/services/permissionRegistry.js`
|
|
831
|
+
|
|
832
|
+
- [ ] **Step 1: Locate the existing `blocks` entry**
|
|
833
|
+
|
|
834
|
+
Run: `grep -n "key: 'blocks'" server/services/permissionRegistry.js`
|
|
835
|
+
|
|
836
|
+
Expected: a single line around `permissionRegistry.js:39`.
|
|
837
|
+
|
|
838
|
+
- [ ] **Step 2: Add the `components` descriptor directly after `blocks`**
|
|
839
|
+
|
|
840
|
+
Insert a new entry inside the `REGISTRY` array, matching the existing descriptor shape exactly:
|
|
841
|
+
|
|
842
|
+
```js
|
|
843
|
+
{
|
|
844
|
+
key: 'components',
|
|
845
|
+
label: 'Components',
|
|
846
|
+
description: 'Manage reusable Domma components.',
|
|
847
|
+
icon: 'puzzle',
|
|
848
|
+
group: 'Content',
|
|
849
|
+
actions: [
|
|
850
|
+
{key: 'read', label: 'View', description: 'View components'},
|
|
851
|
+
{key: 'create', label: 'Create', description: 'Create new components'},
|
|
852
|
+
{key: 'update', label: 'Edit', description: 'Edit existing components'},
|
|
853
|
+
{key: 'delete', label: 'Delete', description: 'Delete components'}
|
|
854
|
+
]
|
|
855
|
+
},
|
|
856
|
+
```
|
|
857
|
+
|
|
858
|
+
- [ ] **Step 3: Set the default role grants**
|
|
859
|
+
|
|
860
|
+
Per the spec: super-admin gets all four actions; admin gets `read` only. These live in the seeded role entries - find where the `admin` and `super-admin` role entries are seeded (likely `server/services/userTypes.js` or `config/presets.json`) and add entries. If the seeding is already run, site owners can add the permissions manually via the Roles admin UI.
|
|
861
|
+
|
|
862
|
+
Run: `grep -rn "'blocks.read'\|blocks.read" server/ config/ | head -10`
|
|
863
|
+
|
|
864
|
+
Whatever pattern is used for `blocks.*` is what we mirror for `components.*`.
|
|
865
|
+
|
|
866
|
+
- [ ] **Step 4: Restart + smoke**
|
|
867
|
+
|
|
868
|
+
After Task 10 is done, verify with:
|
|
869
|
+
|
|
870
|
+
```bash
|
|
871
|
+
# Replace $TOKEN with an admin JWT.
|
|
872
|
+
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:4096/api/auth/permissions | grep -o 'components\.read'
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
Expected: one match.
|
|
876
|
+
|
|
877
|
+
---
|
|
878
|
+
|
|
879
|
+
## Task 10: API routes
|
|
880
|
+
|
|
881
|
+
**Files:**
|
|
882
|
+
- Create: `server/routes/api/components.js`
|
|
883
|
+
- Modify: `server/server.js` - register route
|
|
884
|
+
|
|
885
|
+
- [ ] **Step 1: Write the routes file**
|
|
886
|
+
|
|
887
|
+
Create `server/routes/api/components.js`:
|
|
888
|
+
|
|
889
|
+
```js
|
|
890
|
+
/**
|
|
891
|
+
* Components API
|
|
892
|
+
* CRUD + compilation + export/import for .dmc components.
|
|
893
|
+
*
|
|
894
|
+
* GET /api/components - list
|
|
895
|
+
* GET /api/components/:name - raw source + parsed props
|
|
896
|
+
* GET /api/components/:name.js - compiled JS module (public)
|
|
897
|
+
* POST /api/components/compile - transient compile for preview
|
|
898
|
+
* GET /api/components/:name/export - download .dmcomponent.json
|
|
899
|
+
* POST /api/components/import - import .dmcomponent.json
|
|
900
|
+
* PUT /api/components/:name - create/update (compiles first)
|
|
901
|
+
* DELETE /api/components/:name - delete
|
|
902
|
+
*/
|
|
903
|
+
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
904
|
+
import {
|
|
905
|
+
BUNDLE_FORMAT,
|
|
906
|
+
compileComponent,
|
|
907
|
+
deleteComponent,
|
|
908
|
+
exportBundle,
|
|
909
|
+
getComponent,
|
|
910
|
+
getCompiledJs,
|
|
911
|
+
importBundle,
|
|
912
|
+
listComponents,
|
|
913
|
+
saveComponent
|
|
914
|
+
} from '../../services/components.js';
|
|
915
|
+
|
|
916
|
+
export async function componentsRoutes(fastify) {
|
|
917
|
+
const canRead = {preHandler: [authenticate, requirePermission('components', 'read')]};
|
|
918
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('components', 'update')]};
|
|
919
|
+
const canDelete = {preHandler: [authenticate, requirePermission('components', 'delete')]};
|
|
920
|
+
|
|
921
|
+
// Public: compiled JS module (served to every page visitor).
|
|
922
|
+
fastify.get('/components/:name.js', async (request, reply) => {
|
|
923
|
+
const {name} = request.params;
|
|
924
|
+
try {
|
|
925
|
+
const {js, errors, mtime} = await getCompiledJs(name);
|
|
926
|
+
if (errors.length) return reply.status(500).send({error: 'Compile failed', errors});
|
|
927
|
+
reply.header('Content-Type', 'application/javascript; charset=utf-8');
|
|
928
|
+
reply.header('Cache-Control', 'public, max-age=60, must-revalidate');
|
|
929
|
+
reply.header('ETag', `"${name}-${mtime}"`);
|
|
930
|
+
return js;
|
|
931
|
+
} catch (err) {
|
|
932
|
+
if (err.code === 'INVALID_NAME') return reply.status(400).send({error: err.message});
|
|
933
|
+
if (err.code === 'ENOENT') return reply.status(404).send({error: 'Component not found'});
|
|
934
|
+
throw err;
|
|
935
|
+
}
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
fastify.get('/components', canRead, () => listComponents());
|
|
939
|
+
|
|
940
|
+
fastify.get('/components/:name', canRead, async (request, reply) => {
|
|
941
|
+
try { return await getComponent(request.params.name); }
|
|
942
|
+
catch (err) {
|
|
943
|
+
if (err.code === 'INVALID_NAME') return reply.status(400).send({error: err.message});
|
|
944
|
+
if (err.code === 'ENOENT') return reply.status(404).send({error: 'Component not found'});
|
|
945
|
+
throw err;
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
// Transient compile - used by the editor's live preview.
|
|
950
|
+
fastify.post('/components/compile', canUpdate, async (request, reply) => {
|
|
951
|
+
const {source, name} = request.body || {};
|
|
952
|
+
if (typeof source !== 'string' || typeof name !== 'string') {
|
|
953
|
+
return reply.status(400).send({error: 'source and name are required'});
|
|
954
|
+
}
|
|
955
|
+
return compileComponent(source, name);
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
fastify.get('/components/:name/export', canRead, async (request, reply) => {
|
|
959
|
+
const {name} = request.params;
|
|
960
|
+
try {
|
|
961
|
+
const bundle = await exportBundle(name);
|
|
962
|
+
reply.header('Content-Type', 'application/json; charset=utf-8');
|
|
963
|
+
reply.header('Content-Disposition', `attachment; filename="${name}.dmcomponent.json"`);
|
|
964
|
+
return JSON.stringify(bundle, null, 2);
|
|
965
|
+
} catch (err) {
|
|
966
|
+
if (err.code === 'INVALID_NAME') return reply.status(400).send({error: err.message});
|
|
967
|
+
if (err.code === 'ENOENT') return reply.status(404).send({error: 'Component not found'});
|
|
968
|
+
throw err;
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
fastify.post('/components/import', canUpdate, async (request, reply) => {
|
|
973
|
+
try { return await importBundle(request.body, {overwrite: !!request.body?.overwrite}); }
|
|
974
|
+
catch (err) {
|
|
975
|
+
if (err.code === 'CONFLICT') return reply.status(409).send({error: err.message, name: err.name});
|
|
976
|
+
if (err.code === 'INVALID_BUNDLE') return reply.status(400).send({error: err.message});
|
|
977
|
+
if (err.code === 'INVALID_NAME') return reply.status(400).send({error: err.message});
|
|
978
|
+
throw err;
|
|
979
|
+
}
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
fastify.put('/components/:name', canUpdate, async (request, reply) => {
|
|
983
|
+
const {name} = request.params;
|
|
984
|
+
const {source, bundled} = request.body || {};
|
|
985
|
+
if (typeof source !== 'string') return reply.status(400).send({error: 'source (string) is required'});
|
|
986
|
+
try {
|
|
987
|
+
const result = await saveComponent(name, source, {bundled: !!bundled});
|
|
988
|
+
if (!result.success) return reply.status(400).send({error: 'Compile failed', errors: result.errors});
|
|
989
|
+
return result;
|
|
990
|
+
} catch (err) {
|
|
991
|
+
if (err.code === 'INVALID_NAME') return reply.status(400).send({error: err.message});
|
|
992
|
+
throw err;
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
fastify.delete('/components/:name', canDelete, async (request, reply) => {
|
|
997
|
+
try { await deleteComponent(request.params.name); return reply.status(204).send(); }
|
|
998
|
+
catch (err) {
|
|
999
|
+
if (err.code === 'INVALID_NAME') return reply.status(400).send({error: err.message});
|
|
1000
|
+
if (err.code === 'ENOENT') return reply.status(404).send({error: 'Component not found'});
|
|
1001
|
+
throw err;
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
```
|
|
1006
|
+
|
|
1007
|
+
- [ ] **Step 2: Register the route in `server/server.js`**
|
|
1008
|
+
|
|
1009
|
+
Two insertions are needed, mirroring the existing `blocksRoutes` pattern:
|
|
1010
|
+
|
|
1011
|
+
- After line **243** (`const {blocksRoutes} = await import('./routes/api/blocks.js');`) add:
|
|
1012
|
+
```js
|
|
1013
|
+
const {componentsRoutes} = await import('./routes/api/components.js');
|
|
1014
|
+
```
|
|
1015
|
+
- After line **260** (`await app.register(blocksRoutes, {prefix: '/api'});`) add:
|
|
1016
|
+
```js
|
|
1017
|
+
await app.register(componentsRoutes, {prefix: '/api'});
|
|
1018
|
+
```
|
|
1019
|
+
|
|
1020
|
+
- [ ] **Step 3: Restart manually (user action) and smoke-test**
|
|
1021
|
+
|
|
1022
|
+
Tell the user to restart their dev server (`npm run dev`), then run:
|
|
1023
|
+
|
|
1024
|
+
```bash
|
|
1025
|
+
# Replace $TOKEN with an admin JWT from the admin SPA (S.get('auth_token') in the browser console).
|
|
1026
|
+
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:4096/api/components | head
|
|
1027
|
+
```
|
|
1028
|
+
|
|
1029
|
+
Expected: `[]` (empty list - no components yet).
|
|
1030
|
+
|
|
1031
|
+
---
|
|
1032
|
+
|
|
1033
|
+
# PHASE 3 - Seeded starter library
|
|
1034
|
+
|
|
1035
|
+
## Task 11: Write the five seed `.dmc` sources
|
|
1036
|
+
|
|
1037
|
+
**Files:**
|
|
1038
|
+
- Modify: `server/services/components.js`
|
|
1039
|
+
|
|
1040
|
+
- [ ] **Step 1: Add the `SEED_COMPONENTS` map at the bottom of `components.js`**
|
|
1041
|
+
|
|
1042
|
+
```js
|
|
1043
|
+
// ─── Seeded starter components ────────────────────────────────────────────
|
|
1044
|
+
|
|
1045
|
+
const SEED_COMPONENTS = {
|
|
1046
|
+
|
|
1047
|
+
counter: `
|
|
1048
|
+
<template>
|
|
1049
|
+
<div class="dm-counter">
|
|
1050
|
+
<div class="value">{{count}}</div>
|
|
1051
|
+
<div class="controls">
|
|
1052
|
+
<button data-action="dec">−</button>
|
|
1053
|
+
<button data-action="inc">+</button>
|
|
1054
|
+
{{#if showReset}}<button data-action="reset">Reset</button>{{/if}}
|
|
1055
|
+
</div>
|
|
1056
|
+
</div>
|
|
1057
|
+
</template>
|
|
1058
|
+
<props>
|
|
1059
|
+
{
|
|
1060
|
+
"initial": { "type": "number", "default": 0, "label": "Initial value" },
|
|
1061
|
+
"step": { "type": "number", "default": 1, "label": "Step size" },
|
|
1062
|
+
"showReset": { "type": "boolean", "default": false, "label": "Show reset button" }
|
|
1063
|
+
}
|
|
1064
|
+
</props>
|
|
1065
|
+
<script>
|
|
1066
|
+
export default {
|
|
1067
|
+
data() { return { count: this.props.initial }; },
|
|
1068
|
+
methods: {
|
|
1069
|
+
inc() { this.set({ count: this.data.count + this.props.step }); },
|
|
1070
|
+
dec() { this.set({ count: this.data.count - this.props.step }); },
|
|
1071
|
+
reset() { this.set({ count: this.props.initial }); }
|
|
1072
|
+
},
|
|
1073
|
+
onMount() {
|
|
1074
|
+
this.el.shadowRoot.addEventListener('click', (e) => {
|
|
1075
|
+
const action = e.target.dataset.action;
|
|
1076
|
+
if (action && this[action]) this[action]();
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
};
|
|
1080
|
+
</script>
|
|
1081
|
+
<style>
|
|
1082
|
+
.dm-counter { text-align: center; padding: 1rem; }
|
|
1083
|
+
.dm-counter .value { font-size: 2.5rem; font-weight: 700; margin-bottom: .5rem; }
|
|
1084
|
+
.dm-counter .controls button { margin: 0 .25rem; padding: .4rem .8rem; }
|
|
1085
|
+
</style>
|
|
1086
|
+
`,
|
|
1087
|
+
|
|
1088
|
+
'copy-button': `
|
|
1089
|
+
<template>
|
|
1090
|
+
<button class="dm-copy-btn" data-action="copy">{{label}}</button>
|
|
1091
|
+
</template>
|
|
1092
|
+
<props>
|
|
1093
|
+
{
|
|
1094
|
+
"text": { "type": "string", "default": "", "label": "Text to copy" },
|
|
1095
|
+
"label": { "type": "string", "default": "Copy", "label": "Button label" },
|
|
1096
|
+
"copiedLabel": { "type": "string", "default": "Copied!","label": "Confirmation label" }
|
|
1097
|
+
}
|
|
1098
|
+
</props>
|
|
1099
|
+
<script>
|
|
1100
|
+
export default {
|
|
1101
|
+
data() { return { label: this.props.label }; },
|
|
1102
|
+
methods: {
|
|
1103
|
+
async copy() {
|
|
1104
|
+
try {
|
|
1105
|
+
await navigator.clipboard.writeText(this.props.text);
|
|
1106
|
+
this.set({ label: this.props.copiedLabel });
|
|
1107
|
+
setTimeout(() => this.set({ label: this.props.label }), 1500);
|
|
1108
|
+
} catch {}
|
|
1109
|
+
}
|
|
1110
|
+
},
|
|
1111
|
+
onMount() {
|
|
1112
|
+
this.el.shadowRoot.addEventListener('click', (e) => {
|
|
1113
|
+
if (e.target.dataset.action === 'copy') this.copy();
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
};
|
|
1117
|
+
</script>
|
|
1118
|
+
<style>
|
|
1119
|
+
.dm-copy-btn { padding: .5rem 1rem; cursor: pointer; }
|
|
1120
|
+
</style>
|
|
1121
|
+
`,
|
|
1122
|
+
|
|
1123
|
+
'reveal-panel': `
|
|
1124
|
+
<template>
|
|
1125
|
+
<div class="dm-reveal">
|
|
1126
|
+
<button class="toggle" data-action="toggle">{{heading}}</button>
|
|
1127
|
+
{{#if open}}<div class="body"><slot></slot></div>{{/if}}
|
|
1128
|
+
</div>
|
|
1129
|
+
</template>
|
|
1130
|
+
<props>
|
|
1131
|
+
{
|
|
1132
|
+
"heading": { "type": "string", "default": "Show more", "label": "Toggle heading" },
|
|
1133
|
+
"openDefault": { "type": "boolean", "default": false, "label": "Open on load" }
|
|
1134
|
+
}
|
|
1135
|
+
</props>
|
|
1136
|
+
<script>
|
|
1137
|
+
export default {
|
|
1138
|
+
data() { return { open: this.props.openDefault }; },
|
|
1139
|
+
methods: { toggle() { this.set({ open: !this.data.open }); } },
|
|
1140
|
+
onMount() {
|
|
1141
|
+
this.el.shadowRoot.addEventListener('click', (e) => {
|
|
1142
|
+
if (e.target.dataset.action === 'toggle') this.toggle();
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
};
|
|
1146
|
+
</script>
|
|
1147
|
+
<style>
|
|
1148
|
+
.dm-reveal .toggle { background: none; border: none; font-weight: 600; cursor: pointer; }
|
|
1149
|
+
.dm-reveal .body { margin-top: .5rem; padding-top: .5rem; border-top: 1px solid currentColor; }
|
|
1150
|
+
</style>
|
|
1151
|
+
`,
|
|
1152
|
+
|
|
1153
|
+
'testimonial-card': `
|
|
1154
|
+
<template>
|
|
1155
|
+
<figure class="dm-testimonial">
|
|
1156
|
+
<blockquote class="quote">"{{quote}}"</blockquote>
|
|
1157
|
+
<figcaption class="author">
|
|
1158
|
+
{{#if avatar}}<img class="avatar" src="{{avatar}}" alt="">{{/if}}
|
|
1159
|
+
<div><div class="name">{{author}}</div><div class="role">{{role}}</div></div>
|
|
1160
|
+
</figcaption>
|
|
1161
|
+
</figure>
|
|
1162
|
+
</template>
|
|
1163
|
+
<props>
|
|
1164
|
+
{
|
|
1165
|
+
"quote": { "type": "string", "default": "", "label": "Quote" },
|
|
1166
|
+
"author": { "type": "string", "default": "", "label": "Author name" },
|
|
1167
|
+
"role": { "type": "string", "default": "", "label": "Author role or company" },
|
|
1168
|
+
"avatar": { "type": "string", "default": "", "label": "Avatar image URL" }
|
|
1169
|
+
}
|
|
1170
|
+
</props>
|
|
1171
|
+
<script>
|
|
1172
|
+
export default {};
|
|
1173
|
+
</script>
|
|
1174
|
+
<style>
|
|
1175
|
+
.dm-testimonial { margin: 0; padding: 1.25rem; border-radius: 8px; background: rgba(0,0,0,.04); }
|
|
1176
|
+
.dm-testimonial .quote { font-size: 1.05rem; font-style: italic; margin: 0 0 .75rem; }
|
|
1177
|
+
.dm-testimonial .author { display: flex; align-items: center; gap: .75rem; font-size: .85rem; }
|
|
1178
|
+
.dm-testimonial .avatar { width: 40px; height: 40px; border-radius: 50%; object-fit: cover; }
|
|
1179
|
+
.dm-testimonial .name { font-weight: 600; }
|
|
1180
|
+
.dm-testimonial .role { opacity: .7; }
|
|
1181
|
+
</style>
|
|
1182
|
+
`,
|
|
1183
|
+
|
|
1184
|
+
'countdown-timer': `
|
|
1185
|
+
<template>
|
|
1186
|
+
<div class="dm-countdown-c">
|
|
1187
|
+
<div class="part"><span class="n">{{days}}</span><span class="l">days</span></div>
|
|
1188
|
+
<div class="part"><span class="n">{{hours}}</span><span class="l">hrs</span></div>
|
|
1189
|
+
<div class="part"><span class="n">{{minutes}}</span><span class="l">min</span></div>
|
|
1190
|
+
<div class="part"><span class="n">{{seconds}}</span><span class="l">sec</span></div>
|
|
1191
|
+
</div>
|
|
1192
|
+
</template>
|
|
1193
|
+
<props>
|
|
1194
|
+
{
|
|
1195
|
+
"target": { "type": "string", "default": "", "label": "Target date (ISO 8601)" }
|
|
1196
|
+
}
|
|
1197
|
+
</props>
|
|
1198
|
+
<script>
|
|
1199
|
+
export default {
|
|
1200
|
+
data() { return { days: 0, hours: 0, minutes: 0, seconds: 0 }; },
|
|
1201
|
+
_timer: null,
|
|
1202
|
+
methods: {
|
|
1203
|
+
tick() {
|
|
1204
|
+
const t = new Date(this.props.target).getTime() - Date.now();
|
|
1205
|
+
if (isNaN(t) || t <= 0) { this.set({ days: 0, hours: 0, minutes: 0, seconds: 0 }); return; }
|
|
1206
|
+
this.set({
|
|
1207
|
+
days: Math.floor(t / 86400000),
|
|
1208
|
+
hours: Math.floor((t % 86400000) / 3600000),
|
|
1209
|
+
minutes: Math.floor((t % 3600000) / 60000),
|
|
1210
|
+
seconds: Math.floor((t % 60000) / 1000)
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
},
|
|
1214
|
+
onMount() { this.tick(); this._timer = setInterval(() => this.tick(), 1000); },
|
|
1215
|
+
onUnmount() { if (this._timer) clearInterval(this._timer); }
|
|
1216
|
+
};
|
|
1217
|
+
</script>
|
|
1218
|
+
<style>
|
|
1219
|
+
.dm-countdown-c { display: flex; gap: 1rem; justify-content: center; }
|
|
1220
|
+
.dm-countdown-c .part { display: flex; flex-direction: column; align-items: center; }
|
|
1221
|
+
.dm-countdown-c .n { font-size: 1.8rem; font-weight: 700; }
|
|
1222
|
+
.dm-countdown-c .l { font-size: .7rem; text-transform: uppercase; opacity: .7; }
|
|
1223
|
+
</style>
|
|
1224
|
+
`
|
|
1225
|
+
};
|
|
1226
|
+
```
|
|
1227
|
+
|
|
1228
|
+
---
|
|
1229
|
+
|
|
1230
|
+
## Task 12: `seedDefaultComponents()` and wire into startup
|
|
1231
|
+
|
|
1232
|
+
**Files:**
|
|
1233
|
+
- Modify: `server/services/components.js`
|
|
1234
|
+
- Modify: `server/server.js`
|
|
1235
|
+
|
|
1236
|
+
- [ ] **Step 1: Add the seeder**
|
|
1237
|
+
|
|
1238
|
+
```js
|
|
1239
|
+
/**
|
|
1240
|
+
* Seed starter components into content/components/.
|
|
1241
|
+
* Never overwrites existing files - user customisations are preserved.
|
|
1242
|
+
* All seeded components are marked `bundled: true` via .meta.json.
|
|
1243
|
+
*/
|
|
1244
|
+
export async function seedDefaultComponents() {
|
|
1245
|
+
await fs.mkdir(COMPONENTS_DIR, {recursive: true});
|
|
1246
|
+
const seeded = [];
|
|
1247
|
+
for (const [name, source] of Object.entries(SEED_COMPONENTS)) {
|
|
1248
|
+
const fp = componentFilePath(name);
|
|
1249
|
+
try { await fs.access(fp); }
|
|
1250
|
+
catch {
|
|
1251
|
+
await fs.writeFile(fp, source.trim() + '\n', 'utf8');
|
|
1252
|
+
await fs.writeFile(componentMetaPath(name), JSON.stringify({bundled: true}, null, 2) + '\n', 'utf8');
|
|
1253
|
+
seeded.push(name);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
if (seeded.length) console.log(`[components] Seeded default components: ${seeded.join(', ')}`);
|
|
1257
|
+
}
|
|
1258
|
+
```
|
|
1259
|
+
|
|
1260
|
+
- [ ] **Step 2: Call it during startup in `server/server.js`**
|
|
1261
|
+
|
|
1262
|
+
Two insertions, mirroring the existing `seedDefaultBlocks()` pattern:
|
|
1263
|
+
|
|
1264
|
+
- After line **24** (`import {seedDefaultBlocks} from './services/blocks.js';`) add:
|
|
1265
|
+
```js
|
|
1266
|
+
import {seedDefaultComponents} from './services/components.js';
|
|
1267
|
+
```
|
|
1268
|
+
- After line **169** (`await seedDefaultBlocks();`) add:
|
|
1269
|
+
```js
|
|
1270
|
+
await seedDefaultComponents();
|
|
1271
|
+
```
|
|
1272
|
+
|
|
1273
|
+
- [ ] **Step 3: Restart + verify**
|
|
1274
|
+
|
|
1275
|
+
After restart:
|
|
1276
|
+
|
|
1277
|
+
```bash
|
|
1278
|
+
ls /home/darryl/src/js/domma-cms/content/components/
|
|
1279
|
+
```
|
|
1280
|
+
|
|
1281
|
+
Expected: five `.dmc` + five `.meta.json` files.
|
|
1282
|
+
|
|
1283
|
+
```bash
|
|
1284
|
+
curl -s http://localhost:4096/api/components/counter.js | head -n 3
|
|
1285
|
+
```
|
|
1286
|
+
|
|
1287
|
+
Expected: first line begins `// Compiled from counter.dmc`.
|
|
1288
|
+
|
|
1289
|
+
---
|
|
1290
|
+
|
|
1291
|
+
# PHASE 4 - Renderer integration (public site)
|
|
1292
|
+
|
|
1293
|
+
## Task 13: `collectComponents()` + shortcode rewrite in `markdown.js`
|
|
1294
|
+
|
|
1295
|
+
**Files:**
|
|
1296
|
+
- Modify: `server/services/markdown.js`
|
|
1297
|
+
|
|
1298
|
+
- [ ] **Step 1: Locate the shortcode pipeline**
|
|
1299
|
+
|
|
1300
|
+
Run: `grep -n "function process\|export function parseMarkdown\|export function renderMarkdown" server/services/markdown.js | head -20`
|
|
1301
|
+
|
|
1302
|
+
- [ ] **Step 2: Add the `collectAndRewriteComponents` processor**
|
|
1303
|
+
|
|
1304
|
+
Add near the other `process*` functions:
|
|
1305
|
+
|
|
1306
|
+
```js
|
|
1307
|
+
/**
|
|
1308
|
+
* Process component references in two injection forms:
|
|
1309
|
+
* 1. [component name="x" attr="y" /] - shortcode
|
|
1310
|
+
* 2. <dm-x attr="y">…</dm-x> - raw custom-element tag
|
|
1311
|
+
*
|
|
1312
|
+
* Rewrites form 1 to form 2 in-place, and collects every <dm-*> tag
|
|
1313
|
+
* name encountered into a de-duplicated Set.
|
|
1314
|
+
*
|
|
1315
|
+
* @param {string} source
|
|
1316
|
+
* @returns {{output: string, used: Set<string>}}
|
|
1317
|
+
*/
|
|
1318
|
+
export function collectAndRewriteComponents(source) {
|
|
1319
|
+
const used = new Set();
|
|
1320
|
+
|
|
1321
|
+
// Rewrite shortcode to raw tag. [component name="x" a="1" b="2" /] → <dm-x a="1" b="2"></dm-x>
|
|
1322
|
+
const rewritten = source.replace(
|
|
1323
|
+
/\[component\s+([^\]]+?)\s*\/?\]/g,
|
|
1324
|
+
(_, attrs) => {
|
|
1325
|
+
const nameMatch = attrs.match(/name\s*=\s*["']([a-z][a-z0-9-]*)["']/);
|
|
1326
|
+
if (!nameMatch) return ''; // silently drop malformed shortcodes
|
|
1327
|
+
const name = nameMatch[1];
|
|
1328
|
+
const otherAttrs = attrs.replace(/name\s*=\s*["'][^"']+["']/, '').trim();
|
|
1329
|
+
return `<dm-${name} ${otherAttrs}></dm-${name}>`;
|
|
1330
|
+
}
|
|
1331
|
+
);
|
|
1332
|
+
|
|
1333
|
+
// Collect every <dm-name … tag from the rewritten source.
|
|
1334
|
+
for (const m of rewritten.matchAll(/<dm-([a-z][a-z0-9-]*)/g)) {
|
|
1335
|
+
used.add(m[1]);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
return {output: rewritten, used};
|
|
1339
|
+
}
|
|
1340
|
+
```
|
|
1341
|
+
|
|
1342
|
+
- [ ] **Step 3: Wire it into the existing pipeline**
|
|
1343
|
+
|
|
1344
|
+
`parseMarkdown()` in `markdown.js` currently returns `{data, content, html}`. The renderer calls it and then assembles the page shell. To get `used` to the renderer:
|
|
1345
|
+
|
|
1346
|
+
1. Inside `parseMarkdown()`, call `collectAndRewriteComponents(raw)` on the Markdown body **before** the other `process*()` passes (so shortcodes are rewritten to `<dm-*>` tags that downstream passes see). Capture the output and `used` set.
|
|
1347
|
+
2. Feed `output` into the rest of the pipeline instead of the raw source.
|
|
1348
|
+
3. **Return `usedComponents: [...used]` as a fourth field** from `parseMarkdown()`:
|
|
1349
|
+
```js
|
|
1350
|
+
return {data, content, html, usedComponents: [...used]};
|
|
1351
|
+
```
|
|
1352
|
+
4. In `renderer.js`'s `renderPage()`, the parsed page object will now carry `usedComponents`. Read it directly there - no mutation of a shared page object is required.
|
|
1353
|
+
|
|
1354
|
+
- [ ] **Step 4: Test it on an isolated Markdown string**
|
|
1355
|
+
|
|
1356
|
+
```bash
|
|
1357
|
+
node -e "
|
|
1358
|
+
const m = await import('/home/darryl/src/js/domma-cms/server/services/markdown.js');
|
|
1359
|
+
const res = m.collectAndRewriteComponents('Hello [component name=\"counter\" initial=\"5\" /] and <dm-pricing-table /> end.');
|
|
1360
|
+
console.log(res.output);
|
|
1361
|
+
console.log([...res.used]);
|
|
1362
|
+
"
|
|
1363
|
+
```
|
|
1364
|
+
|
|
1365
|
+
Expected output:
|
|
1366
|
+
```
|
|
1367
|
+
Hello <dm-counter initial="5"></dm-counter> and <dm-pricing-table /> end.
|
|
1368
|
+
[ 'counter', 'pricing-table' ]
|
|
1369
|
+
```
|
|
1370
|
+
|
|
1371
|
+
---
|
|
1372
|
+
|
|
1373
|
+
## Task 14: `sanitize-html` config - `dm-*` allowlist + attribute pass-through
|
|
1374
|
+
|
|
1375
|
+
> **Correction vs spec:** the spec referred to DOMPurify but this project uses `sanitize-html` (see `server/services/markdown.js:7` and the sanitiser block at `markdown.js:2715-2748`). All DOMPurify-specific config keys (`ADD_TAGS`, `CUSTOM_ELEMENT_HANDLING`, `uponSanitizeAttribute`) have been translated to their `sanitize-html` equivalents below.
|
|
1376
|
+
|
|
1377
|
+
**Files:**
|
|
1378
|
+
- Modify: `server/services/markdown.js` (sanitiser block near line 2715)
|
|
1379
|
+
|
|
1380
|
+
- [ ] **Step 1: Add a helper that resolves the live set of allowed `dm-*` tag names**
|
|
1381
|
+
|
|
1382
|
+
At the top of `server/services/markdown.js`, add an import:
|
|
1383
|
+
|
|
1384
|
+
```js
|
|
1385
|
+
import {listComponents} from './components.js';
|
|
1386
|
+
```
|
|
1387
|
+
|
|
1388
|
+
And near the other top-level helpers add a small cache:
|
|
1389
|
+
|
|
1390
|
+
```js
|
|
1391
|
+
// Cache of registered dm-* tag names. Rebuilt by `refreshComponentTagAllowlist()`
|
|
1392
|
+
// on component create/delete so the sanitiser stays in sync without server restarts.
|
|
1393
|
+
let _dmTagAllowlist = [];
|
|
1394
|
+
let _dmTagAllowlistReady = false;
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* Refresh the in-memory list of allowed dm-* custom-element tag names.
|
|
1398
|
+
* Called once on startup, and by the components service after mutation.
|
|
1399
|
+
*/
|
|
1400
|
+
export async function refreshComponentTagAllowlist() {
|
|
1401
|
+
try {
|
|
1402
|
+
const entries = await listComponents();
|
|
1403
|
+
_dmTagAllowlist = entries.map(c => `dm-${c.name}`);
|
|
1404
|
+
_dmTagAllowlistReady = true;
|
|
1405
|
+
} catch {
|
|
1406
|
+
_dmTagAllowlist = [];
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
```
|
|
1410
|
+
|
|
1411
|
+
- [ ] **Step 2: Extend the sanitiser config at `markdown.js:2720-2747`**
|
|
1412
|
+
|
|
1413
|
+
Edit the existing `sanitizeHtml(rendered, { … })` call:
|
|
1414
|
+
|
|
1415
|
+
```js
|
|
1416
|
+
const sanitized = sanitizeHtml(rendered, {
|
|
1417
|
+
allowVulnerableTags: true,
|
|
1418
|
+
allowedTags: sanitizeHtml.defaults.allowedTags.concat([
|
|
1419
|
+
'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
1420
|
+
'form', 'input', 'textarea', 'select', 'option', 'optgroup',
|
|
1421
|
+
'button', 'label', 'fieldset', 'legend',
|
|
1422
|
+
'style',
|
|
1423
|
+
...extensions.tags,
|
|
1424
|
+
..._dmTagAllowlist // ← new: dm-<name> for every registered component
|
|
1425
|
+
]),
|
|
1426
|
+
allowedAttributes: {
|
|
1427
|
+
...sanitizeHtml.defaults.allowedAttributes,
|
|
1428
|
+
'*': ['class', 'id', 'style', 'data-*'],
|
|
1429
|
+
img: [/* existing … */],
|
|
1430
|
+
form: [/* existing … */],
|
|
1431
|
+
input: [/* existing … */],
|
|
1432
|
+
textarea: [/* existing … */],
|
|
1433
|
+
select: [/* existing … */],
|
|
1434
|
+
option: [/* existing … */],
|
|
1435
|
+
optgroup: [/* existing … */],
|
|
1436
|
+
button: [/* existing … */],
|
|
1437
|
+
label: [/* existing … */],
|
|
1438
|
+
fieldset: [/* existing … */],
|
|
1439
|
+
// Allow any kebab-case attribute on every registered dm-* tag.
|
|
1440
|
+
// Props are author-defined; enumerating them at sanitise time has no
|
|
1441
|
+
// security benefit. On* handlers and javascript: URLs are still stripped
|
|
1442
|
+
// by sanitize-html's defaults (globally, via the '*' default).
|
|
1443
|
+
..._dmTagAllowlist.reduce((acc, tag) => {
|
|
1444
|
+
acc[tag] = ['*'];
|
|
1445
|
+
return acc;
|
|
1446
|
+
}, {}),
|
|
1447
|
+
...extensions.attributes
|
|
1448
|
+
}
|
|
1449
|
+
});
|
|
1450
|
+
```
|
|
1451
|
+
|
|
1452
|
+
The key addition is two lines: `..._dmTagAllowlist` under `allowedTags`, and the `reduce(...)` under `allowedAttributes` that maps every registered `dm-<name>` tag to `['*']` (all attributes allowed). The two existing author-level trust boundaries (admins write components, admins write pages) mean this does not introduce a new XSS surface.
|
|
1453
|
+
|
|
1454
|
+
- [ ] **Step 3: Call `refreshComponentTagAllowlist()` during startup**
|
|
1455
|
+
|
|
1456
|
+
In `server/server.js`, directly **after** the `await seedDefaultComponents()` line from Task 12 Step 2, add:
|
|
1457
|
+
|
|
1458
|
+
```js
|
|
1459
|
+
import {refreshComponentTagAllowlist} from './services/markdown.js';
|
|
1460
|
+
// …
|
|
1461
|
+
await refreshComponentTagAllowlist();
|
|
1462
|
+
```
|
|
1463
|
+
|
|
1464
|
+
- [ ] **Step 4: Invalidate the allowlist on component mutations**
|
|
1465
|
+
|
|
1466
|
+
In `server/services/components.js`, import the refresh function lazily (at the top of the file) and call it in `saveComponent()` and `deleteComponent()`:
|
|
1467
|
+
|
|
1468
|
+
```js
|
|
1469
|
+
// Near the top of components.js:
|
|
1470
|
+
import {refreshComponentTagAllowlist} from './markdown.js';
|
|
1471
|
+
|
|
1472
|
+
// In saveComponent, after invalidateCache(name):
|
|
1473
|
+
await refreshComponentTagAllowlist();
|
|
1474
|
+
|
|
1475
|
+
// In deleteComponent, after invalidateCache(name):
|
|
1476
|
+
await refreshComponentTagAllowlist();
|
|
1477
|
+
```
|
|
1478
|
+
|
|
1479
|
+
Watch for circular import: `markdown.js` already imports from `components.js` (Step 1) and now `components.js` imports from `markdown.js`. Node's ESM handles circular imports when the reference is only used lazily at call time - verify by restarting the server. If the circular causes a `TDZ` error, move the helper to a new `server/services/sanitiser.js` module that both sides import from.
|
|
1480
|
+
|
|
1481
|
+
- [ ] **Step 5: Verify via round-trip**
|
|
1482
|
+
|
|
1483
|
+
After restarting:
|
|
1484
|
+
|
|
1485
|
+
```bash
|
|
1486
|
+
node -e "
|
|
1487
|
+
const m = await import('/home/darryl/src/js/domma-cms/server/services/markdown.js');
|
|
1488
|
+
await m.refreshComponentTagAllowlist();
|
|
1489
|
+
const {html} = await m.renderMarkdown('<dm-counter initial=\"5\"></dm-counter>');
|
|
1490
|
+
console.log(html.includes('<dm-counter') ? 'OK' : 'STRIPPED');
|
|
1491
|
+
"
|
|
1492
|
+
```
|
|
1493
|
+
|
|
1494
|
+
Expected: `OK`.
|
|
1495
|
+
|
|
1496
|
+
---
|
|
1497
|
+
|
|
1498
|
+
## Task 15: Per-page script injection in `renderer.js`
|
|
1499
|
+
|
|
1500
|
+
**Files:**
|
|
1501
|
+
- Modify: `server/services/renderer.js`
|
|
1502
|
+
|
|
1503
|
+
- [ ] **Step 1: Locate the `bodyEndInject` assembly**
|
|
1504
|
+
|
|
1505
|
+
Run: `grep -n "bodyEndInject" server/services/renderer.js`
|
|
1506
|
+
|
|
1507
|
+
- [ ] **Step 2: Build the per-page component scripts list**
|
|
1508
|
+
|
|
1509
|
+
At the point where `bodyEndInject` is constructed, add:
|
|
1510
|
+
|
|
1511
|
+
```js
|
|
1512
|
+
const componentScripts = (page.usedComponents || [])
|
|
1513
|
+
.map(n => `<script type="module" src="/api/components/${n}.js"></script>`)
|
|
1514
|
+
.join('\n');
|
|
1515
|
+
```
|
|
1516
|
+
|
|
1517
|
+
(Per Task 13 Step 3, `parseMarkdown()` now returns `usedComponents` as part of the page object.)
|
|
1518
|
+
|
|
1519
|
+
- [ ] **Step 3: Insert into `bodyEndInject` at the correct position**
|
|
1520
|
+
|
|
1521
|
+
The existing array at `renderer.js:157-161` is:
|
|
1522
|
+
|
|
1523
|
+
```js
|
|
1524
|
+
bodyEndInject: [
|
|
1525
|
+
injection.bodyEnd,
|
|
1526
|
+
site.backToTop?.enabled ? '<script src="/public/js/btt.js"></script>' : '',
|
|
1527
|
+
site.cookieConsent?.enabled ? '<script src="/public/js/cookie-consent.js"></script>' : ''
|
|
1528
|
+
].filter(Boolean).join('\n'),
|
|
1529
|
+
```
|
|
1530
|
+
|
|
1531
|
+
Add `componentScripts` as a new entry at the **end** of the array (so component scripts load after `btt.js` / `cookie-consent.js` - both small and synchronous, no blocking concern):
|
|
1532
|
+
|
|
1533
|
+
```js
|
|
1534
|
+
bodyEndInject: [
|
|
1535
|
+
injection.bodyEnd,
|
|
1536
|
+
site.backToTop?.enabled ? '<script src="/public/js/btt.js"></script>' : '',
|
|
1537
|
+
site.cookieConsent?.enabled ? '<script src="/public/js/cookie-consent.js"></script>' : '',
|
|
1538
|
+
componentScripts // ← new
|
|
1539
|
+
].filter(Boolean).join('\n'),
|
|
1540
|
+
```
|
|
1541
|
+
|
|
1542
|
+
Apply the same edit to the `renderPreview()` function further down the file (it has an identical `bodyEndInject` assembly at a separate line range - find via `grep -n "bodyEndInject" server/services/renderer.js`).
|
|
1543
|
+
|
|
1544
|
+
- [ ] **Step 4: Verify on a real page**
|
|
1545
|
+
|
|
1546
|
+
Pick an existing page file and add a component reference:
|
|
1547
|
+
|
|
1548
|
+
```bash
|
|
1549
|
+
echo '[component name="counter" /]' >> /home/darryl/src/js/domma-cms/content/pages/index.md
|
|
1550
|
+
curl -s http://localhost:4096/ | grep -o '<script type="module" src="/api/components/counter.js"></script>'
|
|
1551
|
+
```
|
|
1552
|
+
|
|
1553
|
+
Expected: one match. Then revert the `index.md` change.
|
|
1554
|
+
|
|
1555
|
+
---
|
|
1556
|
+
|
|
1557
|
+
## Task 16: Flash-prevention CSS
|
|
1558
|
+
|
|
1559
|
+
**Files:**
|
|
1560
|
+
- Modify: `public/css/site.css`
|
|
1561
|
+
|
|
1562
|
+
- [ ] **Step 1: Append the rule**
|
|
1563
|
+
|
|
1564
|
+
At the bottom of `public/css/site.css`:
|
|
1565
|
+
|
|
1566
|
+
```css
|
|
1567
|
+
/* Hide custom elements until their defining module has upgraded them. */
|
|
1568
|
+
:not(:defined) { visibility: hidden; }
|
|
1569
|
+
```
|
|
1570
|
+
|
|
1571
|
+
- [ ] **Step 2: Scope it if a blanket `:not(:defined)` causes issues on built-in browsers that haven't yet defined some Domma element**
|
|
1572
|
+
|
|
1573
|
+
If the blanket rule proves too broad, narrow to `dm-*` via an attribute selector chain:
|
|
1574
|
+
|
|
1575
|
+
```css
|
|
1576
|
+
[class*=""]:is(:not(:defined)) { visibility: hidden; }
|
|
1577
|
+
```
|
|
1578
|
+
|
|
1579
|
+
(Only apply Step 2 if testing surfaces a problem - otherwise skip.)
|
|
1580
|
+
|
|
1581
|
+
- [ ] **Step 3: Verify on the public page**
|
|
1582
|
+
|
|
1583
|
+
Reload a page containing a `<dm-counter>`. Expected: no flash of unstyled tag text before the component upgrades.
|
|
1584
|
+
|
|
1585
|
+
---
|
|
1586
|
+
|
|
1587
|
+
# PHASE 5 - Admin preview iframe bootstrap
|
|
1588
|
+
|
|
1589
|
+
## Task 17: `component-preview.html`
|
|
1590
|
+
|
|
1591
|
+
**Files:**
|
|
1592
|
+
- Create: `admin/preview/component-preview.html`
|
|
1593
|
+
- Modify: `server/server.js` - register static serve path (if not already covered)
|
|
1594
|
+
|
|
1595
|
+
- [ ] **Step 1: Write the bootstrap**
|
|
1596
|
+
|
|
1597
|
+
```html
|
|
1598
|
+
<!DOCTYPE html>
|
|
1599
|
+
<html lang="en-GB">
|
|
1600
|
+
<head>
|
|
1601
|
+
<meta charset="UTF-8">
|
|
1602
|
+
<title>Component Preview</title>
|
|
1603
|
+
<link rel="stylesheet" href="/dist/domma/domma.css">
|
|
1604
|
+
<link rel="stylesheet" href="/dist/domma/elements.css">
|
|
1605
|
+
<link rel="stylesheet" href="/dist/domma/themes/domma-themes.css">
|
|
1606
|
+
<style>
|
|
1607
|
+
body { margin: 0; padding: 1rem; }
|
|
1608
|
+
#mount { display: block; }
|
|
1609
|
+
#err { color: crimson; font-family: monospace; white-space: pre-wrap; }
|
|
1610
|
+
</style>
|
|
1611
|
+
<script src="/dist/domma/domma.min.js"></script>
|
|
1612
|
+
</head>
|
|
1613
|
+
<body class="dm-theme-charcoal-dark">
|
|
1614
|
+
<div id="mount"></div>
|
|
1615
|
+
<pre id="err" hidden></pre>
|
|
1616
|
+
<script>
|
|
1617
|
+
let currentEl = null;
|
|
1618
|
+
|
|
1619
|
+
function showError(msg) {
|
|
1620
|
+
const err = document.getElementById('err');
|
|
1621
|
+
err.hidden = false;
|
|
1622
|
+
err.textContent = msg;
|
|
1623
|
+
parent.postMessage({type: 'error', message: msg}, '*');
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
function clearMount() {
|
|
1627
|
+
const mount = document.getElementById('mount');
|
|
1628
|
+
while (mount.firstChild) mount.removeChild(mount.firstChild);
|
|
1629
|
+
document.getElementById('err').hidden = true;
|
|
1630
|
+
currentEl = null;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
async function mount({compiledJs, tagName, props}) {
|
|
1634
|
+
clearMount();
|
|
1635
|
+
try {
|
|
1636
|
+
// Execute the compiled module - it calls Domma.component(...) at top level.
|
|
1637
|
+
const blob = new Blob([compiledJs], {type: 'application/javascript'});
|
|
1638
|
+
const url = URL.createObjectURL(blob);
|
|
1639
|
+
await import(url);
|
|
1640
|
+
URL.revokeObjectURL(url);
|
|
1641
|
+
|
|
1642
|
+
currentEl = document.createElement(tagName);
|
|
1643
|
+
for (const [k, v] of Object.entries(props || {})) {
|
|
1644
|
+
const attr = k.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
1645
|
+
currentEl.setAttribute(attr, String(v));
|
|
1646
|
+
}
|
|
1647
|
+
document.getElementById('mount').appendChild(currentEl);
|
|
1648
|
+
parent.postMessage({type: 'ready', height: document.body.scrollHeight}, '*');
|
|
1649
|
+
} catch (e) {
|
|
1650
|
+
showError(e.stack || e.message);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
function updateProps(props) {
|
|
1655
|
+
if (!currentEl) return;
|
|
1656
|
+
for (const [k, v] of Object.entries(props || {})) {
|
|
1657
|
+
const attr = k.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
1658
|
+
currentEl.setAttribute(attr, String(v));
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
window.addEventListener('message', (e) => {
|
|
1663
|
+
const msg = e.data || {};
|
|
1664
|
+
if (msg.type === 'mount') mount(msg.payload);
|
|
1665
|
+
else if (msg.type === 'update') updateProps(msg.payload?.props);
|
|
1666
|
+
else if (msg.type === 'remount') mount(msg.payload);
|
|
1667
|
+
});
|
|
1668
|
+
|
|
1669
|
+
new ResizeObserver(() => {
|
|
1670
|
+
parent.postMessage({type: 'resize', height: document.body.scrollHeight}, '*');
|
|
1671
|
+
}).observe(document.body);
|
|
1672
|
+
</script>
|
|
1673
|
+
</body>
|
|
1674
|
+
</html>
|
|
1675
|
+
```
|
|
1676
|
+
|
|
1677
|
+
- [ ] **Step 2: Ensure `/admin/preview/` is served statically**
|
|
1678
|
+
|
|
1679
|
+
Check `server/server.js` - the admin static serve may already cover `/admin/*`. If not, add a static registration mirroring the pattern used for `/admin`.
|
|
1680
|
+
|
|
1681
|
+
- [ ] **Step 3: Smoke test it loads**
|
|
1682
|
+
|
|
1683
|
+
```bash
|
|
1684
|
+
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4096/admin/preview/component-preview.html
|
|
1685
|
+
```
|
|
1686
|
+
|
|
1687
|
+
Expected: `200`.
|
|
1688
|
+
|
|
1689
|
+
---
|
|
1690
|
+
|
|
1691
|
+
# PHASE 6 - Admin list view
|
|
1692
|
+
|
|
1693
|
+
## Task 18: `api.components` helpers in `admin/js/api.js`
|
|
1694
|
+
|
|
1695
|
+
**Files:**
|
|
1696
|
+
- Modify: `admin/js/api.js`
|
|
1697
|
+
|
|
1698
|
+
- [ ] **Step 1: Locate existing `api.blocks` definition**
|
|
1699
|
+
|
|
1700
|
+
Run: `grep -n "blocks" admin/js/api.js | head -10`
|
|
1701
|
+
|
|
1702
|
+
- [ ] **Step 2: Add `api.components` mirroring the blocks pattern**
|
|
1703
|
+
|
|
1704
|
+
The existing `api.blocks` sub-object lives at `admin/js/api.js:305-360` and uses the module-level `apiRequest()` helper plus raw `fetch()` for the file-download / file-upload paths (so Bearer auth is carried). Mirror it exactly - add inside the same exported `api` object, directly after the `blocks` key:
|
|
1705
|
+
|
|
1706
|
+
```js
|
|
1707
|
+
components: {
|
|
1708
|
+
list: () => apiRequest('/components', {method: 'GET'}),
|
|
1709
|
+
get: (name) => apiRequest(`/components/${encodeURIComponent(name)}`, {method: 'GET'}),
|
|
1710
|
+
compile: (name, source) => apiRequest('/components/compile', {
|
|
1711
|
+
method: 'POST',
|
|
1712
|
+
body: JSON.stringify({name, source})
|
|
1713
|
+
}),
|
|
1714
|
+
put: (name, data) => apiRequest(`/components/${encodeURIComponent(name)}`, {
|
|
1715
|
+
method: 'PUT',
|
|
1716
|
+
body: JSON.stringify(data)
|
|
1717
|
+
}),
|
|
1718
|
+
delete: (name) => apiRequest(`/components/${encodeURIComponent(name)}`, {method: 'DELETE'}),
|
|
1719
|
+
|
|
1720
|
+
/** See api.blocks.exportBundle for the rationale. */
|
|
1721
|
+
async exportBundle(name) {
|
|
1722
|
+
const token = getToken();
|
|
1723
|
+
const res = await fetch(`${BASE}/components/${encodeURIComponent(name)}/export`, {
|
|
1724
|
+
headers: token ? {Authorization: `Bearer ${token}`} : {}
|
|
1725
|
+
});
|
|
1726
|
+
if (!res.ok) {
|
|
1727
|
+
const err = await res.json().catch(() => ({}));
|
|
1728
|
+
throw new Error(err.error || `Export failed (${res.status})`);
|
|
1729
|
+
}
|
|
1730
|
+
const blob = await res.blob();
|
|
1731
|
+
const url = URL.createObjectURL(blob);
|
|
1732
|
+
const a = document.createElement('a');
|
|
1733
|
+
a.href = url;
|
|
1734
|
+
a.download = `${name}.dmcomponent.json`;
|
|
1735
|
+
document.body.appendChild(a);
|
|
1736
|
+
a.click();
|
|
1737
|
+
a.remove();
|
|
1738
|
+
URL.revokeObjectURL(url);
|
|
1739
|
+
},
|
|
1740
|
+
|
|
1741
|
+
async importBundle(bundle, {overwrite = false} = {}) {
|
|
1742
|
+
const res = await fetch(`${BASE}/components/import`, {
|
|
1743
|
+
method: 'POST',
|
|
1744
|
+
headers: {
|
|
1745
|
+
'Content-Type': 'application/json',
|
|
1746
|
+
...(getToken() ? {Authorization: `Bearer ${getToken()}`} : {})
|
|
1747
|
+
},
|
|
1748
|
+
body: JSON.stringify({...bundle, overwrite})
|
|
1749
|
+
});
|
|
1750
|
+
if (res.status === 409) {
|
|
1751
|
+
const payload = await res.json().catch(() => ({}));
|
|
1752
|
+
const err = new Error(payload.error || 'Component already exists');
|
|
1753
|
+
err.code = 'CONFLICT';
|
|
1754
|
+
err.name = payload.name;
|
|
1755
|
+
throw err;
|
|
1756
|
+
}
|
|
1757
|
+
if (!res.ok) {
|
|
1758
|
+
const payload = await res.json().catch(() => ({}));
|
|
1759
|
+
throw new Error(payload.error || `Import failed (${res.status})`);
|
|
1760
|
+
}
|
|
1761
|
+
return res.json();
|
|
1762
|
+
}
|
|
1763
|
+
},
|
|
1764
|
+
```
|
|
1765
|
+
|
|
1766
|
+
- [ ] **Step 3: Smoke-test in the browser console after reload**
|
|
1767
|
+
|
|
1768
|
+
`await api.components.list()` - expect an array of five seeded entries.
|
|
1769
|
+
|
|
1770
|
+
---
|
|
1771
|
+
|
|
1772
|
+
## Task 19: List view template + logic
|
|
1773
|
+
|
|
1774
|
+
**Files:**
|
|
1775
|
+
- Create: `admin/js/templates/components.html`
|
|
1776
|
+
- Create: `admin/js/views/components.js`
|
|
1777
|
+
|
|
1778
|
+
- [ ] **Step 1: Write the template**
|
|
1779
|
+
|
|
1780
|
+
Use `admin/js/templates/blocks.html` as a reference and copy its structure. Change every "Block" to "Component", add a **Props** column to the table, and change the URL fragments from `/blocks/` to `/components/`.
|
|
1781
|
+
|
|
1782
|
+
- [ ] **Step 2: Write the view**
|
|
1783
|
+
|
|
1784
|
+
Use `admin/js/views/blocks.js` as a reference and copy its structure. Key changes:
|
|
1785
|
+
|
|
1786
|
+
- Import `api.components` instead of `api.blocks`.
|
|
1787
|
+
- The table columns include **Props** - render the parsed props as a pill list:
|
|
1788
|
+
```js
|
|
1789
|
+
{
|
|
1790
|
+
key: 'props',
|
|
1791
|
+
title: 'Props',
|
|
1792
|
+
render: (val) => Object.keys(val || {}).map(p => `<span class="badge badge-secondary" style="margin-right:.25rem;">${esc(p)}</span>`).join('')
|
|
1793
|
+
}
|
|
1794
|
+
```
|
|
1795
|
+
- File extension in links: `.dmc` not `.html`.
|
|
1796
|
+
- Register a `puzzle` icon (paths: `M10 3h4v4h4v4h-4v4h-4v-4H6V7h4V3z` or a similar puzzle-piece SVG) alongside the existing download/upload/plus icon registrations.
|
|
1797
|
+
|
|
1798
|
+
- [ ] **Step 3: Smoke test once wired in Task 20**
|
|
1799
|
+
|
|
1800
|
+
---
|
|
1801
|
+
|
|
1802
|
+
## Task 20: Register view, sidebar entry, and sidebar counts
|
|
1803
|
+
|
|
1804
|
+
**Files:**
|
|
1805
|
+
- Modify: `admin/js/views/index.js`
|
|
1806
|
+
- Modify: `admin/js/config/sidebar-config.js`
|
|
1807
|
+
- Modify: `admin/js/app.js`
|
|
1808
|
+
|
|
1809
|
+
- [ ] **Step 1: Register the view**
|
|
1810
|
+
|
|
1811
|
+
In `admin/js/views/index.js`:
|
|
1812
|
+
|
|
1813
|
+
```js
|
|
1814
|
+
import {componentsView} from './components.js';
|
|
1815
|
+
// …and inside the views object:
|
|
1816
|
+
components: componentsView,
|
|
1817
|
+
```
|
|
1818
|
+
|
|
1819
|
+
- [ ] **Step 2: Add the sidebar entry**
|
|
1820
|
+
|
|
1821
|
+
In `admin/js/config/sidebar-config.js`, inside the Data section, after the Blocks entry:
|
|
1822
|
+
|
|
1823
|
+
```js
|
|
1824
|
+
if (canAny('components')) {
|
|
1825
|
+
items.push({id: 'components', text: 'Components', icon: 'puzzle', url: '#/components', section: '#/components', badge: badge(counts.components)});
|
|
1826
|
+
}
|
|
1827
|
+
```
|
|
1828
|
+
|
|
1829
|
+
Also update the `hasData` check to include `canAny('components')`.
|
|
1830
|
+
|
|
1831
|
+
- [ ] **Step 3: Wire sidebar count**
|
|
1832
|
+
|
|
1833
|
+
In `admin/js/app.js` → `fetchSidebarCounts()`, add `api.components.list().catch(() => [])` to the `Promise.all` and expose `components: components.length` in the returned object.
|
|
1834
|
+
|
|
1835
|
+
- [ ] **Step 4: Register the route**
|
|
1836
|
+
|
|
1837
|
+
Find where `/blocks` route is defined in `admin/js/app.js` (or wherever Domma router routes are declared) and add:
|
|
1838
|
+
|
|
1839
|
+
```js
|
|
1840
|
+
R.route('/components', {view: 'components'});
|
|
1841
|
+
R.route('/components/new', {view: 'componentEditor'});
|
|
1842
|
+
R.route('/components/edit/:name', {view: 'componentEditor'});
|
|
1843
|
+
```
|
|
1844
|
+
|
|
1845
|
+
- [ ] **Step 5: Reload and check**
|
|
1846
|
+
|
|
1847
|
+
Open the admin in a browser. Expected: **Components** appears in the sidebar under Data, clicking it shows five seeded entries.
|
|
1848
|
+
|
|
1849
|
+
---
|
|
1850
|
+
|
|
1851
|
+
# PHASE 7 - Admin component editor
|
|
1852
|
+
|
|
1853
|
+
## Task 21: Editor template
|
|
1854
|
+
|
|
1855
|
+
**Files:**
|
|
1856
|
+
- Create: `admin/js/templates/component-editor.html`
|
|
1857
|
+
|
|
1858
|
+
- [ ] **Step 1: Write the template**
|
|
1859
|
+
|
|
1860
|
+
Reference `admin/js/templates/block-editor.html` for overall structure. Add a tab bar with four tabs (template / props / script / style), each revealing a textarea-based code editor (reuse the `#block-line-numbers`, `#block-cursor-pos`, `#block-editor-toolbar` primitives - give them new IDs scoped to `component-`).
|
|
1861
|
+
|
|
1862
|
+
On the right, add the iframe preview and a "Preview props" panel below it.
|
|
1863
|
+
|
|
1864
|
+
Key new IDs:
|
|
1865
|
+
- `#component-name`, `#component-bundled`, `#save-component-btn`, `#component-quick-switch`
|
|
1866
|
+
- `#component-tab-template`, `#component-tab-props`, `#component-tab-script`, `#component-tab-style`
|
|
1867
|
+
- `#component-pane-template`, `#component-pane-props`, `#component-pane-script`, `#component-pane-style`
|
|
1868
|
+
- `#component-preview-iframe`, `#component-preview-props`, `#component-compile-status`
|
|
1869
|
+
|
|
1870
|
+
---
|
|
1871
|
+
|
|
1872
|
+
## Task 22: Editor view logic
|
|
1873
|
+
|
|
1874
|
+
**Files:**
|
|
1875
|
+
- Create: `admin/js/views/component-editor.js`
|
|
1876
|
+
|
|
1877
|
+
- [ ] **Step 1: Write the view skeleton**
|
|
1878
|
+
|
|
1879
|
+
Reference `admin/js/views/block-editor.js` for editor primitives (line numbers, cursor tracking, tab-indent, toolbar). Adapt:
|
|
1880
|
+
|
|
1881
|
+
- Load via `api.components.get(name)`.
|
|
1882
|
+
- Parse the source into its four blocks on load; populate each tab's textarea.
|
|
1883
|
+
- **Tolerant parse on load:** use the same block-split regex client-side as `parseDmcSource` on the server. If any block is missing, populate the corresponding tab empty and surface a warning in the status bar - do **not** hard-fail the editor. An author editing a component they just broke should still be able to fix it.
|
|
1884
|
+
- On save, reassemble the four textareas into a single `.dmc` source string:
|
|
1885
|
+
```js
|
|
1886
|
+
const source = `<template>\n${templateText}\n</template>\n<props>\n${propsText}\n</props>\n<script>\n${scriptText}\n</script>\n<style>\n${styleText}\n</style>\n`;
|
|
1887
|
+
```
|
|
1888
|
+
- POST to `api.components.put(name, {source, bundled})`. On `400` with `errors`, display them in `#component-compile-status`.
|
|
1889
|
+
|
|
1890
|
+
- [ ] **Step 2: Debounced compile + preview wiring**
|
|
1891
|
+
|
|
1892
|
+
On input in any tab's textarea:
|
|
1893
|
+
|
|
1894
|
+
1. Debounce 300 ms.
|
|
1895
|
+
2. Assemble the current source.
|
|
1896
|
+
3. Call `api.components.compile(name, source)`.
|
|
1897
|
+
4. On success, postMessage `{type: 'remount', payload: {compiledJs: js, tagName: 'dm-'+name, props: previewProps}}` to the iframe.
|
|
1898
|
+
5. On error, show the error in `#component-compile-status` and do NOT remount.
|
|
1899
|
+
|
|
1900
|
+
- [ ] **Step 3: Preview props panel**
|
|
1901
|
+
|
|
1902
|
+
After load, parse the `<props>` block. For each prop, render an input keyed by type:
|
|
1903
|
+
- `number` → `<input type="number">`
|
|
1904
|
+
- `boolean` → `<input type="checkbox">`
|
|
1905
|
+
- `string` → `<input type="text">`
|
|
1906
|
+
- `array` / `object` → `<textarea>` containing JSON
|
|
1907
|
+
|
|
1908
|
+
On input, call the iframe `{type: 'update', payload: {props: currentProps}}` (cheaper than remount for prop-only changes).
|
|
1909
|
+
|
|
1910
|
+
---
|
|
1911
|
+
|
|
1912
|
+
## Task 23: Editor manual smoke test
|
|
1913
|
+
|
|
1914
|
+
- [ ] **Step 1: Navigate to `#/components/edit/counter`**
|
|
1915
|
+
|
|
1916
|
+
Expected: four tabs populated with the seeded counter source; iframe preview on the right shows the working counter.
|
|
1917
|
+
|
|
1918
|
+
- [ ] **Step 2: Edit the `<template>` value tag content and confirm live preview remounts**
|
|
1919
|
+
|
|
1920
|
+
- [ ] **Step 3: Click the `+` button inside the preview iframe**
|
|
1921
|
+
|
|
1922
|
+
Expected: counter increments (proves reactivity reaches into the sandbox).
|
|
1923
|
+
|
|
1924
|
+
- [ ] **Step 4: Introduce a `<props>` JSON syntax error**
|
|
1925
|
+
|
|
1926
|
+
Expected: save button disables, status bar shows the error.
|
|
1927
|
+
|
|
1928
|
+
---
|
|
1929
|
+
|
|
1930
|
+
# PHASE 8 - Page editor insertion slideover
|
|
1931
|
+
|
|
1932
|
+
## Task 24: Insert Component button + slideover
|
|
1933
|
+
|
|
1934
|
+
**Files:**
|
|
1935
|
+
- Modify: `admin/js/views/page-editor.js`
|
|
1936
|
+
|
|
1937
|
+
- [ ] **Step 1: Locate existing insert buttons**
|
|
1938
|
+
|
|
1939
|
+
Run: `grep -n "Insert Block\|insert-block\|[Ii]nsertBlock" admin/js/views/page-editor.js | head -20`
|
|
1940
|
+
|
|
1941
|
+
- [ ] **Step 2: Add the button + handler**
|
|
1942
|
+
|
|
1943
|
+
Next to the existing Insert Block / Insert Form buttons, add:
|
|
1944
|
+
|
|
1945
|
+
```js
|
|
1946
|
+
const insertComponentBtn = $container.find('#insert-component-btn').get(0);
|
|
1947
|
+
if (insertComponentBtn) insertComponentBtn.addEventListener('click', () => openComponentSlideover($container));
|
|
1948
|
+
```
|
|
1949
|
+
|
|
1950
|
+
Add a button in the template HTML with id `#insert-component-btn` and text "Insert Component".
|
|
1951
|
+
|
|
1952
|
+
- [ ] **Step 3: Implement `openComponentSlideover`**
|
|
1953
|
+
|
|
1954
|
+
```js
|
|
1955
|
+
async function openComponentSlideover($container) {
|
|
1956
|
+
const components = await api.components.list().catch(() => []);
|
|
1957
|
+
const slideover = E.slideover({
|
|
1958
|
+
title: 'Insert Component',
|
|
1959
|
+
size: 'lg',
|
|
1960
|
+
position: 'right'
|
|
1961
|
+
});
|
|
1962
|
+
|
|
1963
|
+
// Build gallery + form + preview DOM (do NOT use slideover.setContent with HTML - strips <button>).
|
|
1964
|
+
// Use .element.appendChild(...) pattern for interactive content (same as modal gotcha).
|
|
1965
|
+
const root = document.createElement('div');
|
|
1966
|
+
// …build UI: gallery cards, props form via F.create, iframe preview.
|
|
1967
|
+
slideover.element.appendChild(root);
|
|
1968
|
+
slideover.open();
|
|
1969
|
+
}
|
|
1970
|
+
```
|
|
1971
|
+
|
|
1972
|
+
Inside the slideover:
|
|
1973
|
+
|
|
1974
|
+
1. **Gallery**: card grid of `components`. On click of a card, reveal the props form and preview.
|
|
1975
|
+
2. **Props form**: `F.create(propsContainer, { blueprint: buildBlueprint(selectedComponent.props) })`. Emits changes.
|
|
1976
|
+
3. **Preview**: iframe pointing at `/admin/preview/component-preview.html`; postMessage mount with the selected component's compiled JS + current props.
|
|
1977
|
+
4. **Insert button**: closes the slideover and inserts `[component name="x" a="y" /]` at the textarea cursor in the page editor.
|
|
1978
|
+
|
|
1979
|
+
- [ ] **Step 4: Verify `E.slideover()` + iframe behaviour**
|
|
1980
|
+
|
|
1981
|
+
Flag during this task: if `E.slideover()` strips HTML the way `E.modal()` does, the light-DOM append pattern is the workaround (as done in the plan). Confirm the iframe survives open/close cycles. If it does not, note in the save preview code that the iframe is destroyed and recreated on reopen.
|
|
1982
|
+
|
|
1983
|
+
---
|
|
1984
|
+
|
|
1985
|
+
# PHASE 9 - End-to-end verification
|
|
1986
|
+
|
|
1987
|
+
## Task 25: Smoke tests
|
|
1988
|
+
|
|
1989
|
+
- [ ] **Step 1: Fresh page with a component**
|
|
1990
|
+
|
|
1991
|
+
Create a scratch page `content/pages/scratch.md`:
|
|
1992
|
+
|
|
1993
|
+
```markdown
|
|
1994
|
+
---
|
|
1995
|
+
title: Scratch
|
|
1996
|
+
---
|
|
1997
|
+
[component name="counter" initial="3" step="2" /]
|
|
1998
|
+
```
|
|
1999
|
+
|
|
2000
|
+
Navigate to `http://localhost:4096/scratch`. Expected: counter renders with value 3; clicking `+` advances by 2.
|
|
2001
|
+
|
|
2002
|
+
- [ ] **Step 2: Page with a raw-tag component**
|
|
2003
|
+
|
|
2004
|
+
Edit the same page to use `<dm-testimonial-card quote="Hello" author="Alice" role="CEO"></dm-testimonial-card>` - confirm it renders.
|
|
2005
|
+
|
|
2006
|
+
- [ ] **Step 3: Multiple components on one page**
|
|
2007
|
+
|
|
2008
|
+
Add both a counter and a testimonial-card to the same page. Open the browser dev tools → Network → confirm exactly two `/api/components/*.js` requests are made (not five, not zero).
|
|
2009
|
+
|
|
2010
|
+
- [ ] **Step 4: Reference a non-existent component**
|
|
2011
|
+
|
|
2012
|
+
Add `<dm-does-not-exist></dm-does-not-exist>` to the page. Expected: browser 404 for the script, tag stays inert, rest of the page renders fine.
|
|
2013
|
+
|
|
2014
|
+
- [ ] **Step 5: Permission gating**
|
|
2015
|
+
|
|
2016
|
+
With a non-super-admin user (but admin-level), confirm:
|
|
2017
|
+
- The Components sidebar entry is visible (they have `read`).
|
|
2018
|
+
- `PUT /api/components/counter` returns 403 (they lack `update`).
|
|
2019
|
+
|
|
2020
|
+
- [ ] **Step 6: Export / import round-trip**
|
|
2021
|
+
|
|
2022
|
+
Export the `counter`, delete it, re-import, confirm it reappears with identical source.
|
|
2023
|
+
|
|
2024
|
+
- [ ] **Step 7: Delete the scratch page**
|
|
2025
|
+
|
|
2026
|
+
```bash
|
|
2027
|
+
rm /home/darryl/src/js/domma-cms/content/pages/scratch.md
|
|
2028
|
+
```
|
|
2029
|
+
|
|
2030
|
+
---
|
|
2031
|
+
|
|
2032
|
+
## Done criteria
|
|
2033
|
+
|
|
2034
|
+
- All twenty-five tasks checked off.
|
|
2035
|
+
- `node --test tests/components/compile.test.js` passes all assertions.
|
|
2036
|
+
- Smoke tests in Task 25 all pass.
|
|
2037
|
+
- No new lint warnings.
|
|
2038
|
+
- User has committed the changes at their own cadence.
|