htmlboard 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +306 -2
- package/bin/htmlboard.js +294 -0
- package/package.json +48 -4
- package/skills/htmlboard/SKILL.md +95 -0
- package/src/client/explorer.css +372 -0
- package/src/client/explorer.js +952 -0
- 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 +101 -0
- package/src/client/logo.svg +1 -0
- package/src/client/mark.svg +1 -0
- package/src/index.js +21 -0
- package/src/server.js +529 -0
- package/src/skills.js +116 -0
- package/src/templates/artboards/components.html +199 -0
- package/src/templates/artboards/desktop.html +397 -0
- package/src/templates/artboards/mobile.html +383 -0
- package/src/templates/designs.json +50 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
const CLIENT_DIR = path.join(__dirname, 'client');
|
|
10
|
+
const { version: VERSION } = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
11
|
+
|
|
12
|
+
// Supported MIME types
|
|
13
|
+
const MIME_TYPES = {
|
|
14
|
+
'.html': 'text/html; charset=utf-8',
|
|
15
|
+
'.htm': 'text/html; charset=utf-8',
|
|
16
|
+
'.css': 'text/css; charset=utf-8',
|
|
17
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
18
|
+
'.mjs': 'application/javascript; charset=utf-8',
|
|
19
|
+
'.json': 'application/json; charset=utf-8',
|
|
20
|
+
'.png': 'image/png',
|
|
21
|
+
'.jpg': 'image/jpeg',
|
|
22
|
+
'.jpeg': 'image/jpeg',
|
|
23
|
+
'.gif': 'image/gif',
|
|
24
|
+
'.svg': 'image/svg+xml',
|
|
25
|
+
'.ico': 'image/x-icon',
|
|
26
|
+
'.webp': 'image/webp',
|
|
27
|
+
'.woff': 'font/woff',
|
|
28
|
+
'.woff2': 'font/woff2',
|
|
29
|
+
'.ttf': 'font/ttf',
|
|
30
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
31
|
+
'.mp4': 'video/mp4'
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const IGNORED_DIRS = new Set([
|
|
35
|
+
'node_modules',
|
|
36
|
+
'.git',
|
|
37
|
+
'.github',
|
|
38
|
+
'dist',
|
|
39
|
+
'build',
|
|
40
|
+
'.vscode',
|
|
41
|
+
'.idea',
|
|
42
|
+
'.gemini',
|
|
43
|
+
'.agents'
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extract <title> content from HTML text if available
|
|
48
|
+
*/
|
|
49
|
+
function extractTitleFromHtml(content, fallbackName) {
|
|
50
|
+
const match = content.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
51
|
+
if (match && match[1]?.trim()) {
|
|
52
|
+
return match[1].trim();
|
|
53
|
+
}
|
|
54
|
+
return fallbackName
|
|
55
|
+
.replace(/\.html$/i, '')
|
|
56
|
+
.replace(/[-_]/g, ' ')
|
|
57
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Default frame size for each category */
|
|
61
|
+
const CATEGORY_SIZES = {
|
|
62
|
+
mobile: [390, 844],
|
|
63
|
+
tablet: [768, 1024],
|
|
64
|
+
components: [720, 600],
|
|
65
|
+
desktop: [1280, 800],
|
|
66
|
+
responsive: [1200, 800]
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Guess an artboard's category from words in its path, then from its width
|
|
71
|
+
*/
|
|
72
|
+
export function inferCategory(file, width) {
|
|
73
|
+
const lower = file.toLowerCase();
|
|
74
|
+
if (/mobile|phone|iphone/.test(lower)) return 'mobile';
|
|
75
|
+
if (/tablet|ipad/.test(lower)) return 'tablet';
|
|
76
|
+
if (/component|\bui\b|atomic/.test(lower)) return 'components';
|
|
77
|
+
if (/desktop|laptop|screen/.test(lower)) return 'desktop';
|
|
78
|
+
if (width) {
|
|
79
|
+
if (width <= 480) return 'mobile';
|
|
80
|
+
if (width <= 1024) return 'tablet';
|
|
81
|
+
return 'desktop';
|
|
82
|
+
}
|
|
83
|
+
return 'responsive';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function slugify(text) {
|
|
87
|
+
return String(text).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
88
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** True when target is base itself or inside it */
|
|
92
|
+
function isInside(base, target) {
|
|
93
|
+
const rel = path.relative(base, target);
|
|
94
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Scan target directory recursively for HTML files to auto-discover
|
|
99
|
+
*/
|
|
100
|
+
export function discoverDesigns(baseDir) {
|
|
101
|
+
const artboards = [];
|
|
102
|
+
|
|
103
|
+
function scan(currentDir, relativePath = '') {
|
|
104
|
+
let entries;
|
|
105
|
+
try {
|
|
106
|
+
entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
107
|
+
} catch {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
if (entry.name.startsWith('.') && entry.name !== '.') continue;
|
|
113
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
114
|
+
|
|
115
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
116
|
+
const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
|
|
117
|
+
|
|
118
|
+
if (entry.isDirectory()) {
|
|
119
|
+
scan(fullPath, relPath);
|
|
120
|
+
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.html')) {
|
|
121
|
+
let title = '';
|
|
122
|
+
let fileContent = '';
|
|
123
|
+
try {
|
|
124
|
+
fileContent = fs.readFileSync(fullPath, 'utf8');
|
|
125
|
+
title = extractTitleFromHtml(fileContent, entry.name);
|
|
126
|
+
} catch {
|
|
127
|
+
title = entry.name;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const category = inferCategory(relPath);
|
|
131
|
+
const [width, height] = CATEGORY_SIZES[category];
|
|
132
|
+
|
|
133
|
+
const sectionName = relativePath ? relativePath.replace(/[\\/]/g, ' / ') : 'Root Designs';
|
|
134
|
+
|
|
135
|
+
artboards.push({
|
|
136
|
+
id: slugify(relPath.replace(/\.html$/i, '')),
|
|
137
|
+
title,
|
|
138
|
+
file: relPath.replace(/\\/g, '/'),
|
|
139
|
+
category,
|
|
140
|
+
width,
|
|
141
|
+
height,
|
|
142
|
+
sectionName,
|
|
143
|
+
tags: [category]
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
scan(baseDir);
|
|
150
|
+
|
|
151
|
+
// Group into sections
|
|
152
|
+
const sectionsMap = new Map();
|
|
153
|
+
for (const board of artboards) {
|
|
154
|
+
const sName = board.sectionName;
|
|
155
|
+
if (!sectionsMap.has(sName)) {
|
|
156
|
+
sectionsMap.set(sName, {
|
|
157
|
+
id: sName.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
|
158
|
+
name: sName,
|
|
159
|
+
description: `HTML artboards discovered in ${sName}`,
|
|
160
|
+
artboards: []
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
const { sectionName, ...rest } = board;
|
|
164
|
+
sectionsMap.get(sName).artboards.push(rest);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const projectName = path.basename(path.resolve(baseDir)) || 'Design Explorer';
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
title: projectName,
|
|
171
|
+
description: 'Auto-discovered HTML artboards in workspace',
|
|
172
|
+
autoDiscovered: true,
|
|
173
|
+
sections: Array.from(sectionsMap.values())
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Fill in what a manifest may leave out, so the explorer always gets
|
|
179
|
+
* `sections[].{id, name, artboards[].{id, title, file, category}}`.
|
|
180
|
+
* Accepts the short form too: `sections[].title` and `sections[].designs[]`
|
|
181
|
+
* with only `{ file, title, width, height }`.
|
|
182
|
+
* `prefix` is prepended to every file (the designs folder relative to the served root).
|
|
183
|
+
*/
|
|
184
|
+
export function normalizeManifest(manifest, prefix = '') {
|
|
185
|
+
const usedIds = new Set();
|
|
186
|
+
const uniqueId = (wanted) => {
|
|
187
|
+
let id = wanted || 'artboard';
|
|
188
|
+
for (let n = 2; usedIds.has(id); n++) id = `${wanted}-${n}`;
|
|
189
|
+
usedIds.add(id);
|
|
190
|
+
return id;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const sections = (Array.isArray(manifest.sections) ? manifest.sections : []).map((section, i) => {
|
|
194
|
+
const name = section.name ?? section.title ?? `Section ${i + 1}`;
|
|
195
|
+
const boards = section.artboards ?? section.designs ?? [];
|
|
196
|
+
return {
|
|
197
|
+
...section,
|
|
198
|
+
id: section.id ?? (slugify(name) || `section-${i + 1}`),
|
|
199
|
+
name,
|
|
200
|
+
artboards: boards.filter((board) => board && board.file).map((board) => {
|
|
201
|
+
const file = prefix ? path.posix.join(prefix, board.file) : board.file;
|
|
202
|
+
return {
|
|
203
|
+
...board,
|
|
204
|
+
id: uniqueId(board.id ?? slugify(board.file.replace(/\.html?(?=$|\?)/i, ''))),
|
|
205
|
+
title: board.title ?? board.file,
|
|
206
|
+
file,
|
|
207
|
+
category: board.category ?? inferCategory(board.file.split('?')[0], board.width)
|
|
208
|
+
};
|
|
209
|
+
})
|
|
210
|
+
};
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return { ...manifest, sections };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Load designs configuration from designs.json or auto-discover.
|
|
218
|
+
* With `rootDir` (a folder containing baseDir), artboard paths are given relative to it.
|
|
219
|
+
*/
|
|
220
|
+
export function loadDesignsManifest(baseDir, rootDir = baseDir) {
|
|
221
|
+
const prefix = path.relative(rootDir, baseDir).split(path.sep).join('/');
|
|
222
|
+
const configPath = path.join(baseDir, 'designs.json');
|
|
223
|
+
if (fs.existsSync(configPath)) {
|
|
224
|
+
try {
|
|
225
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
226
|
+
const data = JSON.parse(raw);
|
|
227
|
+
return {
|
|
228
|
+
...normalizeManifest(data, prefix),
|
|
229
|
+
autoDiscovered: false
|
|
230
|
+
};
|
|
231
|
+
} catch (err) {
|
|
232
|
+
console.warn(`[htmlboard] Failed parsing designs.json: ${err.message}. Falling back to auto-discovery.`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return normalizeManifest(discoverDesigns(baseDir), prefix);
|
|
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
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Find the next available port using net.createServer
|
|
323
|
+
*/
|
|
324
|
+
export function findAvailablePort(startPort) {
|
|
325
|
+
return new Promise((resolve, reject) => {
|
|
326
|
+
const tester = net.createServer();
|
|
327
|
+
tester.once('error', (err) => {
|
|
328
|
+
if (err.code === 'EADDRINUSE') {
|
|
329
|
+
resolve(findAvailablePort(startPort + 1));
|
|
330
|
+
} else {
|
|
331
|
+
reject(err);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
tester.once('listening', () => {
|
|
335
|
+
const port = tester.address().port;
|
|
336
|
+
tester.close(() => resolve(port));
|
|
337
|
+
});
|
|
338
|
+
tester.listen(startPort);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Creates and starts the HtmlBoard server
|
|
344
|
+
*/
|
|
345
|
+
export function createServer(options = {}) {
|
|
346
|
+
const targetDir = path.resolve(options.dir || process.cwd());
|
|
347
|
+
// Files are served from rootDir, so artboards can link to shared files above the designs folder
|
|
348
|
+
const rootDir = path.resolve(options.root || targetDir);
|
|
349
|
+
if (!isInside(rootDir, targetDir)) {
|
|
350
|
+
throw new Error(`The designs folder (${targetDir}) must be inside the root (${rootDir})`);
|
|
351
|
+
}
|
|
352
|
+
const manifestFile = path.posix.join(path.relative(rootDir, targetDir).split(path.sep).join('/'), 'designs.json');
|
|
353
|
+
const clients = new Set();
|
|
354
|
+
|
|
355
|
+
const server = http.createServer((req, res) => {
|
|
356
|
+
const parsedUrl = new URL(req.url, 'http://localhost');
|
|
357
|
+
let pathname = decodeURIComponent(parsedUrl.pathname);
|
|
358
|
+
|
|
359
|
+
// API: Designs Manifest
|
|
360
|
+
if (pathname === '/api/designs') {
|
|
361
|
+
const manifest = loadDesignsManifest(targetDir, rootDir);
|
|
362
|
+
res.writeHead(200, {
|
|
363
|
+
'Content-Type': 'application/json',
|
|
364
|
+
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
|
365
|
+
});
|
|
366
|
+
res.end(JSON.stringify(manifest, null, 2));
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// API: SSE Live Reload stream
|
|
371
|
+
if (pathname === '/api/events') {
|
|
372
|
+
res.writeHead(200, {
|
|
373
|
+
'Content-Type': 'text/event-stream',
|
|
374
|
+
'Cache-Control': 'no-cache',
|
|
375
|
+
'Connection': 'keep-alive',
|
|
376
|
+
'Access-Control-Allow-Origin': '*'
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
res.write(`event: connected\ndata: ${JSON.stringify({ time: Date.now() })}\n\n`);
|
|
380
|
+
clients.add(res);
|
|
381
|
+
|
|
382
|
+
const heartbeat = setInterval(() => {
|
|
383
|
+
res.write(': ping\n\n');
|
|
384
|
+
}, 25000);
|
|
385
|
+
|
|
386
|
+
req.on('close', () => {
|
|
387
|
+
clearInterval(heartbeat);
|
|
388
|
+
clients.delete(res);
|
|
389
|
+
});
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// API: Health status
|
|
394
|
+
if (pathname === '/api/health') {
|
|
395
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
396
|
+
res.end(JSON.stringify({ status: 'ok', version: VERSION, dir: targetDir, root: rootDir }));
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Serve HtmlBoard Explorer client files
|
|
401
|
+
if (pathname === '/' || pathname === '/index.html') {
|
|
402
|
+
serveStaticFile(path.join(CLIENT_DIR, 'index.html'), res);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (pathname.startsWith('/__explorer/')) {
|
|
407
|
+
const clientRelPath = pathname.replace('/__explorer/', '');
|
|
408
|
+
const clientFilePath = path.join(CLIENT_DIR, clientRelPath);
|
|
409
|
+
// Ensure path is within CLIENT_DIR
|
|
410
|
+
if (!clientFilePath.startsWith(CLIENT_DIR)) {
|
|
411
|
+
res.writeHead(403).end('Forbidden');
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
serveStaticFile(clientFilePath, res);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Serve user artboards and assets from rootDir
|
|
419
|
+
let safePath = path.normalize(pathname).replace(/^(\.\.[\/\\])+/, '');
|
|
420
|
+
if (safePath.startsWith('/')) {
|
|
421
|
+
safePath = safePath.slice(1);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const filePath = path.join(rootDir, safePath);
|
|
425
|
+
|
|
426
|
+
// Security check: must stay within rootDir
|
|
427
|
+
if (!isInside(rootDir, filePath)) {
|
|
428
|
+
res.writeHead(403).end('Forbidden');
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
|
433
|
+
serveStaticFile(filePath, res);
|
|
434
|
+
} else {
|
|
435
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
436
|
+
res.end(`File not found: ${pathname}`);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
function serveStaticFile(filePath, res) {
|
|
441
|
+
try {
|
|
442
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
443
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
444
|
+
const content = fs.readFileSync(filePath);
|
|
445
|
+
res.writeHead(200, {
|
|
446
|
+
'Content-Type': contentType,
|
|
447
|
+
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
|
448
|
+
});
|
|
449
|
+
res.end(content);
|
|
450
|
+
} catch {
|
|
451
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
452
|
+
res.end('Error reading file');
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
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
|
|
459
|
+
let watchDebounceTimer = null;
|
|
460
|
+
let stopWatching = null;
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
stopWatching = watchTree(rootDir, (file, isFolder) => {
|
|
464
|
+
if (file.endsWith('~') || file.endsWith('.tmp')) return; // editor scratch files
|
|
465
|
+
changed.set(file, isFolder);
|
|
466
|
+
clearTimeout(watchDebounceTimer);
|
|
467
|
+
watchDebounceTimer = setTimeout(() => {
|
|
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
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}, 100);
|
|
482
|
+
});
|
|
483
|
+
} catch (err) {
|
|
484
|
+
console.warn(`[htmlboard] Live watcher error: ${err.message}`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
server,
|
|
489
|
+
targetDir,
|
|
490
|
+
rootDir,
|
|
491
|
+
close: () => {
|
|
492
|
+
clearTimeout(watchDebounceTimer);
|
|
493
|
+
if (stopWatching) stopWatching();
|
|
494
|
+
for (const client of clients) {
|
|
495
|
+
client.end();
|
|
496
|
+
}
|
|
497
|
+
return new Promise((resolve) => server.close(resolve));
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Start listening on port with auto-retry if in use
|
|
504
|
+
*/
|
|
505
|
+
export async function startServer(options = {}) {
|
|
506
|
+
const requestedPort = parseInt(options.port, 10) || 3000;
|
|
507
|
+
const actualPort = await findAvailablePort(requestedPort);
|
|
508
|
+
|
|
509
|
+
if (actualPort !== requestedPort) {
|
|
510
|
+
console.log(`[htmlboard] Port ${requestedPort} is in use, using ${actualPort}...`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const { server, targetDir, rootDir, close } = createServer(options);
|
|
514
|
+
|
|
515
|
+
return new Promise((resolve, reject) => {
|
|
516
|
+
server.once('error', reject);
|
|
517
|
+
server.listen(actualPort, () => {
|
|
518
|
+
const url = `http://localhost:${actualPort}`;
|
|
519
|
+
resolve({
|
|
520
|
+
server,
|
|
521
|
+
port: actualPort,
|
|
522
|
+
url,
|
|
523
|
+
targetDir,
|
|
524
|
+
rootDir,
|
|
525
|
+
close
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
}
|
package/src/skills.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
|
|
9
|
+
/** Agent skills bundled with the package, one folder per skill */
|
|
10
|
+
export const SKILLS_DIR = path.join(__dirname, '..', 'skills');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Where each agent looks for skills, relative to the project root (or the home folder with `global`).
|
|
14
|
+
* `agents` is the shared Agent Skills location read by Codex and other agents.
|
|
15
|
+
*/
|
|
16
|
+
export const SKILL_TARGETS = {
|
|
17
|
+
claude: path.join('.claude', 'skills'),
|
|
18
|
+
agents: path.join('.agents', 'skills')
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function bundledSkills() {
|
|
22
|
+
return fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
|
|
23
|
+
.filter((entry) => entry.isDirectory() && fs.existsSync(path.join(SKILLS_DIR, entry.name, 'SKILL.md')))
|
|
24
|
+
.map((entry) => entry.name);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveTargets(agent = 'all') {
|
|
28
|
+
if (agent === 'all') return Object.keys(SKILL_TARGETS);
|
|
29
|
+
if (!SKILL_TARGETS[agent]) {
|
|
30
|
+
throw new Error(`Unknown agent "${agent}". Use one of: ${[...Object.keys(SKILL_TARGETS), 'all'].join(', ')}`);
|
|
31
|
+
}
|
|
32
|
+
return [agent];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** List every file under dir as paths relative to it */
|
|
36
|
+
function listFiles(dir, prefix = '') {
|
|
37
|
+
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
38
|
+
const rel = path.join(prefix, entry.name);
|
|
39
|
+
return entry.isDirectory() ? listFiles(path.join(dir, entry.name), rel) : [rel];
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sameTree(a, b) {
|
|
44
|
+
const filesA = listFiles(a).sort();
|
|
45
|
+
const filesB = listFiles(b).sort();
|
|
46
|
+
return filesA.length === filesB.length &&
|
|
47
|
+
filesA.every((file, i) => file === filesB[i] &&
|
|
48
|
+
fs.readFileSync(path.join(a, file)).equals(fs.readFileSync(path.join(b, file))));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Copy the bundled skills into the agents' skill folders.
|
|
53
|
+
* An existing skill folder that differs from the bundled one is left alone unless `force` is set.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} [options]
|
|
56
|
+
* @param {'claude'|'agents'|'all'} [options.agent='all']
|
|
57
|
+
* @param {boolean} [options.global=false] Install under the home folder instead of the project
|
|
58
|
+
* @param {string} [options.dir=process.cwd()] Project root (ignored with `global`)
|
|
59
|
+
* @param {boolean} [options.force=false] Replace skills that were changed locally
|
|
60
|
+
* @returns {{skill: string, path: string, status: 'installed'|'updated'|'unchanged'|'skipped'}[]}
|
|
61
|
+
*/
|
|
62
|
+
export function installSkills({ agent = 'all', global = false, dir = process.cwd(), force = false } = {}) {
|
|
63
|
+
const base = global ? os.homedir() : path.resolve(dir);
|
|
64
|
+
const results = [];
|
|
65
|
+
|
|
66
|
+
for (const target of resolveTargets(agent)) {
|
|
67
|
+
for (const skill of bundledSkills()) {
|
|
68
|
+
const src = path.join(SKILLS_DIR, skill);
|
|
69
|
+
const dest = path.join(base, SKILL_TARGETS[target], skill);
|
|
70
|
+
let status = 'installed';
|
|
71
|
+
|
|
72
|
+
if (fs.existsSync(dest)) {
|
|
73
|
+
if (sameTree(src, dest)) {
|
|
74
|
+
results.push({ skill, path: dest, status: 'unchanged' });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (!force) {
|
|
78
|
+
results.push({ skill, path: dest, status: 'skipped' });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
82
|
+
status = 'updated';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
86
|
+
fs.cpSync(src, dest, { recursive: true });
|
|
87
|
+
results.push({ skill, path: dest, status });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return results;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Remove the bundled skills from the agents' skill folders.
|
|
96
|
+
* Takes the same options as installSkills (except `force`).
|
|
97
|
+
* @returns {{skill: string, path: string, status: 'removed'|'absent'}[]}
|
|
98
|
+
*/
|
|
99
|
+
export function uninstallSkills({ agent = 'all', global = false, dir = process.cwd() } = {}) {
|
|
100
|
+
const base = global ? os.homedir() : path.resolve(dir);
|
|
101
|
+
const results = [];
|
|
102
|
+
|
|
103
|
+
for (const target of resolveTargets(agent)) {
|
|
104
|
+
for (const skill of bundledSkills()) {
|
|
105
|
+
const dest = path.join(base, SKILL_TARGETS[target], skill);
|
|
106
|
+
if (fs.existsSync(dest)) {
|
|
107
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
108
|
+
results.push({ skill, path: dest, status: 'removed' });
|
|
109
|
+
} else {
|
|
110
|
+
results.push({ skill, path: dest, status: 'absent' });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return results;
|
|
116
|
+
}
|