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
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// Checking the designs (`htmlboard check`). Each design loads afresh, out of sight, in a frame of its
|
|
2
|
+
// size, so the check sees the files as they are now, whatever the canvas shows (a filter, a reload
|
|
3
|
+
// on its way). Once its page settles, the check reads what went wrong:
|
|
4
|
+
//
|
|
5
|
+
// - what the probe noted while the page built (probe.js, which the server puts in framed pages):
|
|
6
|
+
// script errors, console.error, files that didn't load;
|
|
7
|
+
// - files the page asked for that aren't there (a stylesheet's fonts and images too);
|
|
8
|
+
// - components that weren't built, fonts that didn't load, a page that shows nothing;
|
|
9
|
+
// - layout: content cut at the frame's right edge, or below a frame of fixed height; text cut off
|
|
10
|
+
// by a box that hides what overflows it; text spilling out of its box.
|
|
11
|
+
//
|
|
12
|
+
// What a design means to do is left alone: text that ends in an ellipsis or is clamped, boxes that
|
|
13
|
+
// scroll, content hidden or moved off the page on purpose.
|
|
14
|
+
|
|
15
|
+
const AT_ONCE = 4; // designs loading at the same time
|
|
16
|
+
const LOAD_MS = 10000; // a page that hasn't loaded by then is checked as it is, and said so
|
|
17
|
+
const QUIET_MS = 300; // a page whose content hasn't changed for this long has settled…
|
|
18
|
+
const SETTLE_MS = 3000; // …or this long after it loaded, if it keeps changing
|
|
19
|
+
const MOST = 5; // boxes listed for a layout problem; the rest are counted
|
|
20
|
+
|
|
21
|
+
const MEDIA = 'img, svg, video, canvas, picture, iframe, embed, object, input, select, textarea, button';
|
|
22
|
+
const NOT_TEXT = new Set(['script', 'style', 'noscript', 'template', 'textarea', 'option', 'title']);
|
|
23
|
+
const CLIPS = new Set(['hidden', 'clip']);
|
|
24
|
+
const SCROLLS = new Set(['auto', 'scroll']);
|
|
25
|
+
|
|
26
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check designs, each `{ file, title, width, height, frameHeight }` (height: the design's own, 0 when
|
|
30
|
+
* it grows with its page; frameHeight: its frame's), and say what went wrong in each.
|
|
31
|
+
*/
|
|
32
|
+
export async function checkDesigns(designs) {
|
|
33
|
+
const box = document.createElement('div');
|
|
34
|
+
box.setAttribute('aria-hidden', 'true');
|
|
35
|
+
box.style.cssText = 'position: fixed; left: 0; top: 0; width: 0; height: 0; overflow: hidden; visibility: hidden; pointer-events: none;';
|
|
36
|
+
document.body.append(box);
|
|
37
|
+
const results = [];
|
|
38
|
+
let next = 0;
|
|
39
|
+
const worker = async () => {
|
|
40
|
+
while (next < designs.length) {
|
|
41
|
+
const index = next++;
|
|
42
|
+
results[index] = await checkDesign(designs[index], box);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
try {
|
|
46
|
+
await Promise.all(Array.from({ length: Math.min(AT_ONCE, designs.length) }, worker));
|
|
47
|
+
} finally {
|
|
48
|
+
box.remove();
|
|
49
|
+
}
|
|
50
|
+
await findMissing(results);
|
|
51
|
+
return results.map(({ resources, ...result }) => result);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function checkDesign(design, box) {
|
|
55
|
+
const result = {
|
|
56
|
+
file: design.file, title: design.title, width: design.width, height: design.height || null,
|
|
57
|
+
problems: [], resources: new Map(),
|
|
58
|
+
};
|
|
59
|
+
const frame = document.createElement('iframe');
|
|
60
|
+
frame.name = 'htmlboard-check'; // the probe listens in these frames only
|
|
61
|
+
frame.setAttribute('scrolling', 'no');
|
|
62
|
+
frame.style.cssText = `position: absolute; left: 0; top: 0; border: 0; width: ${design.width}px; height: ${design.frameHeight}px;`;
|
|
63
|
+
const loaded = new Promise((resolve) => frame.addEventListener('load', () => resolve(true), { once: true }));
|
|
64
|
+
frame.src = encodeURI(`/${design.file}`);
|
|
65
|
+
box.append(frame);
|
|
66
|
+
try {
|
|
67
|
+
const finished = await Promise.race([loaded, wait(LOAD_MS).then(() => false)]);
|
|
68
|
+
const doc = frame.contentDocument;
|
|
69
|
+
if (!doc?.body) {
|
|
70
|
+
result.problems.push({ level: 'error', kind: 'blank' });
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
if (finished) await settle(doc);
|
|
74
|
+
else result.problems.push({ level: 'error', kind: 'timeout', seconds: LOAD_MS / 1000 });
|
|
75
|
+
inspect(frame.contentWindow, doc, design, result);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
result.problems.push({ level: 'error', kind: 'check', message: String(error?.message || error) });
|
|
78
|
+
} finally {
|
|
79
|
+
frame.remove();
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Fonts in, components built, scripts done changing the page: quiet for QUIET_MS, or SETTLE_MS
|
|
85
|
+
async function settle(doc) {
|
|
86
|
+
const start = performance.now();
|
|
87
|
+
await Promise.race([doc.fonts?.ready, wait(SETTLE_MS)]);
|
|
88
|
+
await new Promise((resolve) => {
|
|
89
|
+
let quiet = 0;
|
|
90
|
+
let cap = 0;
|
|
91
|
+
const observer = new doc.defaultView.MutationObserver(() => {
|
|
92
|
+
clearTimeout(quiet);
|
|
93
|
+
quiet = setTimeout(done, QUIET_MS);
|
|
94
|
+
});
|
|
95
|
+
function done() {
|
|
96
|
+
observer.disconnect();
|
|
97
|
+
clearTimeout(quiet);
|
|
98
|
+
clearTimeout(cap);
|
|
99
|
+
resolve();
|
|
100
|
+
}
|
|
101
|
+
observer.observe(doc.documentElement, { childList: true, subtree: true, characterData: true });
|
|
102
|
+
quiet = setTimeout(done, QUIET_MS);
|
|
103
|
+
cap = setTimeout(done, Math.max(QUIET_MS, SETTLE_MS - (performance.now() - start)));
|
|
104
|
+
});
|
|
105
|
+
await Promise.race([doc.fonts?.ready, wait(SETTLE_MS)]); // the fonts the new content asked for
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A same-origin URL as its path from the server's root (`{ path: "/01-screens/app.js" }`), any other as it is */
|
|
109
|
+
function where(url) {
|
|
110
|
+
try {
|
|
111
|
+
const parsed = new URL(url, location.href);
|
|
112
|
+
return parsed.origin === location.origin ? { path: decodeURIComponent(parsed.pathname) } : { url: parsed.href };
|
|
113
|
+
} catch {
|
|
114
|
+
return { url: String(url) };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The same problem again and again (an error in a loop) is listed once, with how many times
|
|
119
|
+
function addCounted(problems, problem) {
|
|
120
|
+
const key = JSON.stringify(problem);
|
|
121
|
+
const same = problems.find((other) => other.key === key);
|
|
122
|
+
if (same) same.count = (same.count || 1) + 1;
|
|
123
|
+
else problems.push(Object.defineProperty({ ...problem }, 'key', { value: key }));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function inspect(win, doc, design, { problems, resources }) {
|
|
127
|
+
const body = doc.body;
|
|
128
|
+
const cache = (measure) => {
|
|
129
|
+
const known = new Map();
|
|
130
|
+
return (el) => {
|
|
131
|
+
if (!known.has(el)) known.set(el, measure(el));
|
|
132
|
+
return known.get(el);
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
const style = cache((el) => win.getComputedStyle(el));
|
|
136
|
+
const rect = cache((el) => el.getBoundingClientRect());
|
|
137
|
+
const hidden = cache((el) => {
|
|
138
|
+
const own = style(el);
|
|
139
|
+
return own.visibility !== 'visible' || own.opacity === '0'
|
|
140
|
+
|| Boolean(el.parentElement && el.parentElement !== doc.documentElement && hidden(el.parentElement));
|
|
141
|
+
});
|
|
142
|
+
// A box that hides or scrolls what overflows it
|
|
143
|
+
const keeps = (el) => style(el).overflowX !== 'visible' || style(el).overflowY !== 'visible';
|
|
144
|
+
|
|
145
|
+
// What the probe noted while the page built
|
|
146
|
+
const loads = new Set();
|
|
147
|
+
for (const note of win.__htmlboard?.problems ?? []) {
|
|
148
|
+
if (note.kind === 'script') {
|
|
149
|
+
const source = note.source ? { source: where(note.source).path ?? note.source, line: note.line } : {};
|
|
150
|
+
addCounted(problems, { level: 'error', kind: 'script', message: note.message, ...source });
|
|
151
|
+
} else if (note.kind === 'console') {
|
|
152
|
+
addCounted(problems, { level: 'error', kind: 'console', message: note.message });
|
|
153
|
+
} else if (note.kind === 'load') {
|
|
154
|
+
loads.add(note.url);
|
|
155
|
+
addCounted(problems, { level: 'error', kind: 'load', tag: note.tag, ...(note.url ? where(note.url) : {}) });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Images that didn't load, if the probe didn't hear of them
|
|
160
|
+
for (const img of doc.images) {
|
|
161
|
+
const src = img.currentSrc || img.src;
|
|
162
|
+
if (img.complete && !img.naturalWidth && src && !loads.has(src)) {
|
|
163
|
+
loads.add(src);
|
|
164
|
+
addCounted(problems, { level: 'error', kind: 'load', tag: 'img', ...where(src) });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Elements of tags nobody defined: components with no file, or a page that doesn't load components.js
|
|
169
|
+
const tags = new Set([...doc.querySelectorAll(':not(:defined)')].map((el) => el.localName).filter((name) => name.includes('-')));
|
|
170
|
+
if (tags.size) {
|
|
171
|
+
const loader = [...doc.querySelectorAll('script[src]')]
|
|
172
|
+
.find((script) => /(^|\/)components\.js$/.test(new URL(script.src, doc.baseURI).pathname));
|
|
173
|
+
for (const tag of tags) {
|
|
174
|
+
problems.push(loader
|
|
175
|
+
? { level: 'error', kind: 'component', tag, ...where(new URL(`components/${tag}.html`, loader.src)) }
|
|
176
|
+
: { level: 'error', kind: 'component', tag, loader: false });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Fonts the page uses that didn't load
|
|
181
|
+
const families = new Set();
|
|
182
|
+
for (const face of doc.fonts ?? []) if (face.status === 'error') families.add(face.family.replace(/^(["'])(.*)\1$/, '$2'));
|
|
183
|
+
for (const family of families) problems.push({ level: 'error', kind: 'font', family });
|
|
184
|
+
|
|
185
|
+
// Every file the page asked this server for (its stylesheets' fonts and images, its fetches too):
|
|
186
|
+
// which ones aren't there is asked once all the designs are checked
|
|
187
|
+
for (const entry of win.performance.getEntriesByType('resource')) {
|
|
188
|
+
const { path } = where(entry.name);
|
|
189
|
+
if (path && !path.startsWith('/__explorer/') && !path.startsWith('/api/')) resources.set(path, entry.responseStatus || 0);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!body.innerText.trim() && ![...body.querySelectorAll('*')].some((el) => !hidden(el) && rect(el).width > 0 && rect(el).height > 0)) {
|
|
193
|
+
problems.push({ level: 'error', kind: 'blank' });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Content cut at the right edge: the outermost boxes that cross it (or, in the page's flow, start
|
|
198
|
+
// past it) with something to see in them. A box that hides or scrolls its overflow keeps it inside.
|
|
199
|
+
const width = win.innerWidth;
|
|
200
|
+
const beyond = [];
|
|
201
|
+
const hasContent = (el) => el.matches(MEDIA) || Boolean(el.querySelector(MEDIA)) || Boolean(el.innerText?.trim());
|
|
202
|
+
const visit = (el) => {
|
|
203
|
+
for (const child of el.children) {
|
|
204
|
+
const s = style(child);
|
|
205
|
+
if (NOT_TEXT.has(child.localName) || s.display === 'none' || s.position === 'fixed') continue;
|
|
206
|
+
const r = rect(child);
|
|
207
|
+
const crosses = r.left < width - 1 && r.right > width + 1;
|
|
208
|
+
const past = r.left >= width && s.position === 'static' && s.transform === 'none';
|
|
209
|
+
if ((crosses || past) && r.width > 0 && r.height > 0 && !hidden(child) && hasContent(child)) {
|
|
210
|
+
beyond.push({ selector: describe(child), right: Math.round(r.right) });
|
|
211
|
+
} else if (s.overflowX === 'visible') {
|
|
212
|
+
visit(child);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
if (rect(body).right > width + 1 && hasContent(body)) beyond.push({ selector: 'body', right: Math.round(rect(body).right) });
|
|
217
|
+
else visit(body);
|
|
218
|
+
if (beyond.length) {
|
|
219
|
+
problems.push({ level: 'layout', kind: 'beyond', frame: width, elements: beyond.slice(0, MOST), ...(beyond.length > MOST ? { more: beyond.length - MOST } : {}) });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Text: cut off by a box that hides what overflows it, or spilling out of its box. Each text is
|
|
223
|
+
// measured against the boxes around it, from the nearest, up to the first it doesn't fit in (or one
|
|
224
|
+
// that keeps its overflow inside: past it, nothing can cut it).
|
|
225
|
+
const cut = new Map(); // box → { overflow, text, count }
|
|
226
|
+
const spilled = new Map();
|
|
227
|
+
const note = (map, box, node, detail) => {
|
|
228
|
+
if (map.has(box)) map.get(box).count++;
|
|
229
|
+
else map.set(box, { ...detail, text: snippet(node.data), count: 1 });
|
|
230
|
+
};
|
|
231
|
+
let bottom = 0; // where the content that no box keeps inside ends
|
|
232
|
+
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
|
233
|
+
const range = doc.createRange();
|
|
234
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
235
|
+
const parent = node.parentElement;
|
|
236
|
+
if (!node.data.trim() || !parent || NOT_TEXT.has(parent.localName) || hidden(parent)) continue;
|
|
237
|
+
range.selectNodeContents(node);
|
|
238
|
+
const lines = [...range.getClientRects()].filter((r) => r.width > 0 && r.height > 0);
|
|
239
|
+
if (!lines.length) continue;
|
|
240
|
+
const text = {
|
|
241
|
+
left: Math.min(...lines.map((r) => r.left)),
|
|
242
|
+
right: Math.max(...lines.map((r) => r.right)),
|
|
243
|
+
top: Math.min(...lines.map((r) => r.top)),
|
|
244
|
+
bottom: Math.max(...lines.map((r) => r.bottom)),
|
|
245
|
+
};
|
|
246
|
+
// A line's box is taller than its line when the line height is small: that much isn't overflow
|
|
247
|
+
const slack = parseFloat(style(parent).fontSize) * 0.35 + 1 || 6;
|
|
248
|
+
let kept = false;
|
|
249
|
+
for (let el = parent; el && el !== body; el = el.parentElement) {
|
|
250
|
+
const s = style(el);
|
|
251
|
+
if (s.display === 'inline' || s.display === 'contents') continue;
|
|
252
|
+
const r = rect(el);
|
|
253
|
+
const overX = text.left < r.left - 1 || text.right > r.right + 1;
|
|
254
|
+
const overY = text.top < r.top - slack || text.bottom > r.bottom + slack;
|
|
255
|
+
if (!overX && !overY) {
|
|
256
|
+
if (keeps(el)) {
|
|
257
|
+
kept = true;
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
kept = true;
|
|
263
|
+
const cuts = (overX && CLIPS.has(s.overflowX)) || (overY && CLIPS.has(s.overflowY));
|
|
264
|
+
const scrolls = (overX && SCROLLS.has(s.overflowX)) || (overY && SCROLLS.has(s.overflowY));
|
|
265
|
+
const meant = s.textOverflow === 'ellipsis' || (s.webkitLineClamp && s.webkitLineClamp !== 'none') || r.width <= 2 || r.height <= 2;
|
|
266
|
+
if (meant) break;
|
|
267
|
+
if (cuts) note(cut, el, node, { overflow: overX && CLIPS.has(s.overflowX) ? s.overflowX : s.overflowY });
|
|
268
|
+
else if (!scrolls) note(spilled, el, node, {});
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
if (!kept) bottom = Math.max(bottom, text.bottom);
|
|
272
|
+
}
|
|
273
|
+
for (const media of body.querySelectorAll(MEDIA)) {
|
|
274
|
+
const r = rect(media);
|
|
275
|
+
if (r.width > 0 && r.height > 0 && !hidden(media) && ![...ancestors(media, body)].some(keeps)) bottom = Math.max(bottom, r.bottom);
|
|
276
|
+
}
|
|
277
|
+
for (const [map, kind] of [[cut, 'cut'], [spilled, 'spill']]) {
|
|
278
|
+
for (const [box, { count, ...detail }] of [...map].slice(0, MOST)) {
|
|
279
|
+
problems.push({ level: 'layout', kind, selector: describe(box), ...detail, ...(count > 1 ? { count } : {}) });
|
|
280
|
+
}
|
|
281
|
+
if (map.size > MOST) problems.push({ level: 'layout', kind, more: map.size - MOST });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// A frame of fixed height cuts what goes past it (fine for a screen that scrolls, not for a flyer)
|
|
285
|
+
if (design.height && bottom > win.innerHeight + 1) {
|
|
286
|
+
problems.push({ level: 'note', kind: 'below', bottom: Math.round(bottom), frame: win.innerHeight });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function snippet(text) {
|
|
291
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
292
|
+
return flat.length > 40 ? `${flat.slice(0, 39)}…` : flat;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function* ancestors(el, until) {
|
|
296
|
+
for (let node = el.parentElement; node && node !== until; node = node.parentElement) yield node;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* How to find an element: its tag (but div and span) with its id or first classes, after the
|
|
301
|
+
* element around it that has one when it has neither; and, inside a component, the component first.
|
|
302
|
+
*/
|
|
303
|
+
function describe(el) {
|
|
304
|
+
const own = (node) => {
|
|
305
|
+
if (node.localName.includes('-')) return `<${node.localName}>`;
|
|
306
|
+
const tag = node.localName === 'div' || node.localName === 'span' ? '' : node.localName;
|
|
307
|
+
if (node.id) return `${tag}#${node.id}`;
|
|
308
|
+
const classes = [...node.classList].slice(0, 2);
|
|
309
|
+
return classes.length ? `${tag}.${classes.join('.')}` : node.localName;
|
|
310
|
+
};
|
|
311
|
+
let text = own(el);
|
|
312
|
+
if (!el.id && !el.classList.length && !el.localName.includes('-')) {
|
|
313
|
+
const named = el.parentElement?.closest('[id], [class]');
|
|
314
|
+
if (named && named !== el.ownerDocument.body) text = `${own(named)} ${text}`;
|
|
315
|
+
}
|
|
316
|
+
const component = [...ancestors(el)].find((node) => node.localName.includes('-'));
|
|
317
|
+
if (component && !text.startsWith('<')) text = `<${component.localName}> ${text}`;
|
|
318
|
+
return text;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Same-origin files the pages asked for that the server doesn't have, components' files among them.
|
|
322
|
+
// Each file is asked about once, however many designs use it.
|
|
323
|
+
async function findMissing(results) {
|
|
324
|
+
const status = new Map(); // path → HTTP status (0: not known yet)
|
|
325
|
+
for (const { resources, problems } of results) {
|
|
326
|
+
for (const [file, code] of resources) if (code || !status.has(file)) status.set(file, code);
|
|
327
|
+
for (const problem of problems) if (problem.path && !status.has(problem.path)) status.set(problem.path, 0);
|
|
328
|
+
}
|
|
329
|
+
await Promise.all([...status].filter(([, code]) => !code).map(async ([file]) => {
|
|
330
|
+
try {
|
|
331
|
+
status.set(file, (await fetch(encodeURI(file), { method: 'HEAD', cache: 'no-store' })).status);
|
|
332
|
+
} catch {
|
|
333
|
+
// Can't tell
|
|
334
|
+
}
|
|
335
|
+
}));
|
|
336
|
+
const missing = (file) => status.get(file) === 404;
|
|
337
|
+
for (const { resources, problems } of results) {
|
|
338
|
+
const named = new Set();
|
|
339
|
+
for (const problem of problems) {
|
|
340
|
+
if (!problem.path) continue;
|
|
341
|
+
named.add(problem.path);
|
|
342
|
+
if (problem.kind === 'component') problem.found = !missing(problem.path);
|
|
343
|
+
else if (problem.kind === 'load' && missing(problem.path)) problem.kind = 'missing';
|
|
344
|
+
}
|
|
345
|
+
for (const [file] of resources) {
|
|
346
|
+
if (missing(file) && !named.has(file)) problems.push({ level: 'error', kind: 'missing', path: file });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
package/src/client/explorer.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// so they stay sharp and readable at any zoom. The server lists the designs at /api/designs and
|
|
5
5
|
// pushes saved files at /api/events.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
import { checkDesigns } from './check.js';
|
|
8
|
+
|
|
9
|
+
const FRAME_GAP = 120; // canvas px between the designs of a section
|
|
8
10
|
const SECTION_GAP = 560; // canvas px between sections
|
|
9
11
|
const MIN_ZOOM = 0.02;
|
|
10
12
|
const MAX_ZOOM = 8;
|
|
@@ -19,6 +21,7 @@ const DRAG_SLOP = 4; // screen px a press can move and still be a click
|
|
|
19
21
|
const DOUBLE_MS = 350;
|
|
20
22
|
const CHANGE_MS = 60; // saved files arriving this close together are applied at once
|
|
21
23
|
const DEFAULT_SIZE = [1200, 800]; // a design whose entry gives no size; without a height it grows with its page
|
|
24
|
+
const FIT_HEIGHT = 100; // a frame that fits its page however short (a component's) starts this tall
|
|
22
25
|
|
|
23
26
|
const $ = (id) => document.getElementById(id);
|
|
24
27
|
const els = {
|
|
@@ -141,7 +144,8 @@ function makeDesign(board, section) {
|
|
|
141
144
|
path: `/${file.split('?')[0]}`,
|
|
142
145
|
title: board.title || file,
|
|
143
146
|
w: Number(board.width) || DEFAULT_SIZE[0],
|
|
144
|
-
|
|
147
|
+
// A page is never shorter than its frame, so a frame grows to its page but can't shrink to it
|
|
148
|
+
h: Number(board.height) || (board.fit ? FIT_HEIGHT : DEFAULT_SIZE[1]),
|
|
145
149
|
autoHeight: !Number(board.height),
|
|
146
150
|
x: 0,
|
|
147
151
|
y: 0,
|
|
@@ -185,6 +189,15 @@ function onLoad(design) {
|
|
|
185
189
|
if (!doc || !design.autoHeight) return;
|
|
186
190
|
growToPage(design);
|
|
187
191
|
doc.fonts?.ready.then(() => growToPage(design));
|
|
192
|
+
// Pages go on building themselves after they load (components, scripts): measured again when
|
|
193
|
+
// they change. Not when the frame grows: a page always a bit taller than its frame would grow forever.
|
|
194
|
+
let timer = 0;
|
|
195
|
+
new design.iframe.contentWindow.MutationObserver(() => {
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
timer = setTimeout(() => {
|
|
198
|
+
if (design.autoHeight && design.iframe.contentDocument === doc) growToPage(design);
|
|
199
|
+
}, 50);
|
|
200
|
+
}).observe(doc.documentElement, { childList: true, subtree: true });
|
|
188
201
|
}
|
|
189
202
|
|
|
190
203
|
// A design without a height takes its page's.
|
|
@@ -753,6 +766,13 @@ function connect() {
|
|
|
753
766
|
// Not ours.
|
|
754
767
|
}
|
|
755
768
|
});
|
|
769
|
+
source.addEventListener('check', (event) => {
|
|
770
|
+
try {
|
|
771
|
+
takeCheck(JSON.parse(event.data));
|
|
772
|
+
} catch {
|
|
773
|
+
// Not ours.
|
|
774
|
+
}
|
|
775
|
+
});
|
|
756
776
|
// EventSource keeps trying to reconnect by itself.
|
|
757
777
|
source.addEventListener('error', () => {
|
|
758
778
|
lost = true;
|
|
@@ -760,6 +780,52 @@ function connect() {
|
|
|
760
780
|
});
|
|
761
781
|
}
|
|
762
782
|
|
|
783
|
+
// ---------------------------------------------------------------- checking (htmlboard check)
|
|
784
|
+
|
|
785
|
+
let checking = Promise.resolve();
|
|
786
|
+
|
|
787
|
+
// The server asks every open canvas: the first to claim a check does it, after the ones it's doing.
|
|
788
|
+
async function takeCheck(request) {
|
|
789
|
+
let claimed = false;
|
|
790
|
+
try {
|
|
791
|
+
claimed = (await fetch(`/api/check/${request.id}/claim`, { method: 'POST' })).ok;
|
|
792
|
+
} catch {
|
|
793
|
+
// The server is gone
|
|
794
|
+
}
|
|
795
|
+
if (claimed) checking = checking.then(() => runCheck(request));
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async function runCheck({ id, designs: boards }) {
|
|
799
|
+
const designs = boards.map((board) => {
|
|
800
|
+
const shown = state.designs.find((d) => d.file === board.file);
|
|
801
|
+
const height = Number(board.height) || 0;
|
|
802
|
+
return {
|
|
803
|
+
file: board.file,
|
|
804
|
+
title: board.title || board.file,
|
|
805
|
+
width: Number(board.width) || DEFAULT_SIZE[0],
|
|
806
|
+
height,
|
|
807
|
+
// Without a height, the frame is as tall as the canvas has it (as tall as its page)
|
|
808
|
+
frameHeight: height || (shown?.autoHeight ? shown.h : board.fit ? FIT_HEIGHT : DEFAULT_SIZE[1]),
|
|
809
|
+
};
|
|
810
|
+
});
|
|
811
|
+
const what = designs.length === 1 ? designs[0].title : `${designs.length} designs`;
|
|
812
|
+
toast(`Checking ${what}…`);
|
|
813
|
+
let report;
|
|
814
|
+
try {
|
|
815
|
+
report = { designs: await checkDesigns(designs) };
|
|
816
|
+
} catch (err) {
|
|
817
|
+
report = { error: String(err?.message || err) };
|
|
818
|
+
}
|
|
819
|
+
try {
|
|
820
|
+
await fetch(`/api/check/${id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(report) });
|
|
821
|
+
} catch {
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
const failing = (report.designs || []).filter((d) => d.problems.some((p) => p.level !== 'note')).length;
|
|
825
|
+
const verdict = !failing ? 'no problems' : designs.length === 1 ? 'problems found' : `${failing} with problems`;
|
|
826
|
+
toast(report.error ? `Couldn't check ${what}: ${report.error}` : `Checked ${what}: ${verdict}`);
|
|
827
|
+
}
|
|
828
|
+
|
|
763
829
|
function setLive(on) {
|
|
764
830
|
els.live.hidden = false;
|
|
765
831
|
els.live.classList.toggle('is-off', !on);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// probe.js: notes what goes wrong while a design's page builds (script errors, console.error, files
|
|
2
|
+
// that don't load), for `htmlboard check`. The server puts it first in the <head> of the pages shown
|
|
3
|
+
// in frames, so it hears everything from the start. It only listens in the frames the check loads
|
|
4
|
+
// (named htmlboard-check): the canvas's own frames, and a page opened on its own, are left as they are.
|
|
5
|
+
(() => {
|
|
6
|
+
if (window.name !== 'htmlboard-check') return;
|
|
7
|
+
const MOST = 100;
|
|
8
|
+
const problems = [];
|
|
9
|
+
const note = (problem) => {
|
|
10
|
+
if (problems.length < MOST) problems.push(problem);
|
|
11
|
+
};
|
|
12
|
+
const describe = (value) => {
|
|
13
|
+
if (value instanceof Error) return `${value.name}: ${value.message}`;
|
|
14
|
+
if (typeof value === 'string') return value;
|
|
15
|
+
try {
|
|
16
|
+
return JSON.stringify(value) ?? String(value);
|
|
17
|
+
} catch {
|
|
18
|
+
return String(value);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
// Capturing: an element's failed load (an <img>, a <script>, a <link>) doesn't bubble
|
|
22
|
+
addEventListener('error', (event) => {
|
|
23
|
+
const { target } = event;
|
|
24
|
+
if (target instanceof Element) {
|
|
25
|
+
const url = [target.currentSrc, target.src, target.href, target.href?.baseVal].find((value) => typeof value === 'string');
|
|
26
|
+
note({ kind: 'load', tag: target.localName, url: url || '' });
|
|
27
|
+
} else {
|
|
28
|
+
const message = event.message || describe(event.error);
|
|
29
|
+
note({ kind: 'script', message: message.replace(/^Uncaught /, ''), source: event.filename || '', line: event.lineno || 0 });
|
|
30
|
+
}
|
|
31
|
+
}, true);
|
|
32
|
+
addEventListener('unhandledrejection', (event) => {
|
|
33
|
+
note({ kind: 'script', message: `(in a promise) ${describe(event.reason)}`, source: '', line: 0 });
|
|
34
|
+
});
|
|
35
|
+
const error = console.error;
|
|
36
|
+
console.error = function (...args) {
|
|
37
|
+
note({ kind: 'console', message: args.map(describe).join(' ') });
|
|
38
|
+
return error.apply(this, args);
|
|
39
|
+
};
|
|
40
|
+
Object.defineProperty(window, '__htmlboard', { value: Object.freeze({ problems }) });
|
|
41
|
+
})();
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
// Components: every file in a designs folder's components/ is a custom element named after it
|
|
6
|
+
// (components/tx-item.html is <tx-item>), built in the page by components.js, the script beside
|
|
7
|
+
// the folder that designs load like they link styles.css. A component file is a piece of HTML that
|
|
8
|
+
// starts with a comment showing how to use it; that example is also its preview on the canvas.
|
|
9
|
+
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
|
|
12
|
+
/** The folder of components, at the top of the designs folder */
|
|
13
|
+
export const COMPONENTS_FOLDER = 'components';
|
|
14
|
+
/** The script that turns them into custom elements, beside the folder */
|
|
15
|
+
export const COMPONENTS_SCRIPT = 'components.js';
|
|
16
|
+
/** The script as htmlboard ships it */
|
|
17
|
+
export const COMPONENTS_SCRIPT_SOURCE = path.join(__dirname, 'templates', COMPONENTS_SCRIPT);
|
|
18
|
+
/** A component's preview on the canvas is this wide, unless its file says otherwise */
|
|
19
|
+
export const PREVIEW_WIDTH = 480;
|
|
20
|
+
|
|
21
|
+
const RESERVED = new Set(['annotation-xml', 'color-profile', 'font-face', 'font-face-src', 'font-face-uri',
|
|
22
|
+
'font-face-format', 'font-face-name', 'missing-glyph']);
|
|
23
|
+
|
|
24
|
+
/** A name a custom element can have: lowercase, starting with a letter, with a hyphen */
|
|
25
|
+
export function isTagName(name) {
|
|
26
|
+
return /^[a-z][-.0-9_a-z·À-\u{EFFFF}]*$/u.test(name) && name.includes('-') && !RESERVED.has(name);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A whole page (<!DOCTYPE html> or <html> first), not a piece of one: a design, even in components/ */
|
|
30
|
+
export function isPage(html) {
|
|
31
|
+
return /^\s*(?:<!--[\s\S]*?-->\s*)*(?:<!doctype\s+html|<html[\s>])/i.test(html);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** How a component is used: the comment its file starts with, else the bare element */
|
|
35
|
+
export function componentExample(html, tag) {
|
|
36
|
+
const comment = html.match(/^\s*<!--([\s\S]*?)-->/);
|
|
37
|
+
return comment?.[1].trim() || `<${tag}></${tag}>`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The page that shows a component on the canvas (components/<tag>.html?preview): its example, with
|
|
42
|
+
* the stylesheets at the top of the designs folder and components.js. Null for any other file.
|
|
43
|
+
*/
|
|
44
|
+
export function componentPreview(designsDir, file) {
|
|
45
|
+
if (path.dirname(file) !== path.join(designsDir, COMPONENTS_FOLDER) || !/\.html$/.test(file)) return null;
|
|
46
|
+
const tag = path.basename(file, '.html');
|
|
47
|
+
if (!isTagName(tag)) return null;
|
|
48
|
+
let html;
|
|
49
|
+
try {
|
|
50
|
+
html = fs.readFileSync(file, 'utf8');
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
if (isPage(html)) return null;
|
|
55
|
+
const stylesheets = fs.readdirSync(designsDir, { withFileTypes: true })
|
|
56
|
+
.filter((entry) => entry.isFile() && /\.css$/i.test(entry.name))
|
|
57
|
+
.map((entry) => entry.name)
|
|
58
|
+
.sort();
|
|
59
|
+
return `<!DOCTYPE html>
|
|
60
|
+
<html lang="en">
|
|
61
|
+
<head>
|
|
62
|
+
<meta charset="UTF-8">
|
|
63
|
+
<title><${tag}></title>
|
|
64
|
+
${stylesheets.map((name) => ` <link rel="stylesheet" href="../${encodeURI(name)}">\n`).join('')} <script type="module" src="../${COMPONENTS_SCRIPT}"></script>
|
|
65
|
+
<style>body { display: grid; gap: 16px; margin: 0; padding: 24px; }</style>
|
|
66
|
+
</head>
|
|
67
|
+
<body>
|
|
68
|
+
${componentExample(html, tag)}
|
|
69
|
+
</body>
|
|
70
|
+
</html>
|
|
71
|
+
`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Add components.js (and the components/ folder) to a designs folder. A components.js that differs
|
|
76
|
+
* from htmlboard's (an older one, or one edited by hand) is kept unless `force` is set.
|
|
77
|
+
*
|
|
78
|
+
* @param {object} [options]
|
|
79
|
+
* @param {string} [options.dir=process.cwd()] The designs folder
|
|
80
|
+
* @param {boolean} [options.force=false] Replace a components.js that differs
|
|
81
|
+
* @returns {{path: string, status: 'installed'|'updated'|'unchanged'|'skipped'}}
|
|
82
|
+
*/
|
|
83
|
+
export function installComponents({ dir = process.cwd(), force = false } = {}) {
|
|
84
|
+
const designsDir = path.resolve(dir);
|
|
85
|
+
const dest = path.join(designsDir, COMPONENTS_SCRIPT);
|
|
86
|
+
const script = fs.readFileSync(COMPONENTS_SCRIPT_SOURCE);
|
|
87
|
+
let status = 'installed';
|
|
88
|
+
if (fs.existsSync(dest)) {
|
|
89
|
+
if (fs.readFileSync(dest).equals(script)) status = 'unchanged';
|
|
90
|
+
else status = force ? 'updated' : 'skipped';
|
|
91
|
+
}
|
|
92
|
+
fs.mkdirSync(path.join(designsDir, COMPONENTS_FOLDER), { recursive: true });
|
|
93
|
+
if (status === 'installed' || status === 'updated') fs.writeFileSync(dest, script);
|
|
94
|
+
return { path: dest, status };
|
|
95
|
+
}
|