htmlboard 0.4.0 → 0.6.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.
@@ -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
+ }
@@ -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
- const FRAME_GAP = 120; // canvas px between the designs of a section
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
- h: Number(board.height) || DEFAULT_SIZE[1],
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.id === board.id); // a size variant has its file's URL
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>&lt;${tag}&gt;</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
+ }