htmlboard 0.0.0-stage → 0.1.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 +289 -2
- package/bin/htmlboard.js +297 -0
- package/package.json +48 -4
- package/skills/htmlboard/SKILL.md +95 -0
- package/src/client/explorer.css +703 -0
- package/src/client/explorer.js +693 -0
- package/src/client/index.html +191 -0
- package/src/index.js +20 -0
- package/src/server.js +451 -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
|
@@ -0,0 +1,191 @@
|
|
|
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, maximum-scale=1.0, user-scalable=no">
|
|
6
|
+
<title>HtmlBoard — Design Explorer</title>
|
|
7
|
+
<link rel="stylesheet" href="/__explorer/explorer.css">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
11
|
+
</head>
|
|
12
|
+
<body class="theme-dark">
|
|
13
|
+
<div id="app">
|
|
14
|
+
<!-- Top Navigation Toolbar -->
|
|
15
|
+
<header class="topbar">
|
|
16
|
+
<div class="topbar-left">
|
|
17
|
+
<button id="btn-toggle-sidebar" class="btn-icon" title="Toggle Sidebar ([)">
|
|
18
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
19
|
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
|
20
|
+
<line x1="9" y1="3" x2="9" y2="21"/>
|
|
21
|
+
</svg>
|
|
22
|
+
</button>
|
|
23
|
+
|
|
24
|
+
<div class="brand">
|
|
25
|
+
<div class="brand-logo">
|
|
26
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
|
27
|
+
<rect x="3" y="3" width="7" height="7" rx="1.5" fill="#6366f1"/>
|
|
28
|
+
<rect x="14" y="3" width="7" height="7" rx="1.5" fill="#818cf8"/>
|
|
29
|
+
<rect x="3" y="14" width="7" height="7" rx="1.5" fill="#a855f7"/>
|
|
30
|
+
<rect x="14" y="14" width="7" height="7" rx="1.5" fill="#ec4899"/>
|
|
31
|
+
</svg>
|
|
32
|
+
</div>
|
|
33
|
+
<span class="brand-name">HtmlBoard</span>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div class="project-pill" id="project-title-pill">
|
|
37
|
+
<span id="project-title">Loading...</span>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div class="topbar-center">
|
|
42
|
+
<!-- Filter Category Tabs -->
|
|
43
|
+
<div class="filter-tabs" id="filter-tabs">
|
|
44
|
+
<button class="filter-tab active" data-category="all">All</button>
|
|
45
|
+
<button class="filter-tab" data-category="desktop">Desktop</button>
|
|
46
|
+
<button class="filter-tab" data-category="mobile">Mobile</button>
|
|
47
|
+
<button class="filter-tab" data-category="tablet">Tablet</button>
|
|
48
|
+
<button class="filter-tab" data-category="components">Components</button>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div class="topbar-right">
|
|
53
|
+
<!-- Search bar -->
|
|
54
|
+
<div class="search-box">
|
|
55
|
+
<svg class="search-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
56
|
+
<circle cx="11" cy="11" r="8"/>
|
|
57
|
+
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
58
|
+
</svg>
|
|
59
|
+
<input type="text" id="search-input" placeholder="Search boards... (/)" autocomplete="off" spellcheck="false">
|
|
60
|
+
<kbd class="search-kbd">/</kbd>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<!-- Canvas Zoom Controls -->
|
|
64
|
+
<div class="zoom-controls">
|
|
65
|
+
<button id="btn-zoom-out" class="btn-icon" title="Zoom Out (-)">
|
|
66
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
67
|
+
<line x1="5" y1="12" x2="19" y2="12"/>
|
|
68
|
+
</svg>
|
|
69
|
+
</button>
|
|
70
|
+
<span id="zoom-level" class="zoom-level-text" title="Click to reset (0)">100%</span>
|
|
71
|
+
<button id="btn-zoom-in" class="btn-icon" title="Zoom In (+)">
|
|
72
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
73
|
+
<line x1="12" y1="5" x2="12" y2="19"/>
|
|
74
|
+
<line x1="5" y1="12" x2="19" y2="12"/>
|
|
75
|
+
</svg>
|
|
76
|
+
</button>
|
|
77
|
+
<button id="btn-zoom-fit" class="btn-text" title="Fit to Screen (F)">
|
|
78
|
+
Fit All
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<!-- Live Status Indicator -->
|
|
83
|
+
<div class="live-indicator" id="live-indicator" title="Connected for live auto-reload">
|
|
84
|
+
<span class="live-dot"></span>
|
|
85
|
+
<span class="live-text">Live</span>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<!-- Help / Shortcuts -->
|
|
89
|
+
<button id="btn-shortcuts" class="btn-icon" title="Keyboard Shortcuts (?)">
|
|
90
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
91
|
+
<circle cx="12" cy="12" r="10"/>
|
|
92
|
+
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
|
93
|
+
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
|
94
|
+
</svg>
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
</header>
|
|
98
|
+
|
|
99
|
+
<!-- Main Workspace Area -->
|
|
100
|
+
<div class="workspace">
|
|
101
|
+
<!-- Collapsible Sidebar -->
|
|
102
|
+
<aside class="sidebar" id="sidebar">
|
|
103
|
+
<div class="sidebar-header">
|
|
104
|
+
<span class="sidebar-heading">Navigation</span>
|
|
105
|
+
<span class="sidebar-count" id="artboard-count">0 artboards</span>
|
|
106
|
+
</div>
|
|
107
|
+
<div class="sidebar-tree" id="sidebar-tree">
|
|
108
|
+
<!-- Populated by explorer.js -->
|
|
109
|
+
</div>
|
|
110
|
+
</aside>
|
|
111
|
+
|
|
112
|
+
<!-- Infinite Canvas Viewport -->
|
|
113
|
+
<main class="canvas-viewport" id="canvas-viewport">
|
|
114
|
+
<!-- Interactive Canvas Stage -->
|
|
115
|
+
<div class="canvas-stage" id="canvas-stage">
|
|
116
|
+
<!-- Background Grid layer -->
|
|
117
|
+
<div class="canvas-grid" id="canvas-grid"></div>
|
|
118
|
+
|
|
119
|
+
<!-- Dynamic Artboard Content Container -->
|
|
120
|
+
<div class="canvas-content" id="canvas-content">
|
|
121
|
+
<!-- Sections & Artboards rendered dynamically -->
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<!-- Pan overlay capturing mouse when Space or dragging is active -->
|
|
126
|
+
<div class="pointer-shield" id="pointer-shield"></div>
|
|
127
|
+
|
|
128
|
+
<!-- Canvas Mini HUD Overlay -->
|
|
129
|
+
<div class="canvas-hud">
|
|
130
|
+
<span id="hud-coordinates">X: 0 Y: 0</span>
|
|
131
|
+
<span class="hud-sep">•</span>
|
|
132
|
+
<span>Hold <kbd>Space</kbd> + Drag to pan</span>
|
|
133
|
+
<span class="hud-sep">•</span>
|
|
134
|
+
<span><kbd>Scroll</kbd> to zoom</span>
|
|
135
|
+
</div>
|
|
136
|
+
</main>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<!-- Keyboard Shortcuts Modal -->
|
|
140
|
+
<div class="modal-backdrop" id="shortcuts-modal">
|
|
141
|
+
<div class="modal-card">
|
|
142
|
+
<div class="modal-header">
|
|
143
|
+
<h2>Keyboard Shortcuts</h2>
|
|
144
|
+
<button id="btn-close-modal" class="btn-icon">
|
|
145
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
146
|
+
<line x1="18" y1="6" x2="6" y2="18"/>
|
|
147
|
+
<line x1="6" y1="6" x2="18" y2="18"/>
|
|
148
|
+
</svg>
|
|
149
|
+
</button>
|
|
150
|
+
</div>
|
|
151
|
+
<div class="shortcuts-grid">
|
|
152
|
+
<div class="shortcut-item">
|
|
153
|
+
<span class="shortcut-desc">Pan Canvas</span>
|
|
154
|
+
<div class="shortcut-keys"><kbd>Space</kbd> + <kbd>Drag</kbd></div>
|
|
155
|
+
</div>
|
|
156
|
+
<div class="shortcut-item">
|
|
157
|
+
<span class="shortcut-desc">Zoom In / Out</span>
|
|
158
|
+
<div class="shortcut-keys"><kbd>+</kbd> / <kbd>-</kbd> or <kbd>Ctrl</kbd> + <kbd>Wheel</kbd></div>
|
|
159
|
+
</div>
|
|
160
|
+
<div class="shortcut-item">
|
|
161
|
+
<span class="shortcut-desc">Reset Zoom (100%)</span>
|
|
162
|
+
<div class="shortcut-keys"><kbd>0</kbd></div>
|
|
163
|
+
</div>
|
|
164
|
+
<div class="shortcut-item">
|
|
165
|
+
<span class="shortcut-desc">Fit All Artboards</span>
|
|
166
|
+
<div class="shortcut-keys"><kbd>F</kbd> or <kbd>Shift</kbd> + <kbd>1</kbd></div>
|
|
167
|
+
</div>
|
|
168
|
+
<div class="shortcut-item">
|
|
169
|
+
<span class="shortcut-desc">Search Artboards</span>
|
|
170
|
+
<div class="shortcut-keys"><kbd>/</kbd> or <kbd>⌘</kbd> + <kbd>K</kbd></div>
|
|
171
|
+
</div>
|
|
172
|
+
<div class="shortcut-item">
|
|
173
|
+
<span class="shortcut-desc">Toggle Sidebar</span>
|
|
174
|
+
<div class="shortcut-keys"><kbd>[</kbd> or <kbd>]</kbd></div>
|
|
175
|
+
</div>
|
|
176
|
+
<div class="shortcut-item">
|
|
177
|
+
<span class="shortcut-desc">Device Filters</span>
|
|
178
|
+
<div class="shortcut-keys"><kbd>1</kbd> - <kbd>5</kbd></div>
|
|
179
|
+
</div>
|
|
180
|
+
<div class="shortcut-item">
|
|
181
|
+
<span class="shortcut-desc">Show / Hide Help</span>
|
|
182
|
+
<div class="shortcut-keys"><kbd>?</kbd></div>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<script type="module" src="/__explorer/explorer.js"></script>
|
|
190
|
+
</body>
|
|
191
|
+
</html>
|
package/src/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HtmlBoard
|
|
3
|
+
* Infinite canvas and design explorer for HTML artboards — designed for humans and LLMs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
createServer,
|
|
8
|
+
startServer,
|
|
9
|
+
discoverDesigns,
|
|
10
|
+
loadDesignsManifest,
|
|
11
|
+
normalizeManifest,
|
|
12
|
+
inferCategory
|
|
13
|
+
} from './server.js';
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
installSkills,
|
|
17
|
+
uninstallSkills,
|
|
18
|
+
SKILLS_DIR,
|
|
19
|
+
SKILL_TARGETS
|
|
20
|
+
} from './skills.js';
|
package/src/server.js
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
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
|
+
|
|
11
|
+
// Supported MIME types
|
|
12
|
+
const MIME_TYPES = {
|
|
13
|
+
'.html': 'text/html; charset=utf-8',
|
|
14
|
+
'.htm': 'text/html; charset=utf-8',
|
|
15
|
+
'.css': 'text/css; charset=utf-8',
|
|
16
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
17
|
+
'.mjs': 'application/javascript; charset=utf-8',
|
|
18
|
+
'.json': 'application/json; charset=utf-8',
|
|
19
|
+
'.png': 'image/png',
|
|
20
|
+
'.jpg': 'image/jpeg',
|
|
21
|
+
'.jpeg': 'image/jpeg',
|
|
22
|
+
'.gif': 'image/gif',
|
|
23
|
+
'.svg': 'image/svg+xml',
|
|
24
|
+
'.ico': 'image/x-icon',
|
|
25
|
+
'.webp': 'image/webp',
|
|
26
|
+
'.woff': 'font/woff',
|
|
27
|
+
'.woff2': 'font/woff2',
|
|
28
|
+
'.ttf': 'font/ttf',
|
|
29
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
30
|
+
'.mp4': 'video/mp4'
|
|
31
|
+
};
|
|
32
|
+
|
|
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
|
+
/** True when target is base itself or inside it */
|
|
91
|
+
function isInside(base, target) {
|
|
92
|
+
const rel = path.relative(base, target);
|
|
93
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Scan target directory recursively for HTML files to auto-discover
|
|
98
|
+
*/
|
|
99
|
+
export function discoverDesigns(baseDir) {
|
|
100
|
+
const artboards = [];
|
|
101
|
+
|
|
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;
|
|
116
|
+
|
|
117
|
+
if (entry.isDirectory()) {
|
|
118
|
+
scan(fullPath, relPath);
|
|
119
|
+
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.html')) {
|
|
120
|
+
let title = '';
|
|
121
|
+
let fileContent = '';
|
|
122
|
+
try {
|
|
123
|
+
fileContent = fs.readFileSync(fullPath, 'utf8');
|
|
124
|
+
title = extractTitleFromHtml(fileContent, entry.name);
|
|
125
|
+
} catch {
|
|
126
|
+
title = entry.name;
|
|
127
|
+
}
|
|
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: []
|
|
160
|
+
});
|
|
161
|
+
}
|
|
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
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
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;
|
|
190
|
+
};
|
|
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
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Find the next available port using net.createServer
|
|
239
|
+
*/
|
|
240
|
+
export function findAvailablePort(startPort) {
|
|
241
|
+
return new Promise((resolve, reject) => {
|
|
242
|
+
const tester = net.createServer();
|
|
243
|
+
tester.once('error', (err) => {
|
|
244
|
+
if (err.code === 'EADDRINUSE') {
|
|
245
|
+
resolve(findAvailablePort(startPort + 1));
|
|
246
|
+
} else {
|
|
247
|
+
reject(err);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
tester.once('listening', () => {
|
|
251
|
+
const port = tester.address().port;
|
|
252
|
+
tester.close(() => resolve(port));
|
|
253
|
+
});
|
|
254
|
+
tester.listen(startPort);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Creates and starts the HtmlBoard server
|
|
260
|
+
*/
|
|
261
|
+
export function createServer(options = {}) {
|
|
262
|
+
const targetDir = path.resolve(options.dir || process.cwd());
|
|
263
|
+
// Files are served from rootDir, so artboards can link to shared files above the designs folder
|
|
264
|
+
const rootDir = path.resolve(options.root || targetDir);
|
|
265
|
+
if (!isInside(rootDir, targetDir)) {
|
|
266
|
+
throw new Error(`The designs folder (${targetDir}) must be inside the root (${rootDir})`);
|
|
267
|
+
}
|
|
268
|
+
const manifestFile = path.posix.join(path.relative(rootDir, targetDir).split(path.sep).join('/'), 'designs.json');
|
|
269
|
+
const clients = new Set();
|
|
270
|
+
|
|
271
|
+
const server = http.createServer((req, res) => {
|
|
272
|
+
const parsedUrl = new URL(req.url, 'http://localhost');
|
|
273
|
+
let pathname = decodeURIComponent(parsedUrl.pathname);
|
|
274
|
+
|
|
275
|
+
// API: Designs Manifest
|
|
276
|
+
if (pathname === '/api/designs') {
|
|
277
|
+
const manifest = loadDesignsManifest(targetDir, rootDir);
|
|
278
|
+
res.writeHead(200, {
|
|
279
|
+
'Content-Type': 'application/json',
|
|
280
|
+
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
|
281
|
+
});
|
|
282
|
+
res.end(JSON.stringify(manifest, null, 2));
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// API: SSE Live Reload stream
|
|
287
|
+
if (pathname === '/api/events') {
|
|
288
|
+
res.writeHead(200, {
|
|
289
|
+
'Content-Type': 'text/event-stream',
|
|
290
|
+
'Cache-Control': 'no-cache',
|
|
291
|
+
'Connection': 'keep-alive',
|
|
292
|
+
'Access-Control-Allow-Origin': '*'
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
res.write(`event: connected\ndata: ${JSON.stringify({ time: Date.now() })}\n\n`);
|
|
296
|
+
clients.add(res);
|
|
297
|
+
|
|
298
|
+
const heartbeat = setInterval(() => {
|
|
299
|
+
res.write(': ping\n\n');
|
|
300
|
+
}, 25000);
|
|
301
|
+
|
|
302
|
+
req.on('close', () => {
|
|
303
|
+
clearInterval(heartbeat);
|
|
304
|
+
clients.delete(res);
|
|
305
|
+
});
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// API: Health status
|
|
310
|
+
if (pathname === '/api/health') {
|
|
311
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
312
|
+
res.end(JSON.stringify({ status: 'ok', dir: targetDir, root: rootDir }));
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Serve HtmlBoard Explorer client files
|
|
317
|
+
if (pathname === '/' || pathname === '/index.html') {
|
|
318
|
+
serveStaticFile(path.join(CLIENT_DIR, 'index.html'), res);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (pathname.startsWith('/__explorer/')) {
|
|
323
|
+
const clientRelPath = pathname.replace('/__explorer/', '');
|
|
324
|
+
const clientFilePath = path.join(CLIENT_DIR, clientRelPath);
|
|
325
|
+
// Ensure path is within CLIENT_DIR
|
|
326
|
+
if (!clientFilePath.startsWith(CLIENT_DIR)) {
|
|
327
|
+
res.writeHead(403).end('Forbidden');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
serveStaticFile(clientFilePath, res);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Serve user artboards and assets from rootDir
|
|
335
|
+
let safePath = path.normalize(pathname).replace(/^(\.\.[\/\\])+/, '');
|
|
336
|
+
if (safePath.startsWith('/')) {
|
|
337
|
+
safePath = safePath.slice(1);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const filePath = path.join(rootDir, safePath);
|
|
341
|
+
|
|
342
|
+
// Security check: must stay within rootDir
|
|
343
|
+
if (!isInside(rootDir, filePath)) {
|
|
344
|
+
res.writeHead(403).end('Forbidden');
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
|
349
|
+
serveStaticFile(filePath, res);
|
|
350
|
+
} else {
|
|
351
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
352
|
+
res.end(`File not found: ${pathname}`);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
function serveStaticFile(filePath, res) {
|
|
357
|
+
try {
|
|
358
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
359
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
360
|
+
const content = fs.readFileSync(filePath);
|
|
361
|
+
res.writeHead(200, {
|
|
362
|
+
'Content-Type': contentType,
|
|
363
|
+
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
|
364
|
+
});
|
|
365
|
+
res.end(content);
|
|
366
|
+
} catch {
|
|
367
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
368
|
+
res.end('Error reading file');
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// File watcher for live reload with debouncing
|
|
373
|
+
let watchDebounceTimer = null;
|
|
374
|
+
let watcher = null;
|
|
375
|
+
|
|
376
|
+
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
|
+
|
|
390
|
+
clearTimeout(watchDebounceTimer);
|
|
391
|
+
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`);
|
|
403
|
+
}
|
|
404
|
+
}, 100);
|
|
405
|
+
});
|
|
406
|
+
} catch (err) {
|
|
407
|
+
console.warn(`[htmlboard] Live watcher error: ${err.message}`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
server,
|
|
412
|
+
targetDir,
|
|
413
|
+
rootDir,
|
|
414
|
+
close: () => {
|
|
415
|
+
if (watcher) watcher.close();
|
|
416
|
+
for (const client of clients) {
|
|
417
|
+
client.end();
|
|
418
|
+
}
|
|
419
|
+
return new Promise((resolve) => server.close(resolve));
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Start listening on port with auto-retry if in use
|
|
426
|
+
*/
|
|
427
|
+
export async function startServer(options = {}) {
|
|
428
|
+
const requestedPort = parseInt(options.port, 10) || 3000;
|
|
429
|
+
const actualPort = await findAvailablePort(requestedPort);
|
|
430
|
+
|
|
431
|
+
if (actualPort !== requestedPort) {
|
|
432
|
+
console.log(`[htmlboard] Port ${requestedPort} is in use, using ${actualPort}...`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const { server, targetDir, rootDir, close } = createServer(options);
|
|
436
|
+
|
|
437
|
+
return new Promise((resolve, reject) => {
|
|
438
|
+
server.once('error', reject);
|
|
439
|
+
server.listen(actualPort, () => {
|
|
440
|
+
const url = `http://localhost:${actualPort}`;
|
|
441
|
+
resolve({
|
|
442
|
+
server,
|
|
443
|
+
port: actualPort,
|
|
444
|
+
url,
|
|
445
|
+
targetDir,
|
|
446
|
+
rootDir,
|
|
447
|
+
close
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
}
|