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/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
- function attributes(tag) {
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
- /** <meta name="htmlboard:variant" content="?tema=escuro · escuro">: the query, then a label */
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 [, first, rest] = content.match(/^(\S+)\s*(.*)$/s);
121
- const query = first.startsWith('?') ? first : `?${first}`;
122
- if (query === '?') return null;
123
- return { query, label: rest.replace(/^[·|:,\-–—]+\s*/, '').trim() };
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
- function variantDesign(base, { query, label }) {
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
- key: base.file + query,
234
+ ...size,
235
+ key: base.file + query + (sized ? `@${size.width}x${size.height ?? ''}` : ''),
198
236
  file: base.file + query,
199
- title: `${base.baseTitle} · ${label || query.slice(1)}`,
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
- /** designs.json's sections filled from the files, then the designs it doesn't list */
278
- function curate(config, found) {
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
- if (config.unlisted !== 'hide') sections.push(...groupByFolder(rest, 'Unlisted'));
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(v.query + (v.label ? SEP + v.label : ''))}">`);
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 manifestFile = path.posix.join(path.relative(rootDir, targetDir).split(path.sep).join('/'), 'designs.json');
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
- const content = fs.readFileSync(filePath);
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
- const eventData = JSON.stringify({
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>