htmlboard 0.1.0 → 0.2.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 +44 -27
- package/bin/htmlboard.js +10 -13
- package/package.json +2 -2
- package/skills/htmlboard/SKILL.md +4 -4
- package/src/client/explorer.css +268 -599
- package/src/client/explorer.js +863 -604
- package/src/client/favicon.svg +1 -0
- package/src/client/fonts/OFL-bricolage-grotesque.txt +93 -0
- package/src/client/fonts/OFL-inter.txt +93 -0
- package/src/client/fonts/bricolage-grotesque.woff2 +0 -0
- package/src/client/fonts/inter.woff2 +0 -0
- package/src/client/index.html +85 -175
- package/src/client/logo.svg +1 -0
- package/src/client/mark.svg +1 -0
- package/src/index.js +2 -1
- package/src/server.js +106 -28
package/src/server.js
CHANGED
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
const __filename = fileURLToPath(import.meta.url);
|
|
8
8
|
const __dirname = path.dirname(__filename);
|
|
9
9
|
const CLIENT_DIR = path.join(__dirname, 'client');
|
|
10
|
+
const { version: VERSION } = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
10
11
|
|
|
11
12
|
// Supported MIME types
|
|
12
13
|
const MIME_TYPES = {
|
|
@@ -234,6 +235,89 @@ export function loadDesignsManifest(baseDir, rootDir = baseDir) {
|
|
|
234
235
|
return normalizeManifest(discoverDesigns(baseDir), prefix);
|
|
235
236
|
}
|
|
236
237
|
|
|
238
|
+
/** Folders the watcher leaves alone: hidden ones (.git, .vscode, …) and dependencies */
|
|
239
|
+
const skipWatching = (name) => name.startsWith('.') || name === 'node_modules';
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Watch a folder and everything under it, calling onChange(path, isFolder) with the path (relative,
|
|
243
|
+
* with forward slashes) of every file or folder created, changed or removed.
|
|
244
|
+
* macOS and Windows watch a whole tree natively. On Linux Node emulates that with one watcher per
|
|
245
|
+
* file, which loses a file for good once it is saved by replacing it (writing a copy and renaming
|
|
246
|
+
* it over, as many editors do), so there every folder gets its own watcher instead.
|
|
247
|
+
* Returns a function that stops watching.
|
|
248
|
+
*/
|
|
249
|
+
export function watchTree(rootDir, onChange) {
|
|
250
|
+
const relative = (full) => path.relative(rootDir, full).split(path.sep).join('/');
|
|
251
|
+
const skipped = (rel) => rel.split('/').some(skipWatching);
|
|
252
|
+
|
|
253
|
+
if (process.platform !== 'linux') {
|
|
254
|
+
const watcher = fs.watch(rootDir, { recursive: true }, (eventType, filename) => {
|
|
255
|
+
if (!filename) return;
|
|
256
|
+
const rel = String(filename).split(path.sep).join('/');
|
|
257
|
+
if (skipped(rel)) return;
|
|
258
|
+
let isFolder;
|
|
259
|
+
try {
|
|
260
|
+
isFolder = fs.statSync(path.join(rootDir, rel)).isDirectory();
|
|
261
|
+
} catch {
|
|
262
|
+
isFolder = path.extname(rel) === ''; // gone: a name without an extension was likely a folder
|
|
263
|
+
}
|
|
264
|
+
onChange(rel, isFolder);
|
|
265
|
+
});
|
|
266
|
+
return () => watcher.close();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const watchers = new Map(); // folder → its watcher
|
|
270
|
+
const unwatch = (dir) => {
|
|
271
|
+
for (const [watched, watcher] of watchers) {
|
|
272
|
+
if (watched === dir || watched.startsWith(dir + path.sep)) {
|
|
273
|
+
watcher.close();
|
|
274
|
+
watchers.delete(watched);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
const watch = (dir) => {
|
|
279
|
+
if (watchers.has(dir)) return;
|
|
280
|
+
let watcher;
|
|
281
|
+
try {
|
|
282
|
+
watcher = fs.watch(dir, (eventType, name) => {
|
|
283
|
+
if (!name) return;
|
|
284
|
+
const full = path.join(dir, String(name));
|
|
285
|
+
const rel = relative(full);
|
|
286
|
+
if (!rel || rel.startsWith('..') || skipped(rel)) return;
|
|
287
|
+
let isDir = false;
|
|
288
|
+
try {
|
|
289
|
+
isDir = fs.statSync(full).isDirectory();
|
|
290
|
+
} catch {
|
|
291
|
+
// Removed
|
|
292
|
+
}
|
|
293
|
+
const wasDir = watchers.has(full);
|
|
294
|
+
if (isDir) watch(full);
|
|
295
|
+
else if (wasDir) unwatch(full);
|
|
296
|
+
onChange(rel, isDir || wasDir);
|
|
297
|
+
});
|
|
298
|
+
} catch {
|
|
299
|
+
return; // gone already, or unreadable
|
|
300
|
+
}
|
|
301
|
+
watcher.on('error', () => unwatch(dir));
|
|
302
|
+
watchers.set(dir, watcher);
|
|
303
|
+
let entries = [];
|
|
304
|
+
try {
|
|
305
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
306
|
+
} catch {
|
|
307
|
+
// Unreadable: its own watcher still reports what happens in it
|
|
308
|
+
}
|
|
309
|
+
for (const entry of entries) {
|
|
310
|
+
if (entry.isDirectory() && !skipWatching(entry.name)) watch(path.join(dir, entry.name));
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
watch(rootDir);
|
|
315
|
+
return () => {
|
|
316
|
+
for (const watcher of watchers.values()) watcher.close();
|
|
317
|
+
watchers.clear();
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
237
321
|
/**
|
|
238
322
|
* Find the next available port using net.createServer
|
|
239
323
|
*/
|
|
@@ -309,7 +393,7 @@ export function createServer(options = {}) {
|
|
|
309
393
|
// API: Health status
|
|
310
394
|
if (pathname === '/api/health') {
|
|
311
395
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
312
|
-
res.end(JSON.stringify({ status: 'ok', dir: targetDir, root: rootDir }));
|
|
396
|
+
res.end(JSON.stringify({ status: 'ok', version: VERSION, dir: targetDir, root: rootDir }));
|
|
313
397
|
return;
|
|
314
398
|
}
|
|
315
399
|
|
|
@@ -369,37 +453,30 @@ export function createServer(options = {}) {
|
|
|
369
453
|
}
|
|
370
454
|
}
|
|
371
455
|
|
|
372
|
-
//
|
|
456
|
+
// Live reload. Saves come in bursts (an editor writing a file several times, an agent saving a
|
|
457
|
+
// page and its stylesheet together), so changes are collected for 100 ms and then sent once each.
|
|
458
|
+
const changed = new Map(); // path → is a folder
|
|
373
459
|
let watchDebounceTimer = null;
|
|
374
|
-
let
|
|
460
|
+
let stopWatching = null;
|
|
375
461
|
|
|
376
462
|
try {
|
|
377
|
-
|
|
378
|
-
if (
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
// Ignore noise: hidden files and folders (.git, .vscode, …), dependencies, editor temp files
|
|
382
|
-
if (
|
|
383
|
-
normalized.split('/').some((part) => part.startsWith('.') || part === 'node_modules') ||
|
|
384
|
-
normalized.endsWith('~') ||
|
|
385
|
-
normalized.endsWith('.tmp')
|
|
386
|
-
) {
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
|
|
463
|
+
stopWatching = watchTree(rootDir, (file, isFolder) => {
|
|
464
|
+
if (file.endsWith('~') || file.endsWith('.tmp')) return; // editor scratch files
|
|
465
|
+
changed.set(file, isFolder);
|
|
390
466
|
clearTimeout(watchDebounceTimer);
|
|
391
467
|
watchDebounceTimer = setTimeout(() => {
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
468
|
+
const files = [...changed];
|
|
469
|
+
changed.clear();
|
|
470
|
+
for (const [file, isFolder] of files) {
|
|
471
|
+
const eventData = JSON.stringify({
|
|
472
|
+
type: file === manifestFile ? 'manifest' : isFolder ? 'folder' : 'file',
|
|
473
|
+
file,
|
|
474
|
+
isHtml: !isFolder && /\.html?$/i.test(file),
|
|
475
|
+
timestamp: Date.now()
|
|
476
|
+
});
|
|
477
|
+
for (const client of clients) {
|
|
478
|
+
client.write(`event: change\ndata: ${eventData}\n\n`);
|
|
479
|
+
}
|
|
403
480
|
}
|
|
404
481
|
}, 100);
|
|
405
482
|
});
|
|
@@ -412,7 +489,8 @@ export function createServer(options = {}) {
|
|
|
412
489
|
targetDir,
|
|
413
490
|
rootDir,
|
|
414
491
|
close: () => {
|
|
415
|
-
|
|
492
|
+
clearTimeout(watchDebounceTimer);
|
|
493
|
+
if (stopWatching) stopWatching();
|
|
416
494
|
for (const client of clients) {
|
|
417
495
|
client.end();
|
|
418
496
|
}
|