htmlboard 0.1.0 → 0.3.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 +120 -92
- package/bin/htmlboard.js +93 -59
- package/package.json +2 -2
- package/skills/htmlboard/SKILL.md +27 -33
- package/src/client/explorer.css +302 -598
- package/src/client/explorer.js +983 -601
- 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 +87 -175
- package/src/client/logo.svg +1 -0
- package/src/client/mark.svg +1 -0
- package/src/designs.js +398 -0
- package/src/index.js +8 -2
- package/src/migrate.js +193 -0
- package/src/server.js +93 -212
- package/src/templates/{artboards/desktop.html → 01-screens/01-desktop.html} +2 -0
- package/src/templates/{artboards/mobile.html → 01-screens/02-mobile.html} +2 -0
- package/src/templates/{artboards/components.html → 02-components/01-ui-kit.html} +2 -0
- package/src/templates/designs.json +0 -50
package/src/server.js
CHANGED
|
@@ -3,10 +3,12 @@ import net from 'node:net';
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { loadDesignsManifest } from './designs.js';
|
|
6
7
|
|
|
7
8
|
const __filename = fileURLToPath(import.meta.url);
|
|
8
9
|
const __dirname = path.dirname(__filename);
|
|
9
10
|
const CLIENT_DIR = path.join(__dirname, 'client');
|
|
11
|
+
const { version: VERSION } = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
10
12
|
|
|
11
13
|
// Supported MIME types
|
|
12
14
|
const MIME_TYPES = {
|
|
@@ -30,208 +32,93 @@ const MIME_TYPES = {
|
|
|
30
32
|
'.mp4': 'video/mp4'
|
|
31
33
|
};
|
|
32
34
|
|
|
33
|
-
const IGNORED_DIRS = new Set([
|
|
34
|
-
'node_modules',
|
|
35
|
-
'.git',
|
|
36
|
-
'.github',
|
|
37
|
-
'dist',
|
|
38
|
-
'build',
|
|
39
|
-
'.vscode',
|
|
40
|
-
'.idea',
|
|
41
|
-
'.gemini',
|
|
42
|
-
'.agents'
|
|
43
|
-
]);
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Extract <title> content from HTML text if available
|
|
47
|
-
*/
|
|
48
|
-
function extractTitleFromHtml(content, fallbackName) {
|
|
49
|
-
const match = content.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
50
|
-
if (match && match[1]?.trim()) {
|
|
51
|
-
return match[1].trim();
|
|
52
|
-
}
|
|
53
|
-
return fallbackName
|
|
54
|
-
.replace(/\.html$/i, '')
|
|
55
|
-
.replace(/[-_]/g, ' ')
|
|
56
|
-
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Default frame size for each category */
|
|
60
|
-
const CATEGORY_SIZES = {
|
|
61
|
-
mobile: [390, 844],
|
|
62
|
-
tablet: [768, 1024],
|
|
63
|
-
components: [720, 600],
|
|
64
|
-
desktop: [1280, 800],
|
|
65
|
-
responsive: [1200, 800]
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Guess an artboard's category from words in its path, then from its width
|
|
70
|
-
*/
|
|
71
|
-
export function inferCategory(file, width) {
|
|
72
|
-
const lower = file.toLowerCase();
|
|
73
|
-
if (/mobile|phone|iphone/.test(lower)) return 'mobile';
|
|
74
|
-
if (/tablet|ipad/.test(lower)) return 'tablet';
|
|
75
|
-
if (/component|\bui\b|atomic/.test(lower)) return 'components';
|
|
76
|
-
if (/desktop|laptop|screen/.test(lower)) return 'desktop';
|
|
77
|
-
if (width) {
|
|
78
|
-
if (width <= 480) return 'mobile';
|
|
79
|
-
if (width <= 1024) return 'tablet';
|
|
80
|
-
return 'desktop';
|
|
81
|
-
}
|
|
82
|
-
return 'responsive';
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function slugify(text) {
|
|
86
|
-
return String(text).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
87
|
-
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
88
|
-
}
|
|
89
|
-
|
|
90
35
|
/** True when target is base itself or inside it */
|
|
91
36
|
function isInside(base, target) {
|
|
92
37
|
const rel = path.relative(base, target);
|
|
93
38
|
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
94
39
|
}
|
|
95
40
|
|
|
41
|
+
/** Folders the watcher leaves alone: hidden ones (.git, .vscode, …) and dependencies */
|
|
42
|
+
const skipWatching = (name) => name.startsWith('.') || name === 'node_modules';
|
|
43
|
+
|
|
96
44
|
/**
|
|
97
|
-
*
|
|
45
|
+
* Watch a folder and everything under it, calling onChange(path, isFolder) with the path (relative,
|
|
46
|
+
* with forward slashes) of every file or folder created, changed or removed.
|
|
47
|
+
* macOS and Windows watch a whole tree natively. On Linux Node emulates that with one watcher per
|
|
48
|
+
* file, which loses a file for good once it is saved by replacing it (writing a copy and renaming
|
|
49
|
+
* it over, as many editors do), so there every folder gets its own watcher instead.
|
|
50
|
+
* Returns a function that stops watching.
|
|
98
51
|
*/
|
|
99
|
-
export function
|
|
100
|
-
const
|
|
52
|
+
export function watchTree(rootDir, onChange) {
|
|
53
|
+
const relative = (full) => path.relative(rootDir, full).split(path.sep).join('/');
|
|
54
|
+
const skipped = (rel) => rel.split('/').some(skipWatching);
|
|
101
55
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
56
|
+
if (process.platform !== 'linux') {
|
|
57
|
+
const watcher = fs.watch(rootDir, { recursive: true }, (eventType, filename) => {
|
|
58
|
+
if (!filename) return;
|
|
59
|
+
const rel = String(filename).split(path.sep).join('/');
|
|
60
|
+
if (skipped(rel)) return;
|
|
61
|
+
let isFolder;
|
|
62
|
+
try {
|
|
63
|
+
isFolder = fs.statSync(path.join(rootDir, rel)).isDirectory();
|
|
64
|
+
} catch {
|
|
65
|
+
isFolder = path.extname(rel) === ''; // gone: a name without an extension was likely a folder
|
|
66
|
+
}
|
|
67
|
+
onChange(rel, isFolder);
|
|
68
|
+
});
|
|
69
|
+
return () => watcher.close();
|
|
70
|
+
}
|
|
116
71
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
72
|
+
const watchers = new Map(); // folder → its watcher
|
|
73
|
+
const unwatch = (dir) => {
|
|
74
|
+
for (const [watched, watcher] of watchers) {
|
|
75
|
+
if (watched === dir || watched.startsWith(dir + path.sep)) {
|
|
76
|
+
watcher.close();
|
|
77
|
+
watchers.delete(watched);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const watch = (dir) => {
|
|
82
|
+
if (watchers.has(dir)) return;
|
|
83
|
+
let watcher;
|
|
84
|
+
try {
|
|
85
|
+
watcher = fs.watch(dir, (eventType, name) => {
|
|
86
|
+
if (!name) return;
|
|
87
|
+
const full = path.join(dir, String(name));
|
|
88
|
+
const rel = relative(full);
|
|
89
|
+
if (!rel || rel.startsWith('..') || skipped(rel)) return;
|
|
90
|
+
let isDir = false;
|
|
122
91
|
try {
|
|
123
|
-
|
|
124
|
-
title = extractTitleFromHtml(fileContent, entry.name);
|
|
92
|
+
isDir = fs.statSync(full).isDirectory();
|
|
125
93
|
} catch {
|
|
126
|
-
|
|
94
|
+
// Removed
|
|
127
95
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const sectionName = relativePath ? relativePath.replace(/[\\/]/g, ' / ') : 'Root Designs';
|
|
133
|
-
|
|
134
|
-
artboards.push({
|
|
135
|
-
id: slugify(relPath.replace(/\.html$/i, '')),
|
|
136
|
-
title,
|
|
137
|
-
file: relPath.replace(/\\/g, '/'),
|
|
138
|
-
category,
|
|
139
|
-
width,
|
|
140
|
-
height,
|
|
141
|
-
sectionName,
|
|
142
|
-
tags: [category]
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
scan(baseDir);
|
|
149
|
-
|
|
150
|
-
// Group into sections
|
|
151
|
-
const sectionsMap = new Map();
|
|
152
|
-
for (const board of artboards) {
|
|
153
|
-
const sName = board.sectionName;
|
|
154
|
-
if (!sectionsMap.has(sName)) {
|
|
155
|
-
sectionsMap.set(sName, {
|
|
156
|
-
id: sName.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
|
157
|
-
name: sName,
|
|
158
|
-
description: `HTML artboards discovered in ${sName}`,
|
|
159
|
-
artboards: []
|
|
96
|
+
const wasDir = watchers.has(full);
|
|
97
|
+
if (isDir) watch(full);
|
|
98
|
+
else if (wasDir) unwatch(full);
|
|
99
|
+
onChange(rel, isDir || wasDir);
|
|
160
100
|
});
|
|
101
|
+
} catch {
|
|
102
|
+
return; // gone already, or unreadable
|
|
103
|
+
}
|
|
104
|
+
watcher.on('error', () => unwatch(dir));
|
|
105
|
+
watchers.set(dir, watcher);
|
|
106
|
+
let entries = [];
|
|
107
|
+
try {
|
|
108
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
109
|
+
} catch {
|
|
110
|
+
// Unreadable: its own watcher still reports what happens in it
|
|
111
|
+
}
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
if (entry.isDirectory() && !skipWatching(entry.name)) watch(path.join(dir, entry.name));
|
|
161
114
|
}
|
|
162
|
-
const { sectionName, ...rest } = board;
|
|
163
|
-
sectionsMap.get(sName).artboards.push(rest);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const projectName = path.basename(path.resolve(baseDir)) || 'Design Explorer';
|
|
167
|
-
|
|
168
|
-
return {
|
|
169
|
-
title: projectName,
|
|
170
|
-
description: 'Auto-discovered HTML artboards in workspace',
|
|
171
|
-
autoDiscovered: true,
|
|
172
|
-
sections: Array.from(sectionsMap.values())
|
|
173
115
|
};
|
|
174
|
-
}
|
|
175
116
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
* with only `{ file, title, width, height }`.
|
|
181
|
-
* `prefix` is prepended to every file (the designs folder relative to the served root).
|
|
182
|
-
*/
|
|
183
|
-
export function normalizeManifest(manifest, prefix = '') {
|
|
184
|
-
const usedIds = new Set();
|
|
185
|
-
const uniqueId = (wanted) => {
|
|
186
|
-
let id = wanted || 'artboard';
|
|
187
|
-
for (let n = 2; usedIds.has(id); n++) id = `${wanted}-${n}`;
|
|
188
|
-
usedIds.add(id);
|
|
189
|
-
return id;
|
|
117
|
+
watch(rootDir);
|
|
118
|
+
return () => {
|
|
119
|
+
for (const watcher of watchers.values()) watcher.close();
|
|
120
|
+
watchers.clear();
|
|
190
121
|
};
|
|
191
|
-
|
|
192
|
-
const sections = (Array.isArray(manifest.sections) ? manifest.sections : []).map((section, i) => {
|
|
193
|
-
const name = section.name ?? section.title ?? `Section ${i + 1}`;
|
|
194
|
-
const boards = section.artboards ?? section.designs ?? [];
|
|
195
|
-
return {
|
|
196
|
-
...section,
|
|
197
|
-
id: section.id ?? (slugify(name) || `section-${i + 1}`),
|
|
198
|
-
name,
|
|
199
|
-
artboards: boards.filter((board) => board && board.file).map((board) => {
|
|
200
|
-
const file = prefix ? path.posix.join(prefix, board.file) : board.file;
|
|
201
|
-
return {
|
|
202
|
-
...board,
|
|
203
|
-
id: uniqueId(board.id ?? slugify(board.file.replace(/\.html?(?=$|\?)/i, ''))),
|
|
204
|
-
title: board.title ?? board.file,
|
|
205
|
-
file,
|
|
206
|
-
category: board.category ?? inferCategory(board.file.split('?')[0], board.width)
|
|
207
|
-
};
|
|
208
|
-
})
|
|
209
|
-
};
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
return { ...manifest, sections };
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Load designs configuration from designs.json or auto-discover.
|
|
217
|
-
* With `rootDir` (a folder containing baseDir), artboard paths are given relative to it.
|
|
218
|
-
*/
|
|
219
|
-
export function loadDesignsManifest(baseDir, rootDir = baseDir) {
|
|
220
|
-
const prefix = path.relative(rootDir, baseDir).split(path.sep).join('/');
|
|
221
|
-
const configPath = path.join(baseDir, 'designs.json');
|
|
222
|
-
if (fs.existsSync(configPath)) {
|
|
223
|
-
try {
|
|
224
|
-
const raw = fs.readFileSync(configPath, 'utf8');
|
|
225
|
-
const data = JSON.parse(raw);
|
|
226
|
-
return {
|
|
227
|
-
...normalizeManifest(data, prefix),
|
|
228
|
-
autoDiscovered: false
|
|
229
|
-
};
|
|
230
|
-
} catch (err) {
|
|
231
|
-
console.warn(`[htmlboard] Failed parsing designs.json: ${err.message}. Falling back to auto-discovery.`);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
return normalizeManifest(discoverDesigns(baseDir), prefix);
|
|
235
122
|
}
|
|
236
123
|
|
|
237
124
|
/**
|
|
@@ -309,7 +196,7 @@ export function createServer(options = {}) {
|
|
|
309
196
|
// API: Health status
|
|
310
197
|
if (pathname === '/api/health') {
|
|
311
198
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
312
|
-
res.end(JSON.stringify({ status: 'ok', dir: targetDir, root: rootDir }));
|
|
199
|
+
res.end(JSON.stringify({ status: 'ok', version: VERSION, dir: targetDir, root: rootDir }));
|
|
313
200
|
return;
|
|
314
201
|
}
|
|
315
202
|
|
|
@@ -369,37 +256,30 @@ export function createServer(options = {}) {
|
|
|
369
256
|
}
|
|
370
257
|
}
|
|
371
258
|
|
|
372
|
-
//
|
|
259
|
+
// Live reload. Saves come in bursts (an editor writing a file several times, an agent saving a
|
|
260
|
+
// page and its stylesheet together), so changes are collected for 100 ms and then sent once each.
|
|
261
|
+
const changed = new Map(); // path → is a folder
|
|
373
262
|
let watchDebounceTimer = null;
|
|
374
|
-
let
|
|
263
|
+
let stopWatching = null;
|
|
375
264
|
|
|
376
265
|
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
|
-
|
|
266
|
+
stopWatching = watchTree(rootDir, (file, isFolder) => {
|
|
267
|
+
if (file.endsWith('~') || file.endsWith('.tmp')) return; // editor scratch files
|
|
268
|
+
changed.set(file, isFolder);
|
|
390
269
|
clearTimeout(watchDebounceTimer);
|
|
391
270
|
watchDebounceTimer = setTimeout(() => {
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
271
|
+
const files = [...changed];
|
|
272
|
+
changed.clear();
|
|
273
|
+
for (const [file, isFolder] of files) {
|
|
274
|
+
const eventData = JSON.stringify({
|
|
275
|
+
type: file === manifestFile ? 'manifest' : isFolder ? 'folder' : 'file',
|
|
276
|
+
file,
|
|
277
|
+
isHtml: !isFolder && /\.html?$/i.test(file),
|
|
278
|
+
timestamp: Date.now()
|
|
279
|
+
});
|
|
280
|
+
for (const client of clients) {
|
|
281
|
+
client.write(`event: change\ndata: ${eventData}\n\n`);
|
|
282
|
+
}
|
|
403
283
|
}
|
|
404
284
|
}, 100);
|
|
405
285
|
});
|
|
@@ -412,7 +292,8 @@ export function createServer(options = {}) {
|
|
|
412
292
|
targetDir,
|
|
413
293
|
rootDir,
|
|
414
294
|
close: () => {
|
|
415
|
-
|
|
295
|
+
clearTimeout(watchDebounceTimer);
|
|
296
|
+
if (stopWatching) stopWatching();
|
|
416
297
|
for (const client of clients) {
|
|
417
298
|
client.end();
|
|
418
299
|
}
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>Desktop Dashboard</title>
|
|
7
|
+
<meta name="description" content="Main analytics dashboard with sidebar, metric cards, and charts">
|
|
8
|
+
<meta name="htmlboard" content="width=1280, height=820">
|
|
7
9
|
<style>
|
|
8
10
|
* {
|
|
9
11
|
box-sizing: border-box;
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>Mobile Feed Screen</title>
|
|
7
|
+
<meta name="description" content="Smartphone screen with balance card, quick actions, and transaction feed">
|
|
8
|
+
<meta name="htmlboard" content="width=390, height=844">
|
|
7
9
|
<style>
|
|
8
10
|
* {
|
|
9
11
|
box-sizing: border-box;
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>Component Library Preview</title>
|
|
7
|
+
<meta name="description" content="Buttons, inputs, badges, cards, and interactive elements">
|
|
8
|
+
<meta name="htmlboard" content="width=780, height=720, category=components">
|
|
7
9
|
<style>
|
|
8
10
|
* {
|
|
9
11
|
box-sizing: border-box;
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"title": "Design Exploration Canvas",
|
|
3
|
-
"description": "Infinite canvas and artboard explorer for HTML designs and components",
|
|
4
|
-
"sections": [
|
|
5
|
-
{
|
|
6
|
-
"id": "screens",
|
|
7
|
-
"name": "Screen Layouts",
|
|
8
|
-
"description": "Full page layouts across mobile and desktop viewports",
|
|
9
|
-
"artboards": [
|
|
10
|
-
{
|
|
11
|
-
"id": "desktop-dashboard",
|
|
12
|
-
"title": "Desktop Dashboard",
|
|
13
|
-
"description": "Main analytics dashboard with sidebar, metric cards, and charts",
|
|
14
|
-
"file": "artboards/desktop.html",
|
|
15
|
-
"width": 1280,
|
|
16
|
-
"height": 820,
|
|
17
|
-
"category": "desktop",
|
|
18
|
-
"tags": ["desktop", "dashboard", "analytics", "dark-mode"]
|
|
19
|
-
},
|
|
20
|
-
{
|
|
21
|
-
"id": "mobile-app",
|
|
22
|
-
"title": "Mobile App Feed",
|
|
23
|
-
"description": "Smartphone screen with balance card, quick actions, and transaction feed",
|
|
24
|
-
"file": "artboards/mobile.html",
|
|
25
|
-
"width": 390,
|
|
26
|
-
"height": 844,
|
|
27
|
-
"category": "mobile",
|
|
28
|
-
"tags": ["mobile", "ios", "feed", "finance"]
|
|
29
|
-
}
|
|
30
|
-
]
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
"id": "components",
|
|
34
|
-
"name": "Component Library",
|
|
35
|
-
"description": "Reusable atomic UI blocks and design system primitives",
|
|
36
|
-
"artboards": [
|
|
37
|
-
{
|
|
38
|
-
"id": "ui-primitives",
|
|
39
|
-
"title": "UI Component Kit",
|
|
40
|
-
"description": "Buttons, inputs, badges, cards, and interactive elements",
|
|
41
|
-
"file": "artboards/components.html",
|
|
42
|
-
"width": 780,
|
|
43
|
-
"height": 720,
|
|
44
|
-
"category": "components",
|
|
45
|
-
"tags": ["design-system", "buttons", "badges", "inputs"]
|
|
46
|
-
}
|
|
47
|
-
]
|
|
48
|
-
}
|
|
49
|
-
]
|
|
50
|
-
}
|