htmlboard 0.4.0 → 0.6.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/README.md +190 -15
- package/bin/htmlboard.js +176 -4
- package/package.json +1 -1
- package/skills/htmlboard/SKILL.md +28 -33
- package/skills/htmlboard-components/SKILL.md +36 -0
- package/skills/htmlboard-designs-json/SKILL.md +25 -0
- package/src/canvases.js +38 -0
- package/src/catalog.js +449 -0
- package/src/check.js +143 -0
- package/src/client/check.js +349 -0
- package/src/client/explorer.js +68 -2
- package/src/client/probe.js +41 -0
- package/src/components.js +95 -0
- package/src/designs.js +107 -23
- package/src/index.js +18 -0
- package/src/migrate.js +2 -2
- package/src/server.js +167 -11
- package/src/skills.js +2 -1
- package/src/templates/01-screens/01-dashboard.html +112 -0
- package/src/templates/01-screens/02-customers.html +77 -0
- package/src/templates/01-screens/{02-mobile.html → 03-mobile.html} +4 -54
- package/src/templates/{02-components/01-ui-kit.html → 02-ui-kit/01-overview.html} +16 -4
- package/src/templates/components/app-sidebar.html +67 -0
- package/src/templates/components/app-topbar.html +19 -0
- package/src/templates/components/stat-card.html +14 -0
- package/src/templates/components/tx-item.html +33 -0
- package/src/templates/components.js +180 -0
- package/src/templates/styles.css +24 -2
- package/src/templates/01-screens/01-desktop.html +0 -281
package/src/designs.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { COMPONENTS_FOLDER, PREVIEW_WIDTH, isPage, isTagName } from './components.js';
|
|
3
4
|
|
|
4
5
|
// Which designs a folder holds, and how the canvas shows them.
|
|
5
6
|
//
|
|
@@ -16,6 +17,9 @@ import path from 'node:path';
|
|
|
16
17
|
// A designs.json beside them is optional and only curates: sections with their titles, the order,
|
|
17
18
|
// overrides for a design's values. Designs it doesn't list come after the ones it does (unless it
|
|
18
19
|
// says "unlisted": "hide").
|
|
20
|
+
//
|
|
21
|
+
// The components in components/ (pieces of pages, see components.js) aren't designs. A designs.json
|
|
22
|
+
// can show them anyway, each in a frame of its own: "components": "show", or listing them.
|
|
19
23
|
|
|
20
24
|
/** Folders discovery leaves alone (hidden ones too) */
|
|
21
25
|
const IGNORED_DIRS = new Set(['node_modules', 'dist', 'build']);
|
|
@@ -93,7 +97,8 @@ function decode(text) {
|
|
|
93
97
|
});
|
|
94
98
|
}
|
|
95
99
|
|
|
96
|
-
|
|
100
|
+
/** The attributes of an HTML tag (`<meta name="x" content="y">`), names in lowercase, values decoded */
|
|
101
|
+
export function attributes(tag) {
|
|
97
102
|
const attrs = {};
|
|
98
103
|
for (const m of tag.matchAll(/([^\s"'<>/=]+)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g)) {
|
|
99
104
|
attrs[m[1].toLowerCase()] = decode(m[2] ?? m[3] ?? m[4] ?? '');
|
|
@@ -115,19 +120,43 @@ function parseSettings(content) {
|
|
|
115
120
|
return settings;
|
|
116
121
|
}
|
|
117
122
|
|
|
118
|
-
|
|
123
|
+
// A variant's own setting, as the htmlboard tag says it: width=390, height=844, category=mobile
|
|
124
|
+
const VARIANT_SETTING = /^(width|height|category)=[^,;\s]+[,;]?$/i;
|
|
125
|
+
const LABEL_START = /^[·|:,\-–—]+\s*/;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* <meta name="htmlboard:variant" content="?tema=escuro · escuro">: the file once more, with a
|
|
129
|
+
* query string, a size (width=390, height=844) or both, then a label. A query may leave out its "?".
|
|
130
|
+
*/
|
|
119
131
|
function parseVariant(content) {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
132
|
+
const tokens = content.trim().split(/\s+/);
|
|
133
|
+
let query = '';
|
|
134
|
+
const settings = [];
|
|
135
|
+
let i = 0;
|
|
136
|
+
for (; i < tokens.length; i++) {
|
|
137
|
+
const token = tokens[i];
|
|
138
|
+
if (VARIANT_SETTING.test(token)) settings.push(token.replace(/[,;]$/, ''));
|
|
139
|
+
else if (token.startsWith('?') && !query) query = token;
|
|
140
|
+
else if (i === 0 && !LABEL_START.test(token)) query = `?${token}`;
|
|
141
|
+
else break;
|
|
142
|
+
}
|
|
143
|
+
if (query === '?') query = '';
|
|
144
|
+
const { width, height, category } = parseSettings(settings.join(','));
|
|
145
|
+
if (!query && !width && !height) return null;
|
|
146
|
+
return { query, label: tokens.slice(i).join(' ').replace(LABEL_START, '').trim(), ...compact({ width, height, category }) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A variant as its tag's content says it: "?theme=dark width=390, height=844 · dark mobile" */
|
|
150
|
+
export function formatVariant({ query = '', label = '', width, height, category }) {
|
|
151
|
+
const size = [width && `width=${width}`, height && `height=${height}`, category && `category=${category}`].filter(Boolean).join(', ');
|
|
152
|
+
return [query, size].filter(Boolean).join(' ') + (label ? ` · ${label}` : '');
|
|
124
153
|
}
|
|
125
154
|
|
|
126
155
|
/** What an HTML file says about itself, from its <head> (see the top of this file) */
|
|
127
156
|
export function parseDesignMeta(html) {
|
|
128
157
|
const end = html.search(/<\/head\s*>|<body[\s>]/i);
|
|
129
158
|
const head = end >= 0 ? html.slice(0, end) : html.slice(0, 65536);
|
|
130
|
-
const meta = { variants: [] };
|
|
159
|
+
const meta = { variants: [], page: isPage(html) };
|
|
131
160
|
const title = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
132
161
|
if (title) {
|
|
133
162
|
const text = decode(title[1]).replace(/\s+/g, ' ').trim();
|
|
@@ -191,19 +220,32 @@ function fileDesign(file, folder, meta) {
|
|
|
191
220
|
});
|
|
192
221
|
}
|
|
193
222
|
|
|
194
|
-
|
|
223
|
+
// A variant of a file's design: another query string (a state the page builds from its URL), another
|
|
224
|
+
// size (the same page at a phone's width), or both. Without a height of its own, a variant that
|
|
225
|
+
// changes the width grows with its page. One that only changes the size loads the same URL as its
|
|
226
|
+
// file, so its id comes from its label.
|
|
227
|
+
function variantDesign(base, { query = '', label, width, height, category }) {
|
|
228
|
+
const sized = Boolean(width || height);
|
|
229
|
+
const size = sized ? { width: width ?? base.width, height: height ?? (width ? undefined : base.height) } : {};
|
|
230
|
+
const sizeName = sized ? `${size.width}${size.height ? ` × ${size.height}` : ''}` : '';
|
|
231
|
+
const name = label || [query.slice(1), sizeName].filter(Boolean).join(' · ');
|
|
195
232
|
return compact({
|
|
196
233
|
...base,
|
|
197
|
-
|
|
234
|
+
...size,
|
|
235
|
+
key: base.file + query + (sized ? `@${size.width}x${size.height ?? ''}` : ''),
|
|
198
236
|
file: base.file + query,
|
|
199
|
-
title: `${base.baseTitle} · ${
|
|
237
|
+
title: `${base.baseTitle} · ${name}`,
|
|
238
|
+
// A new size is a new device: its category comes from its size, unless it says one
|
|
239
|
+
category: sized ? category : category ?? base.category,
|
|
240
|
+
id: sized ? `${designId(base.file + query)}-${slugify(name)}` : undefined,
|
|
200
241
|
variantOf: base.file
|
|
201
242
|
});
|
|
202
243
|
}
|
|
203
244
|
|
|
204
245
|
/**
|
|
205
246
|
* Every design in the folder, in order: the files of a folder by name (number prefixes order
|
|
206
|
-
* them), each followed by its variants, then its subfolders by name.
|
|
247
|
+
* them), each followed by its variants, then its subfolders by name. The components in
|
|
248
|
+
* components/ are left out (a whole page there is still a design).
|
|
207
249
|
*/
|
|
208
250
|
export function scanDesigns(baseDir) {
|
|
209
251
|
const designs = [];
|
|
@@ -219,7 +261,7 @@ export function scanDesigns(baseDir) {
|
|
|
219
261
|
if (!entry.isFile() || !/\.html?$/i.test(entry.name)) continue;
|
|
220
262
|
const file = folder ? `${folder}/${entry.name}` : entry.name;
|
|
221
263
|
const meta = readDesignMeta(path.join(dir, entry.name));
|
|
222
|
-
if (!meta || meta.skip) continue;
|
|
264
|
+
if (!meta || meta.skip || (folder === COMPONENTS_FOLDER && !meta.page)) continue;
|
|
223
265
|
const base = fileDesign(file, folder, meta);
|
|
224
266
|
designs.push(base);
|
|
225
267
|
for (const variant of meta.variants) designs.push(variantDesign(base, variant));
|
|
@@ -232,6 +274,43 @@ export function scanDesigns(baseDir) {
|
|
|
232
274
|
return designs;
|
|
233
275
|
}
|
|
234
276
|
|
|
277
|
+
/**
|
|
278
|
+
* The components in the folder's components/, by name, as designs that show each one alone (its
|
|
279
|
+
* file's example, at components/<tag>.html?preview). For designs.json, which can show them.
|
|
280
|
+
* A component's file can give its preview's size with the same tag as a design.
|
|
281
|
+
*/
|
|
282
|
+
export function scanComponents(baseDir) {
|
|
283
|
+
const dir = path.join(baseDir, COMPONENTS_FOLDER);
|
|
284
|
+
let entries;
|
|
285
|
+
try {
|
|
286
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
287
|
+
} catch {
|
|
288
|
+
return [];
|
|
289
|
+
}
|
|
290
|
+
return entries
|
|
291
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.html') && isTagName(entry.name.slice(0, -5)))
|
|
292
|
+
.sort((a, b) => byName(a.name, b.name))
|
|
293
|
+
.flatMap((entry) => {
|
|
294
|
+
const meta = readDesignMeta(path.join(dir, entry.name));
|
|
295
|
+
if (!meta || meta.skip || meta.page) return [];
|
|
296
|
+
const tag = entry.name.slice(0, -5);
|
|
297
|
+
const file = `${COMPONENTS_FOLDER}/${entry.name}`;
|
|
298
|
+
return [compact({
|
|
299
|
+
key: file,
|
|
300
|
+
file: `${file}?preview`,
|
|
301
|
+
folder: COMPONENTS_FOLDER,
|
|
302
|
+
id: designId(file),
|
|
303
|
+
title: `<${tag}>`,
|
|
304
|
+
description: meta.description,
|
|
305
|
+
width: meta.width || PREVIEW_WIDTH,
|
|
306
|
+
height: meta.height,
|
|
307
|
+
fit: !meta.height || undefined, // the frame is as tall as what it shows, however short
|
|
308
|
+
category: 'components',
|
|
309
|
+
component: true
|
|
310
|
+
})];
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
235
314
|
/** Designs grouped into one section per folder, in order (`rootName` for the files at the top) */
|
|
236
315
|
function groupByFolder(designs, rootName = '') {
|
|
237
316
|
const groups = new Map();
|
|
@@ -274,8 +353,13 @@ function resolveEntry(entry, byKey) {
|
|
|
274
353
|
return design;
|
|
275
354
|
}
|
|
276
355
|
|
|
277
|
-
/**
|
|
278
|
-
|
|
356
|
+
/**
|
|
357
|
+
* designs.json's sections filled from the files, then the designs it doesn't list. Components
|
|
358
|
+
* (see scanComponents) show where it lists them, and all of them with "components": "show".
|
|
359
|
+
*/
|
|
360
|
+
function curate(config, designs, components = []) {
|
|
361
|
+
const showComponents = config.components === 'show' || config.components === true;
|
|
362
|
+
const found = [...designs, ...components];
|
|
279
363
|
const byKey = new Map(found.map((d) => [d.key, d]));
|
|
280
364
|
const sources = (Array.isArray(config.sections) ? config.sections : []).map((section) => ({
|
|
281
365
|
section,
|
|
@@ -313,14 +397,16 @@ function curate(config, found) {
|
|
|
313
397
|
owners.set(folders[0], owners.has(folders[0]) && owners.get(folders[0]) !== i ? -1 : i);
|
|
314
398
|
});
|
|
315
399
|
|
|
316
|
-
const unlisted = found.filter((d) => !used.has(d.key));
|
|
400
|
+
const unlisted = found.filter((d) => !used.has(d.key) && (!d.component || showComponents));
|
|
317
401
|
const rest = [];
|
|
318
402
|
for (const design of unlisted) {
|
|
319
403
|
const owner = owners.get(design.folder ?? '');
|
|
320
404
|
if (owner !== undefined && owner >= 0) sections[owner].artboards.push(design);
|
|
321
|
-
else rest.push(design);
|
|
405
|
+
else if (design.component || config.unlisted !== 'hide') rest.push(design);
|
|
322
406
|
}
|
|
323
|
-
|
|
407
|
+
// Without sections of its own, designs.json only sets things: the files at the top keep a first
|
|
408
|
+
// row without a title, as without designs.json.
|
|
409
|
+
sections.push(...groupByFolder(rest, sources.length ? 'Unlisted' : ''));
|
|
324
410
|
|
|
325
411
|
const { sections: _sections, ...top } = config;
|
|
326
412
|
return { ...top, sections };
|
|
@@ -343,7 +429,7 @@ function finalize(manifest, prefix = '') {
|
|
|
343
429
|
id: section.id ?? (slugify(name) || (folder ? slugify(folder.split('/').map(stripPrefix).join('-')) : `section-${i + 1}`)),
|
|
344
430
|
name,
|
|
345
431
|
artboards: artboards.map((design) => {
|
|
346
|
-
const { key, folder: _folder, baseTitle, variantOf, ...board } = design;
|
|
432
|
+
const { key, folder: _folder, baseTitle, variantOf, component, ...board } = design;
|
|
347
433
|
const category = board.category ?? inferCategory(design.file.split('?')[0], board.width, board.height);
|
|
348
434
|
return compact({
|
|
349
435
|
...board,
|
|
@@ -381,18 +467,16 @@ export function normalizeManifest(manifest, prefix = '') {
|
|
|
381
467
|
*/
|
|
382
468
|
export function loadDesignsManifest(baseDir, rootDir = baseDir) {
|
|
383
469
|
const prefix = path.relative(rootDir, baseDir).split(path.sep).join('/');
|
|
470
|
+
const title = path.basename(path.resolve(baseDir)) || 'Designs';
|
|
384
471
|
const found = scanDesigns(baseDir);
|
|
385
472
|
const configPath = path.join(baseDir, 'designs.json');
|
|
386
473
|
if (fs.existsSync(configPath)) {
|
|
387
474
|
try {
|
|
388
475
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
389
|
-
return { ...finalize(curate(config, found), prefix), autoDiscovered: false };
|
|
476
|
+
return { ...finalize(curate({ title, ...config }, found, scanComponents(baseDir)), prefix), autoDiscovered: false };
|
|
390
477
|
} catch (err) {
|
|
391
478
|
console.warn(`[htmlboard] Failed parsing designs.json: ${err.message}. Showing the files without it.`);
|
|
392
479
|
}
|
|
393
480
|
}
|
|
394
|
-
return {
|
|
395
|
-
...finalize({ title: path.basename(path.resolve(baseDir)) || 'Designs', sections: groupByFolder(found) }, prefix),
|
|
396
|
-
autoDiscovered: true
|
|
397
|
-
};
|
|
481
|
+
return { ...finalize({ title, sections: groupByFolder(found) }, prefix), autoDiscovered: true };
|
|
398
482
|
}
|
package/src/index.js
CHANGED
|
@@ -15,9 +15,27 @@ export {
|
|
|
15
15
|
normalizeManifest,
|
|
16
16
|
inferCategory,
|
|
17
17
|
parseDesignMeta,
|
|
18
|
+
scanComponents,
|
|
18
19
|
scanDesigns
|
|
19
20
|
} from './designs.js';
|
|
20
21
|
|
|
22
|
+
export {
|
|
23
|
+
installComponents,
|
|
24
|
+
componentPreview,
|
|
25
|
+
COMPONENTS_FOLDER,
|
|
26
|
+
COMPONENTS_SCRIPT
|
|
27
|
+
} from './components.js';
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
buildCatalog,
|
|
31
|
+
formatCatalog
|
|
32
|
+
} from './catalog.js';
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
runCheck,
|
|
36
|
+
formatReport
|
|
37
|
+
} from './check.js';
|
|
38
|
+
|
|
21
39
|
export {
|
|
22
40
|
installSkills,
|
|
23
41
|
uninstallSkills,
|
package/src/migrate.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { loadDesignsManifest, parseDesignMeta, stripPrefix } from './designs.js';
|
|
4
|
+
import { loadDesignsManifest, parseDesignMeta, stripPrefix, formatVariant } from './designs.js';
|
|
5
5
|
|
|
6
6
|
// `htmlboard migrate`: moves what a designs.json says about each design into the design's own file
|
|
7
7
|
// (its size, category, label, variants and description) and leaves designs.json with what only it
|
|
@@ -110,7 +110,7 @@ export function planMigration(designsDir) {
|
|
|
110
110
|
}
|
|
111
111
|
for (const key of ['description', 'tags', 'id']) if (item.entry[key] !== undefined) item.keep[key] = item.entry[key];
|
|
112
112
|
}
|
|
113
|
-
for (const v of declared) lines.push(`<meta name="htmlboard:variant" content="${escapeAttr(
|
|
113
|
+
for (const v of declared) lines.push(`<meta name="htmlboard:variant" content="${escapeAttr(formatVariant(v))}">`);
|
|
114
114
|
|
|
115
115
|
const next = rewriteHead(html, lines);
|
|
116
116
|
if (next !== html) files.push({ file: base, lines, html: next });
|
package/src/server.js
CHANGED
|
@@ -2,8 +2,10 @@ import http from 'node:http';
|
|
|
2
2
|
import net from 'node:net';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import { loadDesignsManifest } from './designs.js';
|
|
8
|
+
import { componentPreview } from './components.js';
|
|
7
9
|
|
|
8
10
|
const __filename = fileURLToPath(import.meta.url);
|
|
9
11
|
const __dirname = path.dirname(__filename);
|
|
@@ -32,6 +34,50 @@ const MIME_TYPES = {
|
|
|
32
34
|
'.mp4': 'video/mp4'
|
|
33
35
|
};
|
|
34
36
|
|
|
37
|
+
// The probe (client/probe.js) notes what goes wrong while a page builds, for `htmlboard check`. It
|
|
38
|
+
// goes first in the pages the canvas shows in frames, before anything that could go wrong.
|
|
39
|
+
const PROBE = Buffer.from('<script src="/__explorer/probe.js"></script>');
|
|
40
|
+
|
|
41
|
+
/** A page with the probe first in it: in its <head>, else after <html> or <!DOCTYPE>, else first */
|
|
42
|
+
export function withProbe(page) {
|
|
43
|
+
const text = page.toString('latin1'); // a byte per character: positions in it are positions in page
|
|
44
|
+
for (const tag of [/<head\b[^>]*>/i, /<html\b[^>]*>/i, /<!doctype\b[^>]*>/i]) {
|
|
45
|
+
const match = tag.exec(text);
|
|
46
|
+
if (match) {
|
|
47
|
+
const at = match.index + match[0].length;
|
|
48
|
+
return Buffer.concat([page.subarray(0, at), PROBE, page.subarray(at)]);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return Buffer.concat([PROBE, page]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function json(res, status, body) {
|
|
55
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
56
|
+
res.end(JSON.stringify(body));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readBody(req, limit = 16 * 1024 * 1024) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const chunks = [];
|
|
62
|
+
let size = 0;
|
|
63
|
+
req.on('data', (chunk) => {
|
|
64
|
+
size += chunk.length;
|
|
65
|
+
if (size > limit) {
|
|
66
|
+
reject(new Error('Too large'));
|
|
67
|
+
req.destroy();
|
|
68
|
+
} else {
|
|
69
|
+
chunks.push(chunk);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
73
|
+
req.on('error', reject);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** How long a canvas has to take a check (htmlboard check), then to do it */
|
|
78
|
+
const CLAIM_MS = 15000;
|
|
79
|
+
const CHECK_MS = 180000;
|
|
80
|
+
|
|
35
81
|
/** True when target is base itself or inside it */
|
|
36
82
|
function isInside(base, target) {
|
|
37
83
|
const rel = path.relative(base, target);
|
|
@@ -152,13 +198,106 @@ export function createServer(options = {}) {
|
|
|
152
198
|
if (!isInside(rootDir, targetDir)) {
|
|
153
199
|
throw new Error(`The designs folder (${targetDir}) must be inside the root (${rootDir})`);
|
|
154
200
|
}
|
|
155
|
-
const
|
|
201
|
+
const designsPath = path.relative(rootDir, targetDir).split(path.sep).join('/'); // '' when they're the same
|
|
202
|
+
const manifestFile = path.posix.join(designsPath, 'designs.json');
|
|
156
203
|
const clients = new Set();
|
|
157
204
|
|
|
205
|
+
const send = (event, data) => {
|
|
206
|
+
for (const client of clients) client.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Checks (htmlboard check). GET /api/check asks the canvases open in a browser (over the live
|
|
210
|
+
// reload events) to check the designs; the first to claim the check does it, and what it posts
|
|
211
|
+
// back answers the request.
|
|
212
|
+
const checks = new Map(); // id → { res, claimed, timer }
|
|
213
|
+
|
|
214
|
+
function endCheck(id, status, body) {
|
|
215
|
+
const check = checks.get(id);
|
|
216
|
+
if (!check) return;
|
|
217
|
+
clearTimeout(check.timer);
|
|
218
|
+
checks.delete(id);
|
|
219
|
+
json(check.res, status, body);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// only: files or folders (from the designs folder) to check; all the designs when there's none
|
|
223
|
+
function startCheck(res, only) {
|
|
224
|
+
const wanted = only.map((item) => item.replace(/\\/g, '/').replace(/^\.?\/+|\/+$/g, ''));
|
|
225
|
+
const designs = loadDesignsManifest(targetDir, rootDir).sections
|
|
226
|
+
.flatMap((section) => section.artboards)
|
|
227
|
+
.map(({ id, file, title, width, height, fit }) => ({ id, file, title, width, height, fit }))
|
|
228
|
+
.filter(({ file }) => {
|
|
229
|
+
const own = (designsPath ? file.slice(designsPath.length + 1) : file).split('?')[0];
|
|
230
|
+
return !wanted.length || wanted.some((item) => !item || own === item || own.startsWith(`${item}/`));
|
|
231
|
+
});
|
|
232
|
+
if (!designs.length) {
|
|
233
|
+
json(res, 404, { error: wanted.some(Boolean) ? `No design on the canvas is in ${only.join(', ')}` : 'There are no designs to check' });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (!clients.size) {
|
|
237
|
+
json(res, 409, { error: "The canvas isn't open in a browser" });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const id = randomUUID();
|
|
241
|
+
const check = {
|
|
242
|
+
res,
|
|
243
|
+
claimed: false,
|
|
244
|
+
timer: setTimeout(() => endCheck(id, 504, { error: "The canvas didn't take the check (reload it in the browser)" }), CLAIM_MS)
|
|
245
|
+
};
|
|
246
|
+
checks.set(id, check);
|
|
247
|
+
res.on('close', () => {
|
|
248
|
+
if (checks.get(id) !== check) return; // answered
|
|
249
|
+
clearTimeout(check.timer);
|
|
250
|
+
checks.delete(id);
|
|
251
|
+
});
|
|
252
|
+
send('check', { id, designs });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function claimCheck(id, res) {
|
|
256
|
+
const check = checks.get(id);
|
|
257
|
+
if (!check || check.claimed) {
|
|
258
|
+
json(res, 409, { error: 'Taken' });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
check.claimed = true;
|
|
262
|
+
clearTimeout(check.timer);
|
|
263
|
+
check.timer = setTimeout(() => endCheck(id, 504, { error: `The canvas didn't finish the check in ${CHECK_MS / 1000} s` }), CHECK_MS);
|
|
264
|
+
json(res, 200, { ok: true });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function finishCheck(id, req, res) {
|
|
268
|
+
let report;
|
|
269
|
+
try {
|
|
270
|
+
report = JSON.parse(await readBody(req));
|
|
271
|
+
} catch {
|
|
272
|
+
json(res, 400, { error: 'Not a report' });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (!checks.get(id)?.claimed) {
|
|
276
|
+
json(res, 404, { error: 'No such check' });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (report.error) endCheck(id, 500, { error: `The canvas couldn't check the designs: ${report.error}` });
|
|
280
|
+
else endCheck(id, 200, { dir: targetDir, root: rootDir, designs: Array.isArray(report.designs) ? report.designs : [] });
|
|
281
|
+
res.writeHead(204).end();
|
|
282
|
+
}
|
|
283
|
+
|
|
158
284
|
const server = http.createServer((req, res) => {
|
|
159
285
|
const parsedUrl = new URL(req.url, 'http://localhost');
|
|
160
286
|
let pathname = decodeURIComponent(parsedUrl.pathname);
|
|
161
287
|
|
|
288
|
+
// API: checking the designs (htmlboard check), which the canvas does
|
|
289
|
+
if (pathname === '/api/check' && req.method === 'GET') {
|
|
290
|
+
startCheck(res, parsedUrl.searchParams.getAll('only'));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (pathname.startsWith('/api/check/') && req.method === 'POST') {
|
|
294
|
+
const [id, action, ...rest] = pathname.slice('/api/check/'.length).split('/');
|
|
295
|
+
if (action === 'claim' && !rest.length) claimCheck(id, res);
|
|
296
|
+
else if (action === undefined) finishCheck(id, req, res);
|
|
297
|
+
else json(res, 404, { error: 'Not found' });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
162
301
|
// API: Designs Manifest
|
|
163
302
|
if (pathname === '/api/designs') {
|
|
164
303
|
const manifest = loadDesignsManifest(targetDir, rootDir);
|
|
@@ -202,7 +341,7 @@ export function createServer(options = {}) {
|
|
|
202
341
|
|
|
203
342
|
// Serve HtmlBoard Explorer client files
|
|
204
343
|
if (pathname === '/' || pathname === '/index.html') {
|
|
205
|
-
serveStaticFile(path.join(CLIENT_DIR, 'index.html'), res);
|
|
344
|
+
serveStaticFile(path.join(CLIENT_DIR, 'index.html'), req, res);
|
|
206
345
|
return;
|
|
207
346
|
}
|
|
208
347
|
|
|
@@ -214,7 +353,7 @@ export function createServer(options = {}) {
|
|
|
214
353
|
res.writeHead(403).end('Forbidden');
|
|
215
354
|
return;
|
|
216
355
|
}
|
|
217
|
-
serveStaticFile(clientFilePath, res);
|
|
356
|
+
serveStaticFile(clientFilePath, req, res);
|
|
218
357
|
return;
|
|
219
358
|
}
|
|
220
359
|
|
|
@@ -232,24 +371,43 @@ export function createServer(options = {}) {
|
|
|
232
371
|
return;
|
|
233
372
|
}
|
|
234
373
|
|
|
374
|
+
// The pages the canvas shows in frames get the probe (htmlboard check); a page opened on its own doesn't
|
|
375
|
+
const framed = req.headers['sec-fetch-dest'] === 'iframe';
|
|
376
|
+
|
|
377
|
+
// A component alone, as the canvas shows it (components/<tag>.html?preview): its example in a page
|
|
378
|
+
if (parsedUrl.searchParams.has('preview')) {
|
|
379
|
+
const preview = componentPreview(targetDir, filePath);
|
|
380
|
+
if (preview !== null) {
|
|
381
|
+
res.writeHead(200, {
|
|
382
|
+
'Content-Type': MIME_TYPES['.html'],
|
|
383
|
+
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
|
384
|
+
});
|
|
385
|
+
res.end(framed ? withProbe(Buffer.from(preview)) : preview);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
235
390
|
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
|
236
|
-
serveStaticFile(filePath, res);
|
|
391
|
+
serveStaticFile(filePath, req, res, { probe: framed });
|
|
237
392
|
} else {
|
|
238
393
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
239
394
|
res.end(`File not found: ${pathname}`);
|
|
240
395
|
}
|
|
241
396
|
});
|
|
242
397
|
|
|
243
|
-
function serveStaticFile(filePath, res) {
|
|
398
|
+
function serveStaticFile(filePath, req, res, { probe = false } = {}) {
|
|
244
399
|
try {
|
|
245
400
|
const ext = path.extname(filePath).toLowerCase();
|
|
246
401
|
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
247
|
-
|
|
402
|
+
// HEAD (htmlboard check asks which files are there) needs no reading
|
|
403
|
+
let content = req.method === 'HEAD' ? null : fs.readFileSync(filePath);
|
|
404
|
+
if (content && probe && (ext === '.html' || ext === '.htm')) content = withProbe(content);
|
|
248
405
|
res.writeHead(200, {
|
|
249
406
|
'Content-Type': contentType,
|
|
250
407
|
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
|
251
408
|
});
|
|
252
|
-
res.end(content);
|
|
409
|
+
if (content) res.end(content);
|
|
410
|
+
else res.end();
|
|
253
411
|
} catch {
|
|
254
412
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
255
413
|
res.end('Error reading file');
|
|
@@ -271,15 +429,12 @@ export function createServer(options = {}) {
|
|
|
271
429
|
const files = [...changed];
|
|
272
430
|
changed.clear();
|
|
273
431
|
for (const [file, isFolder] of files) {
|
|
274
|
-
|
|
432
|
+
send('change', {
|
|
275
433
|
type: file === manifestFile ? 'manifest' : isFolder ? 'folder' : 'file',
|
|
276
434
|
file,
|
|
277
435
|
isHtml: !isFolder && /\.html?$/i.test(file),
|
|
278
436
|
timestamp: Date.now()
|
|
279
437
|
});
|
|
280
|
-
for (const client of clients) {
|
|
281
|
-
client.write(`event: change\ndata: ${eventData}\n\n`);
|
|
282
|
-
}
|
|
283
438
|
}
|
|
284
439
|
}, 100);
|
|
285
440
|
});
|
|
@@ -294,6 +449,7 @@ export function createServer(options = {}) {
|
|
|
294
449
|
close: () => {
|
|
295
450
|
clearTimeout(watchDebounceTimer);
|
|
296
451
|
if (stopWatching) stopWatching();
|
|
452
|
+
for (const id of [...checks.keys()]) endCheck(id, 503, { error: 'htmlboard stopped' });
|
|
297
453
|
for (const client of clients) {
|
|
298
454
|
client.end();
|
|
299
455
|
}
|
package/src/skills.js
CHANGED
|
@@ -21,7 +21,8 @@ export const SKILL_TARGETS = {
|
|
|
21
21
|
function bundledSkills() {
|
|
22
22
|
return fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
|
|
23
23
|
.filter((entry) => entry.isDirectory() && fs.existsSync(path.join(SKILLS_DIR, entry.name, 'SKILL.md')))
|
|
24
|
-
.map((entry) => entry.name)
|
|
24
|
+
.map((entry) => entry.name)
|
|
25
|
+
.sort();
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
function resolveTargets(agent = 'all') {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Dashboard</title>
|
|
7
|
+
<meta name="description" content="Analytics dashboard with sidebar, metric cards, and charts">
|
|
8
|
+
<meta name="htmlboard" content="width=1280, height=820">
|
|
9
|
+
<link rel="stylesheet" href="../styles.css">
|
|
10
|
+
<script type="module" src="../components.js"></script>
|
|
11
|
+
<style>
|
|
12
|
+
.stats-row {
|
|
13
|
+
display: grid;
|
|
14
|
+
grid-template-columns: repeat(4, 1fr);
|
|
15
|
+
gap: 20px;
|
|
16
|
+
}
|
|
17
|
+
.grid-2 {
|
|
18
|
+
display: grid;
|
|
19
|
+
grid-template-columns: 2fr 1fr;
|
|
20
|
+
gap: 20px;
|
|
21
|
+
}
|
|
22
|
+
.grid-2 .card { padding: 22px; }
|
|
23
|
+
.panel-header {
|
|
24
|
+
display: flex;
|
|
25
|
+
justify-content: space-between;
|
|
26
|
+
align-items: center;
|
|
27
|
+
margin-bottom: 20px;
|
|
28
|
+
font-size: 12px;
|
|
29
|
+
}
|
|
30
|
+
.panel-title { font-size: 16px; font-weight: 600; }
|
|
31
|
+
.chart-container { width: 100%; height: 180px; }
|
|
32
|
+
</style>
|
|
33
|
+
</head>
|
|
34
|
+
<body class="app">
|
|
35
|
+
<app-sidebar active="Dashboard"></app-sidebar>
|
|
36
|
+
|
|
37
|
+
<main class="app-main">
|
|
38
|
+
<app-topbar search="Search resources, clients, reports...">
|
|
39
|
+
<span class="muted">Last sync: Just now</span>
|
|
40
|
+
<button class="btn btn-primary">+ New Deployment</button>
|
|
41
|
+
</app-topbar>
|
|
42
|
+
|
|
43
|
+
<div class="app-content">
|
|
44
|
+
<div>
|
|
45
|
+
<h1 class="page-title">Executive Overview</h1>
|
|
46
|
+
<p class="page-sub">Performance metrics and active client deployments</p>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div class="stats-row">
|
|
50
|
+
<stat-card label="Monthly Recurring Revenue" value="$184,290" trend="▲ +12.4% vs last mo"></stat-card>
|
|
51
|
+
<stat-card label="Active Organizations" value="1,428" trend="▲ +48 new"></stat-card>
|
|
52
|
+
<stat-card label="API Requests (24h)" value="42.8M" trend="▲ 99.98% uptime"></stat-card>
|
|
53
|
+
<stat-card label="Average Latency" value="34ms" trend="▼ -2ms target" warn></stat-card>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div class="grid-2">
|
|
57
|
+
<div class="card">
|
|
58
|
+
<div class="panel-header">
|
|
59
|
+
<h2 class="panel-title">Throughput & Workload Trends</h2>
|
|
60
|
+
<span class="muted">Trailing 7 Days</span>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="chart-container">
|
|
63
|
+
<svg viewBox="0 0 500 150" width="100%" height="100%" preserveAspectRatio="none">
|
|
64
|
+
<defs>
|
|
65
|
+
<linearGradient id="chartGrad" x1="0" y1="0" x2="0" y2="1">
|
|
66
|
+
<stop offset="0%" stop-color="#6366f1" stop-opacity="0.5"/>
|
|
67
|
+
<stop offset="100%" stop-color="#6366f1" stop-opacity="0.0"/>
|
|
68
|
+
</linearGradient>
|
|
69
|
+
</defs>
|
|
70
|
+
<path d="M 0 130 Q 80 70, 150 90 T 300 40 T 420 50 T 500 20 L 500 150 L 0 150 Z" fill="url(#chartGrad)"/>
|
|
71
|
+
<path d="M 0 130 Q 80 70, 150 90 T 300 40 T 420 50 T 500 20" fill="none" stroke="#6366f1" stroke-width="3"/>
|
|
72
|
+
<circle cx="500" cy="20" r="4" fill="#a855f7" stroke="#fff" stroke-width="2"/>
|
|
73
|
+
</svg>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div class="card">
|
|
78
|
+
<div class="panel-header">
|
|
79
|
+
<h2 class="panel-title">Recent Audits</h2>
|
|
80
|
+
</div>
|
|
81
|
+
<table class="table">
|
|
82
|
+
<thead>
|
|
83
|
+
<tr>
|
|
84
|
+
<th>Service</th>
|
|
85
|
+
<th>Status</th>
|
|
86
|
+
</tr>
|
|
87
|
+
</thead>
|
|
88
|
+
<tbody>
|
|
89
|
+
<tr>
|
|
90
|
+
<td>us-east-cluster-01</td>
|
|
91
|
+
<td><span class="badge badge-emerald">Optimal</span></td>
|
|
92
|
+
</tr>
|
|
93
|
+
<tr>
|
|
94
|
+
<td>auth-gateway-proxy</td>
|
|
95
|
+
<td><span class="badge badge-emerald">Optimal</span></td>
|
|
96
|
+
</tr>
|
|
97
|
+
<tr>
|
|
98
|
+
<td>edge-cache-frankfurt</td>
|
|
99
|
+
<td><span class="badge badge-amber">Rebalancing</span></td>
|
|
100
|
+
</tr>
|
|
101
|
+
<tr>
|
|
102
|
+
<td>db-replica-primary</td>
|
|
103
|
+
<td><span class="badge badge-emerald">Optimal</span></td>
|
|
104
|
+
</tr>
|
|
105
|
+
</tbody>
|
|
106
|
+
</table>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</main>
|
|
111
|
+
</body>
|
|
112
|
+
</html>
|