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/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
- * Scan target directory recursively for HTML files to auto-discover
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 discoverDesigns(baseDir) {
100
- const artboards = [];
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
- function scan(currentDir, relativePath = '') {
103
- let entries;
104
- try {
105
- entries = fs.readdirSync(currentDir, { withFileTypes: true });
106
- } catch {
107
- return;
108
- }
109
-
110
- for (const entry of entries) {
111
- if (entry.name.startsWith('.') && entry.name !== '.') continue;
112
- if (IGNORED_DIRS.has(entry.name)) continue;
113
-
114
- const fullPath = path.join(currentDir, entry.name);
115
- const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
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
- if (entry.isDirectory()) {
118
- scan(fullPath, relPath);
119
- } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.html')) {
120
- let title = '';
121
- let fileContent = '';
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
- fileContent = fs.readFileSync(fullPath, 'utf8');
124
- title = extractTitleFromHtml(fileContent, entry.name);
92
+ isDir = fs.statSync(full).isDirectory();
125
93
  } catch {
126
- title = entry.name;
94
+ // Removed
127
95
  }
128
-
129
- const category = inferCategory(relPath);
130
- const [width, height] = CATEGORY_SIZES[category];
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
- * Fill in what a manifest may leave out, so the explorer always gets
178
- * `sections[].{id, name, artboards[].{id, title, file, category}}`.
179
- * Accepts the short form too: `sections[].title` and `sections[].designs[]`
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
- // File watcher for live reload with debouncing
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 watcher = null;
263
+ let stopWatching = null;
375
264
 
376
265
  try {
377
- watcher = fs.watch(rootDir, { recursive: true }, (eventType, filename) => {
378
- if (!filename) return;
379
- const normalized = filename.replace(/\\/g, '/');
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 isManifest = normalized === manifestFile;
393
- const isHtml = normalized.endsWith('.html');
394
- const eventData = JSON.stringify({
395
- type: isManifest ? 'manifest' : 'file',
396
- file: normalized,
397
- isHtml,
398
- timestamp: Date.now()
399
- });
400
-
401
- for (const client of clients) {
402
- client.write(`event: change\ndata: ${eventData}\n\n`);
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
- if (watcher) watcher.close();
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
- }