htmlboard 0.4.0 → 0.5.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 +169 -7
- package/bin/htmlboard.js +152 -0
- package/package.json +1 -1
- package/skills/htmlboard/SKILL.md +41 -7
- package/src/catalog.js +449 -0
- package/src/check.js +173 -0
- package/src/client/check.js +349 -0
- package/src/client/explorer.js +68 -2
- package/src/client/probe.js +41 -0
- package/src/components.js +95 -0
- package/src/designs.js +63 -15
- package/src/index.js +18 -0
- package/src/server.js +167 -11
- package/src/templates/01-screens/01-dashboard.html +112 -0
- package/src/templates/01-screens/02-customers.html +77 -0
- package/src/templates/01-screens/{02-mobile.html → 03-mobile.html} +4 -54
- package/src/templates/{02-components/01-ui-kit.html → 02-ui-kit/01-overview.html} +16 -4
- package/src/templates/components/app-sidebar.html +67 -0
- package/src/templates/components/app-topbar.html +19 -0
- package/src/templates/components/stat-card.html +14 -0
- package/src/templates/components/tx-item.html +33 -0
- package/src/templates/components.js +180 -0
- package/src/templates/styles.css +24 -2
- package/src/templates/01-screens/01-desktop.html +0 -281
package/src/catalog.js
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { COMPONENTS_FOLDER, isPage, isTagName } from './components.js';
|
|
4
|
+
import { attributes, scanDesigns } from './designs.js';
|
|
5
|
+
|
|
6
|
+
// The catalog: what the designs in a folder share, read from the files every time, so it is never
|
|
7
|
+
// out of date. It describes whatever the project has and asks for none of it:
|
|
8
|
+
//
|
|
9
|
+
// - the stylesheets the designs link, whatever their name or folder (and the ones at the top of
|
|
10
|
+
// the folder, linked or not yet), with each local one's variables, classes, @keyframes and fonts;
|
|
11
|
+
// - the scripts the designs load;
|
|
12
|
+
// - the components in components/, with their attributes, slots, example and where they're used.
|
|
13
|
+
//
|
|
14
|
+
// As text it takes a few lines (`htmlboard catalog`), so an agent learns what exists without
|
|
15
|
+
// reading every file.
|
|
16
|
+
|
|
17
|
+
const WIDTH = 100; // the text wraps here
|
|
18
|
+
const LONGEST_VALUE = 120; // a variable's value is cut after this many characters
|
|
19
|
+
const MOST_ITEMS = 200; // a stylesheet's classes or variables shown; the rest are counted
|
|
20
|
+
const MOST_NAMED = 3; // designs named; more are counted
|
|
21
|
+
|
|
22
|
+
const byName = (a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
|
|
23
|
+
|
|
24
|
+
function read(file) {
|
|
25
|
+
try {
|
|
26
|
+
return fs.readFileSync(file, 'utf8');
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------- CSS
|
|
33
|
+
|
|
34
|
+
/** Split `text` at `separator`, except inside strings, parentheses and brackets */
|
|
35
|
+
function splitTopLevel(text, separator) {
|
|
36
|
+
const parts = [];
|
|
37
|
+
let depth = 0;
|
|
38
|
+
let quote = null;
|
|
39
|
+
let start = 0;
|
|
40
|
+
for (let i = 0; i < text.length; i++) {
|
|
41
|
+
const char = text[i];
|
|
42
|
+
if (quote) {
|
|
43
|
+
if (char === '\\') i++;
|
|
44
|
+
else if (char === quote || char === '\n') quote = null;
|
|
45
|
+
} else if (char === '"' || char === "'") quote = char;
|
|
46
|
+
else if (char === '(' || char === '[') depth++;
|
|
47
|
+
else if ((char === ')' || char === ']') && depth) depth--;
|
|
48
|
+
else if (char === separator && !depth) {
|
|
49
|
+
parts.push(text.slice(start, i));
|
|
50
|
+
start = i + 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
parts.push(text.slice(start));
|
|
54
|
+
return parts;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A stylesheet as a tree of rules, enough to read what it defines: each rule's prelude (a selector
|
|
59
|
+
* or an at-rule), the text of its own declarations, and the rules inside it. Comments are dropped.
|
|
60
|
+
*/
|
|
61
|
+
function parseCss(css) {
|
|
62
|
+
const root = { prelude: '', text: '', rules: [] };
|
|
63
|
+
const open = [root];
|
|
64
|
+
let text = '';
|
|
65
|
+
let quote = null;
|
|
66
|
+
for (let i = 0; i < css.length; i++) {
|
|
67
|
+
const char = css[i];
|
|
68
|
+
if (quote) {
|
|
69
|
+
text += char;
|
|
70
|
+
if (char === '\\') text += css[++i] ?? '';
|
|
71
|
+
else if (char === quote || char === '\n') quote = null;
|
|
72
|
+
} else if (char === '/' && css[i + 1] === '*') {
|
|
73
|
+
const end = css.indexOf('*/', i + 2);
|
|
74
|
+
i = end < 0 ? css.length : end + 1;
|
|
75
|
+
} else if (char === '"' || char === "'") {
|
|
76
|
+
quote = char;
|
|
77
|
+
text += char;
|
|
78
|
+
} else if (char === '{') {
|
|
79
|
+
// The declarations before a nested rule end with ";", then comes its prelude
|
|
80
|
+
const parts = splitTopLevel(text, ';');
|
|
81
|
+
const rule = { prelude: parts.pop().trim(), text: '', rules: [] };
|
|
82
|
+
open.at(-1).text += parts.map((part) => `${part};`).join('');
|
|
83
|
+
open.at(-1).rules.push(rule);
|
|
84
|
+
open.push(rule);
|
|
85
|
+
text = '';
|
|
86
|
+
} else if (char === '}') {
|
|
87
|
+
open.at(-1).text += text;
|
|
88
|
+
text = '';
|
|
89
|
+
if (open.length > 1) open.pop();
|
|
90
|
+
} else {
|
|
91
|
+
text += char;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
open.at(-1).text += text;
|
|
95
|
+
return root;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const IMPORT = /^@import\s+(?:url\(\s*(?:"([^"]*)"|'([^']*)'|([^)\s]*))\s*\)|"([^"]*)"|'([^']*)')/i;
|
|
99
|
+
|
|
100
|
+
/** The URLs a stylesheet @imports */
|
|
101
|
+
function importsOf(tree) {
|
|
102
|
+
return splitTopLevel(tree.text, ';')
|
|
103
|
+
.map((statement) => statement.trim().match(IMPORT))
|
|
104
|
+
.filter(Boolean)
|
|
105
|
+
.map((match) => match.slice(1).find((value) => value !== undefined));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The class names in a selector, not counting its strings and attribute selectors */
|
|
109
|
+
function classesOf(selector) {
|
|
110
|
+
const bare = selector.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, '').replace(/\[[^\]]*\]/g, '');
|
|
111
|
+
return [...bare.matchAll(/\.(-?(?:[_a-zA-Z\u0080-\uFFFF]|\\.)(?:[\w\u0080-\uFFFF-]|\\.)*)/g)].map((match) => match[1]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A nested rule's selector, written out (`&` is the rule around it) */
|
|
115
|
+
function nest(outer, inner) {
|
|
116
|
+
const scope = splitTopLevel(outer, ',').length > 1 ? `:is(${outer})` : outer;
|
|
117
|
+
return splitTopLevel(inner, ',')
|
|
118
|
+
.map((part) => part.trim())
|
|
119
|
+
.map((part) => (part.includes('&') ? part.replaceAll('&', scope) : `${scope} ${part}`))
|
|
120
|
+
.join(', ');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** At-rules that hold rules for the page; the others (@keyframes, @font-face, @page…) don't */
|
|
124
|
+
const GROUPING_RULES = new Set(['media', 'supports', 'layer', 'container', 'scope', 'starting-style', 'document']);
|
|
125
|
+
|
|
126
|
+
const unquote = (text) => text.trim().replace(/^(["'])(.*)\1$/, '$2');
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What a stylesheet defines: its variables (with the selector that declares them), its classes,
|
|
130
|
+
* @keyframes and font families, and what it @imports.
|
|
131
|
+
*/
|
|
132
|
+
function readStylesheet(css) {
|
|
133
|
+
const tree = parseCss(css);
|
|
134
|
+
const variables = new Map(); // where → Map(name → value)
|
|
135
|
+
const classes = new Set();
|
|
136
|
+
const keyframes = new Set();
|
|
137
|
+
const fonts = new Set();
|
|
138
|
+
const declare = (where, text) => {
|
|
139
|
+
for (const declaration of splitTopLevel(text, ';')) {
|
|
140
|
+
const match = declaration.match(/^\s*(--[^:\s]+)\s*:([\s\S]*)$/);
|
|
141
|
+
if (!match) continue;
|
|
142
|
+
if (!variables.has(where)) variables.set(where, new Map());
|
|
143
|
+
variables.get(where).set(match[1], match[2].replace(/\s+/g, ' ').trim());
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const walk = (rules, at, selector) => {
|
|
147
|
+
for (const { prelude, text, rules: inner } of rules) {
|
|
148
|
+
if (prelude.startsWith('@')) {
|
|
149
|
+
const name = prelude.match(/^@(?:-[a-z]+-)?([\w-]+)/i)?.[1].toLowerCase();
|
|
150
|
+
if (name === 'keyframes') keyframes.add(unquote(prelude.replace(/^@[\w-]+/, '')));
|
|
151
|
+
else if (name === 'font-face') {
|
|
152
|
+
const family = splitTopLevel(text, ';').map((d) => d.match(/^\s*font-family\s*:([\s\S]*)$/i)?.[1]).find(Boolean);
|
|
153
|
+
if (family) fonts.add(unquote(family));
|
|
154
|
+
} else if (GROUPING_RULES.has(name)) {
|
|
155
|
+
const where = at ? `${at} ${prelude}` : prelude;
|
|
156
|
+
if (selector) declare(`${where} ${selector}`, text);
|
|
157
|
+
walk(inner, where, selector);
|
|
158
|
+
}
|
|
159
|
+
} else if (prelude) {
|
|
160
|
+
const resolved = selector ? nest(selector, prelude) : prelude;
|
|
161
|
+
for (const name of classesOf(prelude)) classes.add(name);
|
|
162
|
+
declare(at ? `${at} ${resolved}` : resolved, text);
|
|
163
|
+
walk(inner, at, resolved);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
walk(tree.rules, '', '');
|
|
168
|
+
return {
|
|
169
|
+
variables: [...variables].flatMap(([where, names]) => [...names].map(([name, value]) => ({ where, name, value }))),
|
|
170
|
+
classes: [...classes],
|
|
171
|
+
keyframes: [...keyframes],
|
|
172
|
+
fonts: [...fonts],
|
|
173
|
+
imports: importsOf(tree)
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------- pages and components
|
|
178
|
+
|
|
179
|
+
/** What a page or a component loads: its <link rel="stylesheet">s, <script src>s and <style> @imports */
|
|
180
|
+
function loads(html) {
|
|
181
|
+
html = html.replace(/<!--[\s\S]*?-->/g, '');
|
|
182
|
+
const stylesheets = [];
|
|
183
|
+
const scripts = [];
|
|
184
|
+
const tags = html.replace(/(<(script|style)\b[^>]*>)[\s\S]*?<\/\2\s*>/gi, '$1');
|
|
185
|
+
for (const [tag, name] of tags.matchAll(/<(link|script)\b[^>]*>/gi)) {
|
|
186
|
+
const attrs = attributes(tag);
|
|
187
|
+
if (name.toLowerCase() === 'link' && /\bstylesheet\b/i.test(attrs.rel || '') && attrs.href) stylesheets.push(attrs.href);
|
|
188
|
+
else if (name.toLowerCase() === 'script' && attrs.src) scripts.push(attrs.src);
|
|
189
|
+
}
|
|
190
|
+
for (const [, css] of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi)) stylesheets.push(...importsOf(parseCss(css)));
|
|
191
|
+
return { stylesheets, scripts };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const ATTRIBUTE_SELECTOR = /\[\s*([\w-]+)\s*(?:[~|^$*]?=\s*(?:"([^"]*)"|'([^']*)'|([^\s\]]+))(?:\s+[is])?)?\s*\]/gi;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* How a component is used, from its file: its attributes (the {{name}}s in its markup, the ones its
|
|
198
|
+
* script reads, and the ones its CSS styles on the element: `&[positive]`, `&[size="lg"]`), its slots
|
|
199
|
+
* and the example it starts with. And its markup, to find the components it uses.
|
|
200
|
+
*/
|
|
201
|
+
function readComponent(source) {
|
|
202
|
+
const example = source.match(/^\s*<!--([\s\S]*?)-->/)?.[1].replace(/\s+/g, ' ').trim() || null;
|
|
203
|
+
const css = [...source.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi)].map((match) => match[1]).join('\n')
|
|
204
|
+
.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
205
|
+
const code = [...source.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi)]
|
|
206
|
+
.filter(([, attrs]) => !/\bsrc\s*=/i.test(attrs)).map((match) => match[2]).join('\n');
|
|
207
|
+
const markup = source.replace(/<!--[\s\S]*?-->/g, '').replace(/<(style|script)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '');
|
|
208
|
+
const found = new Map(); // name → { text, flag, values }
|
|
209
|
+
const add = (name, kind, value) => {
|
|
210
|
+
if (!found.has(name)) found.set(name, { values: new Set() });
|
|
211
|
+
if (value !== undefined) found.get(name).values.add(value);
|
|
212
|
+
else found.get(name)[kind] = true;
|
|
213
|
+
};
|
|
214
|
+
for (const [, name] of markup.matchAll(/\{\{\s*([^{}\s]+)\s*\}\}/g)) add(name, 'text');
|
|
215
|
+
for (const [, verb, , name] of code.matchAll(/\b(get|has)Attribute\(\s*(['"`])([^'"`]+)\2/g)) add(name, verb === 'has' ? 'flag' : 'text');
|
|
216
|
+
for (const [, name] of code.matchAll(/\bdataset\.([A-Za-z_$][\w$]*)/g)) add(`data-${name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`, 'text');
|
|
217
|
+
for (const [, selectors] of css.matchAll(/&((?:\[[^\]]*\])+)/g)) {
|
|
218
|
+
for (const [, name, ...values] of selectors.matchAll(ATTRIBUTE_SELECTOR)) add(name, 'flag', values.find((v) => v !== undefined));
|
|
219
|
+
}
|
|
220
|
+
for (const [, name] of css.matchAll(/&:not\(\s*\[\s*([\w-]+)\s*\]/g)) add(name, 'flag');
|
|
221
|
+
const slots = [...markup.matchAll(/<slot\b[^>]*>/gi)].map(([tag]) => attributes(tag).name || '');
|
|
222
|
+
return {
|
|
223
|
+
attributes: [...found].map(([name, { text, values }]) => {
|
|
224
|
+
if (values.size) return { name, values: [...values] };
|
|
225
|
+
return text ? { name } : { name, flag: true };
|
|
226
|
+
}),
|
|
227
|
+
slots: [...new Set(slots)],
|
|
228
|
+
example,
|
|
229
|
+
markup
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------- the catalog
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* What the designs in a folder share: the stylesheets and scripts they load (with what each local
|
|
239
|
+
* stylesheet defines) and the components in its components/ (with how each is used, and where).
|
|
240
|
+
*
|
|
241
|
+
* @param {string} [dir=process.cwd()] The designs folder
|
|
242
|
+
* @param {object} [options]
|
|
243
|
+
* @param {string} [options.root] The folder the server serves from (`--root`), for links that
|
|
244
|
+
* start with "/" (default: the designs folder)
|
|
245
|
+
* @returns {{dir: string, designs: string[], stylesheets: object[], scripts: object[], components: object[]}}
|
|
246
|
+
* Paths are from the designs folder. Each stylesheet, script and component has its `users`:
|
|
247
|
+
* `{ design }` (a file), `{ component }` (a tag) or `{ stylesheet }` (one that @imports it).
|
|
248
|
+
*/
|
|
249
|
+
export function buildCatalog(dir = process.cwd(), { root } = {}) {
|
|
250
|
+
const designsDir = path.resolve(dir);
|
|
251
|
+
if (!fs.statSync(designsDir, { throwIfNoEntry: false })?.isDirectory()) throw new Error(`There is no folder ${dir}`);
|
|
252
|
+
const rootDir = root ? path.resolve(root) : designsDir;
|
|
253
|
+
const fromDesigns = (file) => path.relative(designsDir, file).split(path.sep).join('/');
|
|
254
|
+
|
|
255
|
+
// Where a link points: a URL as it is, a file by its path from the designs folder
|
|
256
|
+
const locate = (href, from) => {
|
|
257
|
+
href = href.trim();
|
|
258
|
+
if (!href || /^(#|data:|about:|javascript:)/i.test(href)) return null;
|
|
259
|
+
if (/^([a-z][a-z\d+.-]*:|\/\/)/i.test(href)) return { key: href, path: href, external: true };
|
|
260
|
+
let clean = href.replace(/[?#][\s\S]*$/, '');
|
|
261
|
+
try {
|
|
262
|
+
clean = decodeURIComponent(clean);
|
|
263
|
+
} catch {
|
|
264
|
+
// as it is
|
|
265
|
+
}
|
|
266
|
+
const file = clean.startsWith('/') ? path.join(rootDir, clean) : path.resolve(path.dirname(from), clean);
|
|
267
|
+
return { key: file, path: fromDesigns(file), file };
|
|
268
|
+
};
|
|
269
|
+
const addUser = (entry, user) => {
|
|
270
|
+
const [[kind, name]] = Object.entries(user);
|
|
271
|
+
if (!entry.users.some((other) => other[kind] === name)) entry.users.push(user);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const stylesheets = new Map(); // URL or file → what it is and who loads it
|
|
275
|
+
const addStylesheet = (where, user) => {
|
|
276
|
+
if (!where) return;
|
|
277
|
+
let entry = stylesheets.get(where.key);
|
|
278
|
+
if (!entry) {
|
|
279
|
+
entry = { path: where.path, users: [] };
|
|
280
|
+
stylesheets.set(where.key, entry);
|
|
281
|
+
const css = where.external ? null : read(where.file);
|
|
282
|
+
if (where.external) entry.external = true;
|
|
283
|
+
else if (css === null) entry.missing = true;
|
|
284
|
+
else {
|
|
285
|
+
const { imports, ...defines } = readStylesheet(css);
|
|
286
|
+
Object.assign(entry, defines);
|
|
287
|
+
for (const href of imports) addStylesheet(locate(href, where.file), { stylesheet: entry.path });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (user) addUser(entry, user);
|
|
291
|
+
};
|
|
292
|
+
const scripts = new Map();
|
|
293
|
+
const addScript = (where, user) => {
|
|
294
|
+
if (!where) return;
|
|
295
|
+
if (!scripts.has(where.key)) {
|
|
296
|
+
const entry = { path: where.path, users: [] };
|
|
297
|
+
if (where.external) entry.external = true;
|
|
298
|
+
else if (!fs.existsSync(where.file)) entry.missing = true;
|
|
299
|
+
scripts.set(where.key, entry);
|
|
300
|
+
}
|
|
301
|
+
addUser(scripts.get(where.key), user);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const designs = scanDesigns(designsDir).filter((design) => !design.variantOf).map((design) => design.file);
|
|
305
|
+
const pages = designs.map((file) => {
|
|
306
|
+
const full = path.join(designsDir, file);
|
|
307
|
+
const html = (read(full) ?? '').replace(/<!--[\s\S]*?-->/g, '');
|
|
308
|
+
const { stylesheets: hrefs, scripts: srcs } = loads(html);
|
|
309
|
+
for (const href of hrefs) addStylesheet(locate(href, full), { design: file });
|
|
310
|
+
for (const src of srcs) addScript(locate(src, full), { design: file });
|
|
311
|
+
return [file, html];
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const components = [];
|
|
315
|
+
const markups = new Map();
|
|
316
|
+
const componentsDir = path.join(designsDir, COMPONENTS_FOLDER);
|
|
317
|
+
let names = [];
|
|
318
|
+
try {
|
|
319
|
+
names = fs.readdirSync(componentsDir).filter((name) => name.endsWith('.html') && isTagName(name.slice(0, -5))).sort(byName);
|
|
320
|
+
} catch {
|
|
321
|
+
// no components
|
|
322
|
+
}
|
|
323
|
+
for (const name of names) {
|
|
324
|
+
const full = path.join(componentsDir, name);
|
|
325
|
+
const source = read(full);
|
|
326
|
+
if (source === null || isPage(source)) continue; // a whole page is a design
|
|
327
|
+
const tag = name.slice(0, -5);
|
|
328
|
+
const { markup, ...usage } = readComponent(source);
|
|
329
|
+
components.push({ tag, file: `${COMPONENTS_FOLDER}/${name}`, ...usage, users: [] });
|
|
330
|
+
markups.set(tag, markup);
|
|
331
|
+
const { stylesheets: hrefs, scripts: srcs } = loads(source);
|
|
332
|
+
for (const href of hrefs) addStylesheet(locate(href, full), { component: tag });
|
|
333
|
+
for (const src of srcs) addScript(locate(src, full), { component: tag });
|
|
334
|
+
}
|
|
335
|
+
for (const component of components) {
|
|
336
|
+
const used = new RegExp(`<${escapeRegExp(component.tag)}[\\s/>]`, 'i');
|
|
337
|
+
for (const [file, html] of pages) if (used.test(html)) component.users.push({ design: file });
|
|
338
|
+
for (const [tag, markup] of markups) if (tag !== component.tag && used.test(markup)) component.users.push({ component: tag });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// The stylesheets at the top of the folder are there to be shared, even before a design links them
|
|
342
|
+
const top = fs.readdirSync(designsDir, { withFileTypes: true })
|
|
343
|
+
.filter((entry) => entry.isFile() && /\.css$/i.test(entry.name))
|
|
344
|
+
.map((entry) => entry.name)
|
|
345
|
+
.sort(byName);
|
|
346
|
+
for (const name of top) addStylesheet({ key: path.join(designsDir, name), path: name, file: path.join(designsDir, name) });
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
dir: designsDir,
|
|
350
|
+
designs,
|
|
351
|
+
stylesheets: [...stylesheets.values()],
|
|
352
|
+
scripts: [...scripts.values()],
|
|
353
|
+
components
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---------------------------------------------------------------- as text
|
|
358
|
+
|
|
359
|
+
/** `head`, the items after it (the first MOST_ITEMS) and `end`, wrapped at WIDTH, indented under head */
|
|
360
|
+
function wrap(head, items, end) {
|
|
361
|
+
if (items.length > MOST_ITEMS) items = [...items.slice(0, MOST_ITEMS), `… and ${items.length - MOST_ITEMS} more`];
|
|
362
|
+
if (end) items = [...items, end];
|
|
363
|
+
const indent = ' '.repeat(head.length - head.trimStart().length + 2);
|
|
364
|
+
const lines = [];
|
|
365
|
+
let line = head;
|
|
366
|
+
let count = 0;
|
|
367
|
+
for (const item of items) {
|
|
368
|
+
if (count && line.length + 1 + item.length > WIDTH) {
|
|
369
|
+
lines.push(line);
|
|
370
|
+
line = indent + item;
|
|
371
|
+
} else {
|
|
372
|
+
line += ` ${item}`;
|
|
373
|
+
}
|
|
374
|
+
count++;
|
|
375
|
+
}
|
|
376
|
+
lines.push(line);
|
|
377
|
+
return lines;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Who loads or uses something: the designs (named when few), components and stylesheets */
|
|
381
|
+
function describeUsers(users, total, none) {
|
|
382
|
+
if (!users.length) return none;
|
|
383
|
+
const parts = [];
|
|
384
|
+
const designs = users.filter((user) => user.design).map((user) => user.design);
|
|
385
|
+
if (designs.length === total && total > 1) parts.push(`all ${total} designs`);
|
|
386
|
+
else if (designs.length > MOST_NAMED) parts.push(`${designs.length} designs`);
|
|
387
|
+
else if (designs.length) parts.push(`${designs.length} design${designs.length === 1 ? '' : 's'}: ${designs.join(', ')}`);
|
|
388
|
+
const components = users.filter((user) => user.component).map((user) => `<${user.component}>`);
|
|
389
|
+
if (components.length) parts.push(components.join(' '));
|
|
390
|
+
const sheets = users.filter((user) => user.stylesheet).map((user) => user.stylesheet);
|
|
391
|
+
if (sheets.length) parts.push(`imported by ${sheets.join(', ')}`);
|
|
392
|
+
return parts.join('; ');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const cut = (value) => (value.length > LONGEST_VALUE ? `${value.slice(0, LONGEST_VALUE - 1)}…` : value);
|
|
396
|
+
|
|
397
|
+
/** How a component is written: its tag with its attributes, then what goes inside */
|
|
398
|
+
function signature({ tag, attributes: attrs, slots }) {
|
|
399
|
+
const names = attrs.map(({ name, values, flag }) => (values ? `${name}=${values.join('|')}` : flag ? `[${name}]` : name));
|
|
400
|
+
const inside = slots.map((slot) => (slot ? `slot="${slot}"` : 'children'));
|
|
401
|
+
return `<${[tag, ...names].join(' ')}>${inside.length ? ` + ${inside.join(', ')}` : ''}`;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The catalog as text, a few lines per stylesheet, script and component (what `htmlboard catalog`
|
|
406
|
+
* prints).
|
|
407
|
+
*
|
|
408
|
+
* @param {ReturnType<typeof buildCatalog>} catalog
|
|
409
|
+
* @param {object} [options]
|
|
410
|
+
* @param {string} [options.name] How to call the designs folder (default: its full path)
|
|
411
|
+
*/
|
|
412
|
+
export function formatCatalog(catalog, { name = catalog.dir } = {}) {
|
|
413
|
+
const total = catalog.designs.length;
|
|
414
|
+
const lines = [`${name}: ${total ? `${total} design${total === 1 ? '' : 's'}` : 'no designs yet'}.`];
|
|
415
|
+
if (catalog.stylesheets.length || catalog.scripts.length || catalog.components.length) {
|
|
416
|
+
lines[0] += ' Paths below are from this folder.';
|
|
417
|
+
} else if (total) {
|
|
418
|
+
lines.push('', 'Nothing shared yet: no stylesheet, script or component.');
|
|
419
|
+
}
|
|
420
|
+
if (catalog.stylesheets.length) {
|
|
421
|
+
lines.push('', 'Stylesheets');
|
|
422
|
+
for (const sheet of catalog.stylesheets) {
|
|
423
|
+
lines.push(` ${sheet.path}${sheet.missing ? ' (not found)' : ''} · ${describeUsers(sheet.users, total, 'not linked yet')}`);
|
|
424
|
+
const groups = new Map();
|
|
425
|
+
for (const { where, name: variable, value } of sheet.variables ?? []) {
|
|
426
|
+
if (!groups.has(where)) groups.set(where, []);
|
|
427
|
+
groups.get(where).push(`${variable}: ${cut(value)};`);
|
|
428
|
+
}
|
|
429
|
+
for (const [where, declarations] of groups) lines.push(...wrap(` ${where} {`, declarations, '}'));
|
|
430
|
+
if (sheet.classes?.length) lines.push(...wrap(' classes', sheet.classes.map((className) => `.${className}`)));
|
|
431
|
+
if (sheet.keyframes?.length) lines.push(...wrap(' @keyframes', sheet.keyframes.map((n, i, all) => (i < all.length - 1 ? `${n},` : n))));
|
|
432
|
+
if (sheet.fonts?.length) lines.push(...wrap(' @font-face', sheet.fonts.map((n, i, all) => (i < all.length - 1 ? `${n},` : n))));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (catalog.scripts.length) {
|
|
436
|
+
lines.push('', 'Scripts');
|
|
437
|
+
for (const script of catalog.scripts) {
|
|
438
|
+
lines.push(` ${script.path}${script.missing ? ' (not found)' : ''} · ${describeUsers(script.users, total)}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (catalog.components.length) {
|
|
442
|
+
lines.push('', `Components (${COMPONENTS_FOLDER}/)`);
|
|
443
|
+
for (const component of catalog.components) {
|
|
444
|
+
lines.push(` ${signature(component)} · ${describeUsers(component.users, total, 'not used yet')}`);
|
|
445
|
+
if (component.example) lines.push(` e.g. ${component.example}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return `${lines.join('\n')}\n`;
|
|
449
|
+
}
|
package/src/check.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// `htmlboard check`: the canvas running for a folder, open in a browser, loads its designs and says
|
|
5
|
+
// what went wrong in each (see client/check.js); this finds that canvas, asks it, and puts the
|
|
6
|
+
// answer in a few lines of text.
|
|
7
|
+
|
|
8
|
+
/** Where htmlboard listens unless told otherwise: 3000, or the next free port */
|
|
9
|
+
const PORTS = Array.from({ length: 21 }, (_, i) => 3000 + i);
|
|
10
|
+
|
|
11
|
+
/** A problem the check couldn't get past (no canvas running, none open in a browser): exit code 2 */
|
|
12
|
+
export class CheckError extends Error {}
|
|
13
|
+
|
|
14
|
+
function get(url, { timeout = 0 } = {}) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const req = http.get(url, (res) => {
|
|
17
|
+
const chunks = [];
|
|
18
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
19
|
+
res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') }));
|
|
20
|
+
res.on('error', reject);
|
|
21
|
+
});
|
|
22
|
+
req.on('error', reject);
|
|
23
|
+
if (timeout) req.setTimeout(timeout, () => req.destroy(new Error('timeout')));
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const isInside = (base, target) => {
|
|
28
|
+
const rel = path.relative(base, target);
|
|
29
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The htmlboard servers listening on these ports, each with its designs folder (dir) and root */
|
|
33
|
+
async function findCanvases(ports) {
|
|
34
|
+
const found = await Promise.all(ports.map(async (port) => {
|
|
35
|
+
try {
|
|
36
|
+
const { status, body } = await get(`http://127.0.0.1:${port}/api/health`, { timeout: 1500 });
|
|
37
|
+
const health = JSON.parse(body);
|
|
38
|
+
return status === 200 && health.status === 'ok' && health.dir ? { ...health, port, url: `http://localhost:${port}` } : null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}));
|
|
43
|
+
return found.filter(Boolean);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Check designs with the canvas that shows them: a designs folder, a folder in it, or a design's
|
|
48
|
+
* file (default: all the designs of the canvas found). The canvas must be open in a browser, where
|
|
49
|
+
* the check runs.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} [target] A path; without one, the canvas's whole folder (looked for from the current one)
|
|
52
|
+
* @param {object} [options]
|
|
53
|
+
* @param {number} [options.port] The canvas's port (default: looked for on 3000 to 3020)
|
|
54
|
+
* @returns {Promise<{canvas: object, report: object}>} The canvas (its url, dir, root) and its report
|
|
55
|
+
*/
|
|
56
|
+
export async function runCheck(target, { port } = {}) {
|
|
57
|
+
const full = path.resolve(target || process.cwd());
|
|
58
|
+
const canvases = await findCanvases(port ? [Number(port)] : PORTS);
|
|
59
|
+
// The canvas whose folder holds the target (the deepest one), else the only one inside it
|
|
60
|
+
const holding = canvases.filter((canvas) => isInside(canvas.dir, full)).sort((a, b) => b.dir.length - a.dir.length);
|
|
61
|
+
const within = canvases.filter((canvas) => isInside(full, canvas.dir));
|
|
62
|
+
let canvas = holding[0] ?? (within.length === 1 ? within[0] : null);
|
|
63
|
+
if (!canvas && port && !target && canvases.length) canvas = canvases[0];
|
|
64
|
+
if (!canvas) {
|
|
65
|
+
const shown = path.relative(process.cwd(), full) || '.';
|
|
66
|
+
if (within.length > 1) {
|
|
67
|
+
throw new CheckError(`Several canvases show folders in ${shown}: ${within.map((c) => `${path.relative(process.cwd(), c.dir)} (${c.url})`).join(', ')}. Say which one.`);
|
|
68
|
+
}
|
|
69
|
+
if (port) throw new CheckError(canvases.length ? `The canvas at ${canvases[0].url} shows ${canvases[0].dir}, not ${shown}.` : `No htmlboard is running at http://localhost:${port}.`);
|
|
70
|
+
throw new CheckError(`No htmlboard canvas is running for ${shown}. Start it (npx htmlboard <designs folder>) and open it in a browser.`);
|
|
71
|
+
}
|
|
72
|
+
const only = holding[0] === canvas && full !== canvas.dir ? path.relative(canvas.dir, full).split(path.sep).join('/') : '';
|
|
73
|
+
let answer;
|
|
74
|
+
try {
|
|
75
|
+
const { status, body } = await get(`http://127.0.0.1:${canvas.port}/api/check${only ? `?only=${encodeURIComponent(only)}` : ''}`);
|
|
76
|
+
answer = { status, ...JSON.parse(body) };
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw new CheckError(`The canvas at ${canvas.url} didn't answer (${error.message}).`);
|
|
79
|
+
}
|
|
80
|
+
if (answer.status === 409) {
|
|
81
|
+
throw new CheckError(`The canvas at ${canvas.url} isn't open in a browser, and the check runs there. Open it and try again.`);
|
|
82
|
+
}
|
|
83
|
+
if (answer.status !== 200) throw new CheckError(`${answer.error || `The canvas answered ${answer.status}`}.`);
|
|
84
|
+
const { status, ...report } = answer;
|
|
85
|
+
return { canvas, report };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const LEVELS = ['error', 'layout', 'note'];
|
|
89
|
+
// The page as a whole first, then its scripts, its components and its files
|
|
90
|
+
const KINDS = ['blank', 'timeout', 'check', 'script', 'console', 'component', 'missing', 'load', 'font', 'beyond', 'cut', 'spill', 'below'];
|
|
91
|
+
|
|
92
|
+
/** Whether the report has something to fix (notes aside) */
|
|
93
|
+
export const hasProblems = (report) => report.designs.some((design) => design.problems.some((problem) => problem.level !== 'note'));
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The report as text: the designs with problems, each followed by its problems (errors, then
|
|
97
|
+
* layout, then notes), with paths from the designs folder.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} report What runCheck returns as `report` (or GET /api/check answers)
|
|
100
|
+
* @param {object} [options]
|
|
101
|
+
* @param {string} [options.name] How to call the designs folder (default: its path)
|
|
102
|
+
* @param {string} [options.url] The canvas's address, to say where the check ran
|
|
103
|
+
*/
|
|
104
|
+
export function formatReport(report, { name = report.dir, url } = {}) {
|
|
105
|
+
const prefix = path.relative(report.root, report.dir).split(path.sep).join('/');
|
|
106
|
+
// A path from the server's root ("/design/app.js"), from the designs folder instead ("app.js")
|
|
107
|
+
const show = (file) => {
|
|
108
|
+
const bare = String(file).replace(/^\/+/, '');
|
|
109
|
+
return prefix ? path.posix.relative(prefix, bare) : bare;
|
|
110
|
+
};
|
|
111
|
+
const designs = report.designs;
|
|
112
|
+
const failing = designs.filter((design) => design.problems.some((problem) => problem.level !== 'note')).length;
|
|
113
|
+
const noted = designs.filter((design) => design.problems.length && design.problems.every((problem) => problem.level === 'note')).length;
|
|
114
|
+
const lines = [`Checked ${count(designs.length, 'design')} in ${name}${url ? ` (the canvas at ${url})` : ''}: ${
|
|
115
|
+
failing ? `${failing} with problems` : 'no problems found'}${noted ? `, ${noted} with notes` : ''}.`];
|
|
116
|
+
const order = (problem) => LEVELS.indexOf(problem.level) * KINDS.length + KINDS.indexOf(problem.kind);
|
|
117
|
+
for (const design of designs.filter((d) => d.problems.length)) {
|
|
118
|
+
lines.push('', `${show(design.file)}${design.title ? ` · ${design.title}` : ''}`);
|
|
119
|
+
for (const problem of [...design.problems].sort((a, b) => order(a) - order(b))) {
|
|
120
|
+
lines.push(` ${problem.level.padEnd(6)} ${describe(problem, show)}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return `${lines.join('\n')}\n`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A problem in words (see client/check.js for what each kind holds) */
|
|
127
|
+
function describe(problem, show) {
|
|
128
|
+
const times = problem.count > 1 ? ` (${problem.count} times)` : '';
|
|
129
|
+
const file = problem.path ? show(problem.path) : problem.url;
|
|
130
|
+
switch (problem.kind) {
|
|
131
|
+
case 'script': {
|
|
132
|
+
const source = problem.source ? (problem.source.startsWith('/') ? show(problem.source) : problem.source) : '';
|
|
133
|
+
return `${problem.message}${source ? ` (${source}${problem.line ? `:${problem.line}` : ''})` : ''}${times}`;
|
|
134
|
+
}
|
|
135
|
+
case 'console':
|
|
136
|
+
return `console.error: ${problem.message}${times}`;
|
|
137
|
+
case 'missing':
|
|
138
|
+
return `missing file: ${file}${problem.tag ? ` (<${problem.tag}>)` : ''}${times}`;
|
|
139
|
+
case 'load':
|
|
140
|
+
return file ? `<${problem.tag}> couldn't load ${file}${times}` : `<${problem.tag}> has nothing to load${times}`;
|
|
141
|
+
case 'font':
|
|
142
|
+
return `font "${problem.family}" didn't load`;
|
|
143
|
+
case 'component':
|
|
144
|
+
if (problem.loader === false) return `<${problem.tag}> isn't built: the page doesn't load components.js`;
|
|
145
|
+
if (problem.found === false) return `<${problem.tag}> isn't built: there is no ${file}`;
|
|
146
|
+
return `<${problem.tag}> isn't built, though ${file || 'its file'} is there (see the errors)`;
|
|
147
|
+
case 'blank':
|
|
148
|
+
return 'the page shows nothing';
|
|
149
|
+
case 'timeout':
|
|
150
|
+
return `still loading after ${problem.seconds} s, checked as it was`;
|
|
151
|
+
case 'beyond':
|
|
152
|
+
return `cut at the right edge (the frame is ${problem.frame} px wide): ${problem.elements
|
|
153
|
+
.map((element) => `${element.selector} reaches ${element.right}`).join(', ')}${problem.more ? `, and ${problem.more} more` : ''}`;
|
|
154
|
+
case 'cut':
|
|
155
|
+
if (problem.more) return `and ${moreBoxes(problem.more)} cut text off`;
|
|
156
|
+
return `text cut off by ${problem.selector} (overflow: ${problem.overflow}): "${problem.text}"${problem.count > 1 ? ` and ${problem.count - 1} more` : ''}`;
|
|
157
|
+
case 'spill':
|
|
158
|
+
if (problem.more) return `and ${moreBoxes(problem.more)} with text spilling out`;
|
|
159
|
+
return `text spills out of ${problem.selector}: "${problem.text}"${problem.count > 1 ? ` and ${problem.count - 1} more` : ''}`;
|
|
160
|
+
case 'below':
|
|
161
|
+
return `content goes on below the frame, down to ${problem.bottom} px (the frame is ${problem.frame} px tall)`;
|
|
162
|
+
case 'check':
|
|
163
|
+
return `couldn't check it: ${problem.message}`;
|
|
164
|
+
default:
|
|
165
|
+
return JSON.stringify(problem);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function count(n, what) {
|
|
170
|
+
return `${n} ${what}${n === 1 ? '' : 's'}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const moreBoxes = (n) => `${n} more ${n === 1 ? 'box' : 'boxes'}`;
|