dekit-cli 0.1.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/LICENSE +200 -0
- package/README.md +78 -0
- package/bin/dekit.ts +78 -0
- package/dist/editor/assets/index-Bx5YsS46.css +9 -0
- package/dist/editor/assets/index-DK9iczjI.js +57 -0
- package/dist/editor/index.html +13 -0
- package/editor/index.html +12 -0
- package/editor/src/App.tsx +713 -0
- package/editor/src/devices.test.ts +50 -0
- package/editor/src/devices.ts +33 -0
- package/editor/src/main.tsx +13 -0
- package/editor/src/styles/editor.css +451 -0
- package/editor/src/types.ts +43 -0
- package/editor/vite.config.ts +11 -0
- package/package.json +42 -0
- package/src/design-server.ts +24 -0
- package/src/editor-server.ts +69 -0
- package/src/injected/inspector.ts +314 -0
- package/src/injected/register-components.ts +57 -0
- package/src/parser.test.ts +68 -0
- package/src/parser.ts +90 -0
- package/src/server.ts +35 -0
- package/src/types.ts +17 -0
- package/src/vite-plugin-dekit.test.ts +55 -0
- package/src/vite-plugin-dekit.ts +107 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import type { DesignConfig } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export async function startEditorServer(
|
|
7
|
+
configRef: { current: DesignConfig },
|
|
8
|
+
port: number,
|
|
9
|
+
designPort: number
|
|
10
|
+
): Promise<ReturnType<typeof createServer>> {
|
|
11
|
+
const editorDistDir = join(import.meta.dirname, "../dist/editor");
|
|
12
|
+
|
|
13
|
+
const server = createServer(async (req, res) => {
|
|
14
|
+
const url = req.url ?? "/";
|
|
15
|
+
|
|
16
|
+
// API: return design config as JSON
|
|
17
|
+
if (url === "/api/config") {
|
|
18
|
+
const config = configRef.current;
|
|
19
|
+
const pagePaths: Record<string, string> = {};
|
|
20
|
+
for (const [name, def] of Object.entries(config.pages)) {
|
|
21
|
+
pagePaths[name] = join(config.baseDir, def.template);
|
|
22
|
+
}
|
|
23
|
+
const payload = {
|
|
24
|
+
pages: Object.keys(config.pages),
|
|
25
|
+
pagePaths,
|
|
26
|
+
components: Object.keys(config.components),
|
|
27
|
+
designServerUrl: `http://localhost:${designPort}`,
|
|
28
|
+
};
|
|
29
|
+
res.setHeader("Content-Type", "application/json");
|
|
30
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
31
|
+
res.end(JSON.stringify(payload));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Serve static editor files
|
|
36
|
+
let filePath = join(editorDistDir, url === "/" ? "index.html" : url);
|
|
37
|
+
try {
|
|
38
|
+
const content = await readFile(filePath);
|
|
39
|
+
const ext = filePath.split(".").pop() ?? "";
|
|
40
|
+
const mimeTypes: Record<string, string> = {
|
|
41
|
+
html: "text/html",
|
|
42
|
+
js: "application/javascript",
|
|
43
|
+
css: "text/css",
|
|
44
|
+
svg: "image/svg+xml",
|
|
45
|
+
png: "image/png",
|
|
46
|
+
json: "application/json",
|
|
47
|
+
};
|
|
48
|
+
res.setHeader("Content-Type", mimeTypes[ext] ?? "application/octet-stream");
|
|
49
|
+
res.end(content);
|
|
50
|
+
} catch {
|
|
51
|
+
// SPA fallback
|
|
52
|
+
try {
|
|
53
|
+
const index = await readFile(join(editorDistDir, "index.html"));
|
|
54
|
+
res.setHeader("Content-Type", "text/html");
|
|
55
|
+
res.end(index);
|
|
56
|
+
} catch {
|
|
57
|
+
res.statusCode = 404;
|
|
58
|
+
res.end("Not found");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
server.listen(port, () => {
|
|
65
|
+
console.log(` Editor server running at http://localhost:${port}`);
|
|
66
|
+
resolve(server);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
export function generateInspectorScript(): string {
|
|
2
|
+
return `
|
|
3
|
+
(function() {
|
|
4
|
+
let overlay = null;
|
|
5
|
+
let selectedNode = null;
|
|
6
|
+
let inspectEnabled = false;
|
|
7
|
+
let pageName = '';
|
|
8
|
+
|
|
9
|
+
function isOverlayEl(el) {
|
|
10
|
+
if (!overlay) return false;
|
|
11
|
+
return el === overlay || overlay.contains(el);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function createOverlay() {
|
|
15
|
+
const el = document.createElement('div');
|
|
16
|
+
el.id = '__dekit_overlay__';
|
|
17
|
+
el.style.cssText = 'position:fixed;pointer-events:none;z-index:999999;display:none;';
|
|
18
|
+
|
|
19
|
+
const layers = ['margin','border','padding','content'];
|
|
20
|
+
const colors = {
|
|
21
|
+
margin: 'rgba(246,178,107,0.66)',
|
|
22
|
+
border: 'rgba(255,229,153,0.66)',
|
|
23
|
+
padding: 'rgba(147,196,125,0.55)',
|
|
24
|
+
content: 'rgba(111,168,220,0.66)'
|
|
25
|
+
};
|
|
26
|
+
for (const name of layers) {
|
|
27
|
+
const d = document.createElement('div');
|
|
28
|
+
d.className = '__rl_' + name;
|
|
29
|
+
d.style.cssText = 'position:absolute;background:' + colors[name] + ';';
|
|
30
|
+
el.appendChild(d);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const tip = document.createElement('div');
|
|
34
|
+
tip.className = '__rl_tooltip';
|
|
35
|
+
tip.style.cssText = 'position:absolute;background:rgba(50,50,50,0.92);color:#fff;font:11px/1.4 SFMono-Regular,Menlo,monospace;padding:3px 6px;border-radius:3px;white-space:nowrap;pointer-events:none;';
|
|
36
|
+
el.appendChild(tip);
|
|
37
|
+
|
|
38
|
+
document.body.appendChild(el);
|
|
39
|
+
return el;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function showOverlay(node) {
|
|
43
|
+
if (!overlay) overlay = createOverlay();
|
|
44
|
+
const cs = getComputedStyle(node);
|
|
45
|
+
const rect = node.getBoundingClientRect();
|
|
46
|
+
|
|
47
|
+
const mt = parseFloat(cs.marginTop) || 0;
|
|
48
|
+
const mr = parseFloat(cs.marginRight) || 0;
|
|
49
|
+
const mb = parseFloat(cs.marginBottom) || 0;
|
|
50
|
+
const ml = parseFloat(cs.marginLeft) || 0;
|
|
51
|
+
const bt = parseFloat(cs.borderTopWidth) || 0;
|
|
52
|
+
const br = parseFloat(cs.borderRightWidth) || 0;
|
|
53
|
+
const bb = parseFloat(cs.borderBottomWidth) || 0;
|
|
54
|
+
const blw = parseFloat(cs.borderLeftWidth) || 0;
|
|
55
|
+
const pt = parseFloat(cs.paddingTop) || 0;
|
|
56
|
+
const pr = parseFloat(cs.paddingRight) || 0;
|
|
57
|
+
const pb = parseFloat(cs.paddingBottom) || 0;
|
|
58
|
+
const pl = parseFloat(cs.paddingLeft) || 0;
|
|
59
|
+
|
|
60
|
+
const mw = ml + rect.width + mr;
|
|
61
|
+
const mh = mt + rect.height + mb;
|
|
62
|
+
|
|
63
|
+
overlay.style.display = 'block';
|
|
64
|
+
overlay.style.left = (rect.x - ml) + 'px';
|
|
65
|
+
overlay.style.top = (rect.y - mt) + 'px';
|
|
66
|
+
overlay.style.width = mw + 'px';
|
|
67
|
+
overlay.style.height = mh + 'px';
|
|
68
|
+
|
|
69
|
+
// Coordinates relative to overlay container (margin box origin)
|
|
70
|
+
// Border box
|
|
71
|
+
const bx0 = ml, by0 = mt;
|
|
72
|
+
const bx1 = ml + rect.width, by1 = mt + rect.height;
|
|
73
|
+
// Padding box
|
|
74
|
+
const px0 = ml + blw, py0 = mt + bt;
|
|
75
|
+
const px1 = ml + rect.width - br, py1 = mt + rect.height - bb;
|
|
76
|
+
// Content box
|
|
77
|
+
const cx0 = ml + blw + pl, cy0 = mt + bt + pt;
|
|
78
|
+
const cx1 = ml + rect.width - br - pr, cy1 = mt + rect.height - bb - pb;
|
|
79
|
+
|
|
80
|
+
function ring(ox0, oy0, ox1, oy1, ix0, iy0, ix1, iy1) {
|
|
81
|
+
// Two separate closed subpaths via SVG path syntax — no connecting edges
|
|
82
|
+
return "path(evenodd, '" +
|
|
83
|
+
'M '+ox0+' '+oy0+' L '+ox1+' '+oy0+' L '+ox1+' '+oy1+' L '+ox0+' '+oy1+' Z ' +
|
|
84
|
+
'M '+ix0+' '+iy0+' L '+ix1+' '+iy0+' L '+ix1+' '+iy1+' L '+ix0+' '+iy1+" Z')";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Each ring layer fills the container; clip-path cuts it to only show its ring
|
|
88
|
+
const marginEl = overlay.querySelector('.__rl_margin');
|
|
89
|
+
marginEl.style.left = '0px';
|
|
90
|
+
marginEl.style.top = '0px';
|
|
91
|
+
marginEl.style.width = mw + 'px';
|
|
92
|
+
marginEl.style.height = mh + 'px';
|
|
93
|
+
marginEl.style.clipPath = ring(0, 0, mw, mh, bx0, by0, bx1, by1);
|
|
94
|
+
|
|
95
|
+
const borderEl = overlay.querySelector('.__rl_border');
|
|
96
|
+
borderEl.style.left = '0px';
|
|
97
|
+
borderEl.style.top = '0px';
|
|
98
|
+
borderEl.style.width = mw + 'px';
|
|
99
|
+
borderEl.style.height = mh + 'px';
|
|
100
|
+
borderEl.style.clipPath = ring(bx0, by0, bx1, by1, px0, py0, px1, py1);
|
|
101
|
+
|
|
102
|
+
const paddingEl = overlay.querySelector('.__rl_padding');
|
|
103
|
+
paddingEl.style.left = '0px';
|
|
104
|
+
paddingEl.style.top = '0px';
|
|
105
|
+
paddingEl.style.width = mw + 'px';
|
|
106
|
+
paddingEl.style.height = mh + 'px';
|
|
107
|
+
paddingEl.style.clipPath = ring(px0, py0, px1, py1, cx0, cy0, cx1, cy1);
|
|
108
|
+
|
|
109
|
+
const contentEl = overlay.querySelector('.__rl_content');
|
|
110
|
+
contentEl.style.left = cx0 + 'px';
|
|
111
|
+
contentEl.style.top = cy0 + 'px';
|
|
112
|
+
contentEl.style.width = Math.max(0, cx1 - cx0) + 'px';
|
|
113
|
+
contentEl.style.height = Math.max(0, cy1 - cy0) + 'px';
|
|
114
|
+
|
|
115
|
+
const tip = overlay.querySelector('.__rl_tooltip');
|
|
116
|
+
const tag = node.tagName.toLowerCase();
|
|
117
|
+
const cls = node.getAttribute('class');
|
|
118
|
+
const w = Math.round(rect.width);
|
|
119
|
+
const h = Math.round(rect.height);
|
|
120
|
+
let label = tag;
|
|
121
|
+
if (cls) label += '.' + cls.trim().split(/\\s+/).join('.');
|
|
122
|
+
label += ' \\u2002 ' + w + ' \\u00d7 ' + h;
|
|
123
|
+
tip.textContent = label;
|
|
124
|
+
|
|
125
|
+
let tipTop = -mt - tip.offsetHeight - 4;
|
|
126
|
+
if (rect.y - mt + tipTop < 0) {
|
|
127
|
+
tipTop = mh + 4;
|
|
128
|
+
}
|
|
129
|
+
let tipLeft = ml;
|
|
130
|
+
tip.style.left = tipLeft + 'px';
|
|
131
|
+
tip.style.top = tipTop + 'px';
|
|
132
|
+
|
|
133
|
+
const tipRect = tip.getBoundingClientRect();
|
|
134
|
+
if (tipRect.right > window.innerWidth) {
|
|
135
|
+
tip.style.left = (ml - (tipRect.right - window.innerWidth) - 4) + 'px';
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function hideOverlay() {
|
|
140
|
+
if (overlay) overlay.style.display = 'none';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function getNodePath(node) {
|
|
144
|
+
const parts = [];
|
|
145
|
+
let current = node;
|
|
146
|
+
while (current && current !== document.body) {
|
|
147
|
+
const parent = current.parentNode;
|
|
148
|
+
if (!parent) break;
|
|
149
|
+
const children = Array.from(parent.children);
|
|
150
|
+
const index = children.indexOf(current);
|
|
151
|
+
parts.unshift(current.tagName.toLowerCase() + '[' + index + ']');
|
|
152
|
+
current = parent;
|
|
153
|
+
}
|
|
154
|
+
parts.unshift('body');
|
|
155
|
+
return parts.join('/');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getNodeInfo(node) {
|
|
159
|
+
const computed = getComputedStyle(node);
|
|
160
|
+
const rect = node.getBoundingClientRect();
|
|
161
|
+
const attrs = {};
|
|
162
|
+
for (const attr of node.attributes) {
|
|
163
|
+
attrs[attr.name] = attr.value;
|
|
164
|
+
}
|
|
165
|
+
const styles = {};
|
|
166
|
+
const styleProps = [
|
|
167
|
+
'width','height','padding','margin','display','position',
|
|
168
|
+
'background','backgroundColor','color','border','borderRadius','opacity',
|
|
169
|
+
'fontFamily','fontSize','fontWeight','lineHeight',
|
|
170
|
+
'paddingTop','paddingRight','paddingBottom','paddingLeft',
|
|
171
|
+
'marginTop','marginRight','marginBottom','marginLeft',
|
|
172
|
+
'borderTopWidth','borderRightWidth','borderBottomWidth','borderLeftWidth'
|
|
173
|
+
];
|
|
174
|
+
for (const prop of styleProps) {
|
|
175
|
+
styles[prop] = computed[prop];
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
tagName: node.tagName.toLowerCase(),
|
|
179
|
+
path: getNodePath(node),
|
|
180
|
+
attributes: attrs,
|
|
181
|
+
computedStyles: styles,
|
|
182
|
+
boundingRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function buildDomTree(node) {
|
|
187
|
+
if (node.nodeType !== 1) return null;
|
|
188
|
+
const tagName = node.tagName.toLowerCase();
|
|
189
|
+
if (tagName === 'script' || tagName === 'style' || node.id === '__dekit_overlay__') return null;
|
|
190
|
+
const children = [];
|
|
191
|
+
for (const child of node.children) {
|
|
192
|
+
const subtree = buildDomTree(child);
|
|
193
|
+
if (subtree) children.push(subtree);
|
|
194
|
+
}
|
|
195
|
+
const classAttr = node.getAttribute('class');
|
|
196
|
+
return {
|
|
197
|
+
tagName,
|
|
198
|
+
path: getNodePath(node),
|
|
199
|
+
className: classAttr || '',
|
|
200
|
+
children
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function resolveNodeByPath(path) {
|
|
205
|
+
const parts = path.split('/').slice(1);
|
|
206
|
+
let current = document.body;
|
|
207
|
+
for (const part of parts) {
|
|
208
|
+
const match = part.match(/^(.+)\\[(\\d+)\\]$/);
|
|
209
|
+
if (!match) break;
|
|
210
|
+
const children = Array.from(current.children);
|
|
211
|
+
current = children[parseInt(match[2])];
|
|
212
|
+
if (!current) break;
|
|
213
|
+
}
|
|
214
|
+
return (current && current !== document.body) ? current : null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
document.addEventListener('click', function(e) {
|
|
218
|
+
if (!inspectEnabled) return;
|
|
219
|
+
e.preventDefault();
|
|
220
|
+
e.stopPropagation();
|
|
221
|
+
const node = e.target;
|
|
222
|
+
if (isOverlayEl(node)) return;
|
|
223
|
+
selectedNode = node;
|
|
224
|
+
const info = getNodeInfo(node);
|
|
225
|
+
window.parent.postMessage({ type: 'NODE_SELECTED', node: info }, '*');
|
|
226
|
+
}, true);
|
|
227
|
+
|
|
228
|
+
document.addEventListener('mouseover', function(e) {
|
|
229
|
+
if (!inspectEnabled) return;
|
|
230
|
+
const node = e.target;
|
|
231
|
+
if (isOverlayEl(node)) return;
|
|
232
|
+
showOverlay(node);
|
|
233
|
+
const info = getNodeInfo(node);
|
|
234
|
+
window.parent.postMessage({ type: 'NODE_HOVERED', node: info }, '*');
|
|
235
|
+
}, true);
|
|
236
|
+
|
|
237
|
+
document.addEventListener('mouseout', function(e) {
|
|
238
|
+
if (!inspectEnabled) return;
|
|
239
|
+
const related = e.relatedTarget;
|
|
240
|
+
if (related && isOverlayEl(related)) return;
|
|
241
|
+
hideOverlay();
|
|
242
|
+
}, true);
|
|
243
|
+
|
|
244
|
+
// Forward Alt key events to parent for inspect mode toggle
|
|
245
|
+
document.addEventListener('keydown', function(e) {
|
|
246
|
+
if (e.key === 'Alt') {
|
|
247
|
+
window.parent.postMessage({ type: 'INSPECT_KEY', pressed: true }, '*');
|
|
248
|
+
}
|
|
249
|
+
}, true);
|
|
250
|
+
document.addEventListener('keyup', function(e) {
|
|
251
|
+
if (e.key === 'Alt') {
|
|
252
|
+
window.parent.postMessage({ type: 'INSPECT_KEY', pressed: false }, '*');
|
|
253
|
+
}
|
|
254
|
+
}, true);
|
|
255
|
+
|
|
256
|
+
window.addEventListener('message', function(e) {
|
|
257
|
+
const msg = e.data;
|
|
258
|
+
if (!msg || !msg.type) return;
|
|
259
|
+
if (msg.type === 'GET_DOM_TREE') {
|
|
260
|
+
const tree = buildDomTree(document.body);
|
|
261
|
+
window.parent.postMessage({ type: 'DOM_TREE', tree: tree ? [tree] : [] }, '*');
|
|
262
|
+
} else if (msg.type === 'HIGHLIGHT_NODE') {
|
|
263
|
+
const node = resolveNodeByPath(msg.path);
|
|
264
|
+
if (node) {
|
|
265
|
+
selectedNode = node;
|
|
266
|
+
hideOverlay();
|
|
267
|
+
const info = getNodeInfo(node);
|
|
268
|
+
window.parent.postMessage({ type: 'NODE_SELECTED', node: info }, '*');
|
|
269
|
+
}
|
|
270
|
+
} else if (msg.type === 'HOVER_NODE') {
|
|
271
|
+
const node = resolveNodeByPath(msg.path);
|
|
272
|
+
if (node) {
|
|
273
|
+
showOverlay(node);
|
|
274
|
+
}
|
|
275
|
+
} else if (msg.type === 'CLEAR_HIGHLIGHT') {
|
|
276
|
+
selectedNode = null;
|
|
277
|
+
hideOverlay();
|
|
278
|
+
} else if (msg.type === 'SET_TOUCH_CURSOR') {
|
|
279
|
+
let touchStyle = document.getElementById('__dekit_touch_cursor__');
|
|
280
|
+
if (msg.enabled) {
|
|
281
|
+
if (!touchStyle) {
|
|
282
|
+
touchStyle = document.createElement('style');
|
|
283
|
+
touchStyle.id = '__dekit_touch_cursor__';
|
|
284
|
+
touchStyle.textContent = '* { cursor: none !important; }';
|
|
285
|
+
document.head.appendChild(touchStyle);
|
|
286
|
+
}
|
|
287
|
+
} else {
|
|
288
|
+
if (touchStyle) touchStyle.remove();
|
|
289
|
+
}
|
|
290
|
+
} else if (msg.type === 'SET_INSPECT_MODE') {
|
|
291
|
+
inspectEnabled = !!msg.enabled;
|
|
292
|
+
if (!inspectEnabled) {
|
|
293
|
+
hideOverlay();
|
|
294
|
+
}
|
|
295
|
+
} else if (msg.type === 'SET_PAGE_NAME') {
|
|
296
|
+
pageName = msg.page || '';
|
|
297
|
+
} else if (msg.type === 'COPY_FEEDBACK') {
|
|
298
|
+
if (overlay) {
|
|
299
|
+
var tip = overlay.querySelector('.__rl_tooltip');
|
|
300
|
+
if (tip) {
|
|
301
|
+
var orig = tip.textContent;
|
|
302
|
+
tip.textContent = '\\u2713 Copied';
|
|
303
|
+
tip.style.color = '#4ade80';
|
|
304
|
+
setTimeout(function() {
|
|
305
|
+
tip.textContent = orig;
|
|
306
|
+
tip.style.color = '#fff';
|
|
307
|
+
}, 800);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
})();
|
|
313
|
+
`;
|
|
314
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ComponentDef } from "../types.js";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
function tagNameToClassName(tagName: string): string {
|
|
6
|
+
return tagName
|
|
7
|
+
.split("-")
|
|
8
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
9
|
+
.join("");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function extractTemplateContent(html: string): string {
|
|
13
|
+
const match = html.match(/<template[^>]*>([\s\S]*?)<\/template>/i);
|
|
14
|
+
return match ? match[1].trim() : html.trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function generateComponentRegistrationScript(
|
|
18
|
+
components: Record<string, ComponentDef>,
|
|
19
|
+
baseDir: string
|
|
20
|
+
): Promise<string> {
|
|
21
|
+
const registrations: string[] = [];
|
|
22
|
+
|
|
23
|
+
for (const [tagName, def] of Object.entries(components)) {
|
|
24
|
+
const templateHtml = await readFile(join(baseDir, def.template), "utf-8");
|
|
25
|
+
const styleContent = await readFile(join(baseDir, def.style), "utf-8");
|
|
26
|
+
const innerContent = extractTemplateContent(templateHtml);
|
|
27
|
+
const className = tagNameToClassName(tagName);
|
|
28
|
+
|
|
29
|
+
// Escape backticks and ${} in content for use inside template literals
|
|
30
|
+
const escapedInnerContent = innerContent
|
|
31
|
+
.replace(/\\/g, "\\\\")
|
|
32
|
+
.replace(/`/g, "\\`")
|
|
33
|
+
.replace(/\$\{/g, "\\${");
|
|
34
|
+
const escapedStyleContent = styleContent
|
|
35
|
+
.replace(/\\/g, "\\\\")
|
|
36
|
+
.replace(/`/g, "\\`")
|
|
37
|
+
.replace(/\$\{/g, "\\${");
|
|
38
|
+
|
|
39
|
+
registrations.push(`
|
|
40
|
+
class ${className} extends HTMLElement {
|
|
41
|
+
constructor() {
|
|
42
|
+
super();
|
|
43
|
+
const shadow = this.attachShadow({ mode: 'open' });
|
|
44
|
+
const style = document.createElement('style');
|
|
45
|
+
style.textContent = \`${escapedStyleContent}\`;
|
|
46
|
+
shadow.appendChild(style);
|
|
47
|
+
const tpl = document.createElement('template');
|
|
48
|
+
tpl.innerHTML = \`${escapedInnerContent}\`;
|
|
49
|
+
shadow.appendChild(tpl.content.cloneNode(true));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
customElements.define(${JSON.stringify(tagName)}, ${className});
|
|
53
|
+
`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return registrations.join("\n");
|
|
57
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { parseDesignConfig } from "./parser.js";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const DESIGN_DIR = join(import.meta.dirname, "../example");
|
|
6
|
+
|
|
7
|
+
describe("parseDesignConfig", () => {
|
|
8
|
+
test("parses valid design.yaml", async () => {
|
|
9
|
+
const config = await parseDesignConfig(join(DESIGN_DIR, "design.yaml"));
|
|
10
|
+
expect(config.version).toBe(1.0);
|
|
11
|
+
expect(config.globalStyle).toBe("global.css");
|
|
12
|
+
expect(config.components["my-banner"]).toEqual({
|
|
13
|
+
template: "components/my-banner/banner.html",
|
|
14
|
+
style: "components/my-banner/banner.css",
|
|
15
|
+
});
|
|
16
|
+
expect(config.pages["cover"]).toEqual({
|
|
17
|
+
template: "pages/cover/cover.html",
|
|
18
|
+
style: "pages/cover/cover.css",
|
|
19
|
+
});
|
|
20
|
+
expect(config.pages["example"]).toBeDefined();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("stores resolved base directory", async () => {
|
|
24
|
+
const config = await parseDesignConfig(join(DESIGN_DIR, "design.yaml"));
|
|
25
|
+
expect(config.baseDir).toBe(DESIGN_DIR);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("rejects component key without hyphen", async () => {
|
|
29
|
+
const yaml = `
|
|
30
|
+
version: 1.0
|
|
31
|
+
global-style: "global.css"
|
|
32
|
+
components:
|
|
33
|
+
banner:
|
|
34
|
+
template: "components/banner/banner.html"
|
|
35
|
+
style: "components/banner/banner.css"
|
|
36
|
+
pages: {}
|
|
37
|
+
`;
|
|
38
|
+
await expect(
|
|
39
|
+
parseDesignConfig("/fake/design.yaml", yaml)
|
|
40
|
+
).rejects.toThrow(/hyphen/i);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("rejects missing template file", async () => {
|
|
44
|
+
const yaml = `
|
|
45
|
+
version: 1.0
|
|
46
|
+
global-style: "global.css"
|
|
47
|
+
components: {}
|
|
48
|
+
pages:
|
|
49
|
+
cover:
|
|
50
|
+
template: "pages/nonexistent.html"
|
|
51
|
+
style: "pages/cover/cover.css"
|
|
52
|
+
`;
|
|
53
|
+
await expect(
|
|
54
|
+
parseDesignConfig(join(DESIGN_DIR, "design.yaml"), yaml)
|
|
55
|
+
).rejects.toThrow(/not found|does not exist/i);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("rejects missing version field", async () => {
|
|
59
|
+
const yaml = `
|
|
60
|
+
global-style: "global.css"
|
|
61
|
+
components: {}
|
|
62
|
+
pages: {}
|
|
63
|
+
`;
|
|
64
|
+
await expect(
|
|
65
|
+
parseDesignConfig("/fake/design.yaml", yaml)
|
|
66
|
+
).rejects.toThrow(/version/i);
|
|
67
|
+
});
|
|
68
|
+
});
|
package/src/parser.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import yaml from "js-yaml";
|
|
5
|
+
import type { DesignConfig, ComponentDef, PageDef } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export async function parseDesignConfig(
|
|
8
|
+
configPath: string,
|
|
9
|
+
rawContent?: string
|
|
10
|
+
): Promise<DesignConfig> {
|
|
11
|
+
const absolutePath = resolve(configPath);
|
|
12
|
+
const baseDir = dirname(absolutePath);
|
|
13
|
+
const content = rawContent ?? (await readFile(absolutePath, "utf-8"));
|
|
14
|
+
const doc = yaml.load(content) as Record<string, unknown>;
|
|
15
|
+
|
|
16
|
+
if (!doc || typeof doc !== "object") {
|
|
17
|
+
throw new Error("Invalid design.yaml: not a valid YAML object");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (doc.version == null) {
|
|
21
|
+
throw new Error("Invalid design.yaml: missing 'version' field");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Validate file paths if the base directory actually exists on disk
|
|
25
|
+
const shouldValidateFiles = existsSync(baseDir);
|
|
26
|
+
|
|
27
|
+
const globalStyle = (doc["global-style"] as string) ?? "";
|
|
28
|
+
if (globalStyle && shouldValidateFiles) {
|
|
29
|
+
const globalStylePath = join(baseDir, globalStyle);
|
|
30
|
+
if (!existsSync(globalStylePath)) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Global style file not found: ${globalStylePath}`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const rawComponents =
|
|
38
|
+
(doc.components as Record<string, { template: string; style: string }>) ??
|
|
39
|
+
{};
|
|
40
|
+
const components: Record<string, ComponentDef> = {};
|
|
41
|
+
for (const [key, val] of Object.entries(rawComponents)) {
|
|
42
|
+
if (!key.includes("-")) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Component key "${key}" must contain a hyphen (Web Component spec requires it)`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if (shouldValidateFiles) {
|
|
48
|
+
validateFilePaths(baseDir, key, val.template, val.style);
|
|
49
|
+
}
|
|
50
|
+
components[key] = { template: val.template, style: val.style };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const rawPages =
|
|
54
|
+
(doc.pages as Record<string, { template: string; style: string }>) ?? {};
|
|
55
|
+
const pages: Record<string, PageDef> = {};
|
|
56
|
+
for (const [key, val] of Object.entries(rawPages)) {
|
|
57
|
+
if (shouldValidateFiles) {
|
|
58
|
+
validateFilePaths(baseDir, key, val.template, val.style);
|
|
59
|
+
}
|
|
60
|
+
pages[key] = { template: val.template, style: val.style };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
version: doc.version as number,
|
|
65
|
+
globalStyle,
|
|
66
|
+
components,
|
|
67
|
+
pages,
|
|
68
|
+
baseDir,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function validateFilePaths(
|
|
73
|
+
baseDir: string,
|
|
74
|
+
key: string,
|
|
75
|
+
template: string,
|
|
76
|
+
style: string
|
|
77
|
+
): void {
|
|
78
|
+
const templatePath = join(baseDir, template);
|
|
79
|
+
if (!existsSync(templatePath)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Template file not found for "${key}": ${templatePath}`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const stylePath = join(baseDir, style);
|
|
85
|
+
if (!existsSync(stylePath)) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Style file not found for "${key}": ${stylePath}`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { DesignConfig } from "./types.js";
|
|
2
|
+
import { startDesignServer } from "./design-server.js";
|
|
3
|
+
import { startEditorServer } from "./editor-server.js";
|
|
4
|
+
|
|
5
|
+
export interface ServerOptions {
|
|
6
|
+
config: DesignConfig;
|
|
7
|
+
port: number;
|
|
8
|
+
designPort: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function startServers(options: ServerOptions) {
|
|
12
|
+
const configRef = { current: options.config };
|
|
13
|
+
|
|
14
|
+
console.log("\n Dekit Dev Server\n");
|
|
15
|
+
|
|
16
|
+
const designServer = await startDesignServer(configRef, options.designPort);
|
|
17
|
+
const editorServer = await startEditorServer(
|
|
18
|
+
configRef,
|
|
19
|
+
options.port,
|
|
20
|
+
options.designPort
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const editorUrl = `http://localhost:${options.port}`;
|
|
24
|
+
console.log(`\n Open ${editorUrl} in your browser\n`);
|
|
25
|
+
|
|
26
|
+
// Try to open browser
|
|
27
|
+
try {
|
|
28
|
+
const open = await import("open");
|
|
29
|
+
await open.default(editorUrl);
|
|
30
|
+
} catch {
|
|
31
|
+
// Silently ignore if open fails
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { designServer, editorServer };
|
|
35
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface ComponentDef {
|
|
2
|
+
template: string;
|
|
3
|
+
style: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface PageDef {
|
|
7
|
+
template: string;
|
|
8
|
+
style: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DesignConfig {
|
|
12
|
+
version: number;
|
|
13
|
+
globalStyle: string;
|
|
14
|
+
components: Record<string, ComponentDef>;
|
|
15
|
+
pages: Record<string, PageDef>;
|
|
16
|
+
baseDir: string; // absolute path to directory containing design.yaml
|
|
17
|
+
}
|