htmlboard 0.5.0 → 0.7.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 +29 -9
- package/bin/htmlboard.js +24 -4
- package/package.json +1 -1
- package/skills/htmlboard/SKILL.md +21 -60
- package/skills/htmlboard-components/SKILL.md +36 -0
- package/skills/htmlboard-designs-json/SKILL.md +25 -0
- package/src/canvases.js +38 -0
- package/src/check.js +1 -31
- package/src/client/explorer.css +173 -2
- package/src/client/explorer.js +436 -36
- package/src/client/index.html +33 -0
- package/src/client/picture.js +281 -0
- package/src/designs.js +44 -8
- package/src/migrate.js +2 -2
- package/src/server.js +1 -1
- package/src/skills.js +2 -1
package/src/client/index.html
CHANGED
|
@@ -29,9 +29,28 @@
|
|
|
29
29
|
<kbd aria-hidden="true">/</kbd>
|
|
30
30
|
</div>
|
|
31
31
|
<div class="filters" id="filters" role="group" aria-label="Show" hidden></div>
|
|
32
|
+
<div class="sections-bar" id="sections-bar" hidden>
|
|
33
|
+
<span class="sections-label">Sections</span>
|
|
34
|
+
<div class="sections-tools" role="group" aria-label="All sections">
|
|
35
|
+
<button class="mini" type="button" id="expand-all" title="Expand all" aria-label="Expand all sections">
|
|
36
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m7 15 5 5 5-5M7 9l5-5 5 5"/></svg>
|
|
37
|
+
</button>
|
|
38
|
+
<button class="mini" type="button" id="collapse-all" title="Collapse all" aria-label="Collapse all sections">
|
|
39
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m7 20 5-5 5 5M7 4l5 5 5-5"/></svg>
|
|
40
|
+
</button>
|
|
41
|
+
<span class="mini-gap" aria-hidden="true"></span>
|
|
42
|
+
<button class="mini" type="button" id="show-all" title="Show all on the canvas" aria-label="Show all sections">
|
|
43
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0"/><circle cx="12" cy="12" r="3"/></svg>
|
|
44
|
+
</button>
|
|
45
|
+
<button class="mini" type="button" id="hide-all" title="Hide all from the canvas" aria-label="Hide all sections">
|
|
46
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10.73 5.08a10.74 10.74 0 0 1 11.2 6.57 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-1.44 2.49M14.08 14.16a3 3 0 0 1-4.24-4.24M17.48 17.5a10.75 10.75 0 0 1-15.42-5.15 1 1 0 0 1 0-.7 10.75 10.75 0 0 1 4.45-5.14M2 2l20 20"/></svg>
|
|
47
|
+
</button>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
32
50
|
<p class="no-match" id="no-match" hidden>No design matches the search.</p>
|
|
33
51
|
<nav class="list" id="list" aria-label="Designs by section"></nav>
|
|
34
52
|
<p class="sidebar-hint">Drag or scroll to move, <kbd>Ctrl</kbd> + scroll or pinch to zoom, double-click a design to fit it. <kbd>?</kbd> lists the shortcuts.</p>
|
|
53
|
+
|
|
35
54
|
</aside>
|
|
36
55
|
|
|
37
56
|
<main class="viewport" id="viewport" aria-label="Canvas">
|
|
@@ -41,6 +60,14 @@
|
|
|
41
60
|
<div class="outline outline--selected" id="selection" hidden></div>
|
|
42
61
|
<div class="size" id="size" hidden></div>
|
|
43
62
|
</div>
|
|
63
|
+
<div class="actions" id="actions" role="toolbar" aria-label="Design" hidden>
|
|
64
|
+
<button class="mini" type="button" id="copy-image" title="Copy as an image (C)" aria-label="Copy as an image">
|
|
65
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
|
66
|
+
</button>
|
|
67
|
+
<button class="mini" type="button" id="save-image" title="Save as an image (S); Shift + click: choose the folder" aria-label="Save as an image">
|
|
68
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
|
|
69
|
+
</button>
|
|
70
|
+
</div>
|
|
44
71
|
|
|
45
72
|
<div class="toolbar" role="toolbar" aria-label="Canvas">
|
|
46
73
|
<button class="tool" type="button" id="toggle-list" title="Show or hide the list (\)" aria-label="Show or hide the list" aria-pressed="true">
|
|
@@ -83,6 +110,7 @@
|
|
|
83
110
|
<div><dt><kbd>Shift</kbd> + <kbd>2</kbd>, double-click</dt><dd>Fit the design</dd></div>
|
|
84
111
|
<div><dt><kbd>←</kbd> <kbd>→</kbd></dt><dd>Previous, next design</dd></div>
|
|
85
112
|
<div><dt><kbd>Enter</kbd></dt><dd>Open the design on its own</dd></div>
|
|
113
|
+
<div><dt><kbd>C</kbd> <kbd>S</kbd></dt><dd>Copy, save the design as an image</dd></div>
|
|
86
114
|
<div><dt><kbd>/</kbd></dt><dd>Search</dd></div>
|
|
87
115
|
<div><dt><kbd>1</kbd> – <kbd>4</kbd></dt><dd>All, mobile, tablet, desktop</dd></div>
|
|
88
116
|
<div><dt><kbd>R</kbd></dt><dd>Reload the designs</dd></div>
|
|
@@ -91,6 +119,11 @@
|
|
|
91
119
|
</dl>
|
|
92
120
|
</section>
|
|
93
121
|
|
|
122
|
+
<div class="empty" id="empty" hidden>
|
|
123
|
+
<p id="empty-text">All sections are hidden.</p>
|
|
124
|
+
<button class="empty-show" type="button" id="empty-show">Show all</button>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
94
127
|
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
|
95
128
|
<div class="notice" id="notice" hidden>
|
|
96
129
|
<img class="notice-mark" src="/__explorer/mark.svg" alt="" height="72">
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// A design as an image, to copy or save it. The browser draws it from the page as its frame has it:
|
|
2
|
+
// the page's HTML and CSS go into an SVG <foreignObject>, with the files they use (stylesheets, fonts,
|
|
3
|
+
// images) inlined, since an image can't load anything. Scripts don't run in an image, so it shows the
|
|
4
|
+
// page as built when it was taken.
|
|
5
|
+
//
|
|
6
|
+
// What it can't show: images from another site that doesn't allow reading them (CORS), pages inside
|
|
7
|
+
// the page (iframes), shadow DOM. A video shows the frame it is at, or its poster.
|
|
8
|
+
|
|
9
|
+
const MAX_SIDE = 16384; // px: the longest side browsers draw a canvas at
|
|
10
|
+
const MAX_AREA = 64e6; // px: about 256 MB of pixels
|
|
11
|
+
const LOAD_MS = 10000;
|
|
12
|
+
const TRANSPARENT = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
13
|
+
const URL_RE = /url\(\s*(['"]?)(.*?)\1\s*\)/gi;
|
|
14
|
+
const IMPORT_RULE = 3;
|
|
15
|
+
const FONT_FACE_RULE = 5;
|
|
16
|
+
const LEFT_OUT = new Set(['script', 'noscript', 'style', 'link', 'source', 'track']);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The page in `iframe` drawn on a canvas, `width` × `height` CSS px at `scale` device px per CSS px
|
|
20
|
+
* (less when that would make a canvas too big to draw).
|
|
21
|
+
*/
|
|
22
|
+
export async function drawDesign(iframe, { width, height, scale = 1 }) {
|
|
23
|
+
const doc = await loaded(iframe);
|
|
24
|
+
const win = iframe.contentWindow;
|
|
25
|
+
await doc.fonts?.ready;
|
|
26
|
+
const files = reader(win);
|
|
27
|
+
const [css, root] = await Promise.all([styles(doc, files), copyPage(doc, files)]);
|
|
28
|
+
const style = doc.createElement('style');
|
|
29
|
+
style.textContent = css;
|
|
30
|
+
(root.querySelector('head') || root).append(style);
|
|
31
|
+
const markup = new XMLSerializer().serializeToString(root);
|
|
32
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">`
|
|
33
|
+
+ `<foreignObject x="0" y="0" width="${width}" height="${height}">${markup}</foreignObject></svg>`;
|
|
34
|
+
const image = new Image();
|
|
35
|
+
// A data: URL, not a blob: one, which would keep the canvas from being read (Chromium)
|
|
36
|
+
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
|
37
|
+
try {
|
|
38
|
+
await image.decode();
|
|
39
|
+
} catch {
|
|
40
|
+
throw new Error('the browser couldn’t draw this page');
|
|
41
|
+
}
|
|
42
|
+
scale = Math.min(scale, MAX_SIDE / width, MAX_SIDE / height, Math.sqrt(MAX_AREA / (width * height)));
|
|
43
|
+
const canvas = document.createElement('canvas');
|
|
44
|
+
canvas.width = Math.max(1, Math.round(width * scale));
|
|
45
|
+
canvas.height = Math.max(1, Math.round(height * scale));
|
|
46
|
+
const context = canvas.getContext('2d');
|
|
47
|
+
// The page's background fills the frame in a browser; in an image it would cover only its box.
|
|
48
|
+
context.fillStyle = backdrop(doc, win);
|
|
49
|
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
50
|
+
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
51
|
+
return canvas;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The frame's page once it has loaded (a frame reloading after a save is waited for) */
|
|
55
|
+
function loaded(iframe) {
|
|
56
|
+
const ready = () => {
|
|
57
|
+
const doc = iframe.contentDocument;
|
|
58
|
+
return doc && doc.readyState === 'complete' && doc.URL !== 'about:blank' ? doc : null;
|
|
59
|
+
};
|
|
60
|
+
if (ready()) return Promise.resolve(ready());
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const done = () => {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
iframe.removeEventListener('load', done);
|
|
65
|
+
if (ready()) resolve(ready());
|
|
66
|
+
else reject(new Error('the page didn’t load'));
|
|
67
|
+
};
|
|
68
|
+
const timer = setTimeout(done, LOAD_MS);
|
|
69
|
+
iframe.addEventListener('load', done);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------- the files the page uses
|
|
74
|
+
|
|
75
|
+
// Reads the files the page uses as data: URLs, each once. `used` holds the files the page loaded, to
|
|
76
|
+
// leave out the fonts it never needed (another alphabet's part of a font); null when it can't tell.
|
|
77
|
+
function reader(win) {
|
|
78
|
+
let used = null;
|
|
79
|
+
try {
|
|
80
|
+
const entries = win.performance.getEntriesByType('resource');
|
|
81
|
+
if (entries.length < 250) used = new Set(entries.map((entry) => entry.name)); // browsers stop listing at 250
|
|
82
|
+
} catch {
|
|
83
|
+
// Can't tell
|
|
84
|
+
}
|
|
85
|
+
const read = new Map();
|
|
86
|
+
return {
|
|
87
|
+
used,
|
|
88
|
+
// '' when it can't be read: missing, or another site's that doesn't allow it
|
|
89
|
+
dataUrl(url) {
|
|
90
|
+
if (!read.has(url)) {
|
|
91
|
+
read.set(url, fetch(url)
|
|
92
|
+
.then((res) => (res.ok ? res.blob() : null))
|
|
93
|
+
.then((blob) => (blob ? asDataUrl(blob) : ''))
|
|
94
|
+
.catch(() => ''));
|
|
95
|
+
}
|
|
96
|
+
return read.get(url);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function asDataUrl(blob) {
|
|
102
|
+
return new Promise((resolve) => {
|
|
103
|
+
const fileReader = new FileReader();
|
|
104
|
+
fileReader.onload = () => resolve(String(fileReader.result));
|
|
105
|
+
fileReader.onerror = () => resolve('');
|
|
106
|
+
fileReader.readAsDataURL(blob);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function absolute(url, base) {
|
|
111
|
+
try {
|
|
112
|
+
return new URL(url, base).href;
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** CSS with every url(…) in it as a data: URL (an empty one for a file it can't read) */
|
|
119
|
+
async function inlineUrls(css, base, files) {
|
|
120
|
+
const urls = new Map();
|
|
121
|
+
for (const [, , raw] of css.matchAll(URL_RE)) {
|
|
122
|
+
const url = raw.trim();
|
|
123
|
+
if (!url || urls.has(url) || /^(data:|#|about:)/i.test(url)) continue;
|
|
124
|
+
const full = absolute(url, base);
|
|
125
|
+
if (full) urls.set(url, files.dataUrl(full));
|
|
126
|
+
}
|
|
127
|
+
if (!urls.size) return css;
|
|
128
|
+
const data = new Map();
|
|
129
|
+
for (const [url, reading] of urls) data.set(url, await reading);
|
|
130
|
+
return css.replace(URL_RE, (whole, quote, raw) => {
|
|
131
|
+
const value = data.get(raw.trim());
|
|
132
|
+
return value === undefined ? whole : `url("${value || 'data:,'}")`;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------- the page's CSS
|
|
137
|
+
|
|
138
|
+
/** All the CSS the page has, in its order, with the files it uses inlined */
|
|
139
|
+
async function styles(doc, files) {
|
|
140
|
+
const parts = [];
|
|
141
|
+
for (const sheet of doc.styleSheets) if (!sheet.disabled) parts.push(await sheetCss(sheet, doc.baseURI, files));
|
|
142
|
+
for (const sheet of doc.adoptedStyleSheets || []) parts.push(await rulesCss(sheet.cssRules, doc.baseURI, files));
|
|
143
|
+
return parts.join('\n');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function sheetCss(sheet, base, files, media = sheet.media?.mediaText) {
|
|
147
|
+
const href = sheet.href || base;
|
|
148
|
+
let css;
|
|
149
|
+
try {
|
|
150
|
+
css = await rulesCss(sheet.cssRules, href, files);
|
|
151
|
+
} catch {
|
|
152
|
+
// Another site's stylesheet, which the page can use but not read: fetched again, as most allow
|
|
153
|
+
// (font services and CDNs do).
|
|
154
|
+
css = sheet.href ? await fetch(sheet.href).then((res) => (res.ok ? res.text() : '')).catch(() => '') : '';
|
|
155
|
+
css = await inlineUrls(css, href, files);
|
|
156
|
+
}
|
|
157
|
+
return media && media !== 'all' ? `@media ${media} {\n${css}\n}` : css;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function rulesCss(rules, base, files) {
|
|
161
|
+
const parts = [];
|
|
162
|
+
for (const rule of rules) {
|
|
163
|
+
if (rule.type === IMPORT_RULE) {
|
|
164
|
+
let css = rule.styleSheet ? await sheetCss(rule.styleSheet, base, files, rule.media?.mediaText) : '';
|
|
165
|
+
if (rule.supportsText) css = `@supports ${rule.supportsText} {\n${css}\n}`;
|
|
166
|
+
if (rule.layerName != null) css = `@layer${rule.layerName ? ` ${rule.layerName}` : ''} {\n${css}\n}`;
|
|
167
|
+
parts.push(css);
|
|
168
|
+
} else if (rule.type !== FONT_FACE_RULE || !unusedFont(rule, base, files.used)) {
|
|
169
|
+
parts.push(rule.cssText);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return inlineUrls(parts.join('\n'), base, files);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// A font face whose files the page never loaded
|
|
176
|
+
function unusedFont(rule, base, used) {
|
|
177
|
+
if (!used) return false;
|
|
178
|
+
const urls = [...rule.cssText.matchAll(URL_RE)].map((match) => match[2].trim()).filter((url) => !/^data:/i.test(url));
|
|
179
|
+
return urls.length > 0 && !urls.some((url) => used.has(absolute(url, base)));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------- the page's HTML
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A copy of the page as it is now: without scripts and stylesheets (their CSS comes whole), with what
|
|
186
|
+
* the page's elements hold but their markup doesn't say (the image each <img> chose, form values, a
|
|
187
|
+
* canvas's drawing) and its files inlined.
|
|
188
|
+
*/
|
|
189
|
+
async function copyPage(doc, files) {
|
|
190
|
+
const root = doc.documentElement;
|
|
191
|
+
const copy = root.cloneNode(true);
|
|
192
|
+
const live = [root, ...root.querySelectorAll('*')];
|
|
193
|
+
const copies = [copy, ...copy.querySelectorAll('*')]; // the same elements, in the same order
|
|
194
|
+
const waits = [];
|
|
195
|
+
live.forEach((el, i) => {
|
|
196
|
+
const out = copies[i];
|
|
197
|
+
const tag = el.localName;
|
|
198
|
+
if (LEFT_OUT.has(tag)) {
|
|
199
|
+
out.remove();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (tag === 'img') {
|
|
203
|
+
const src = el.currentSrc || el.src;
|
|
204
|
+
for (const attribute of ['srcset', 'sizes', 'loading']) out.removeAttribute(attribute);
|
|
205
|
+
if (src) waits.push(files.dataUrl(src).then((data) => out.setAttribute('src', data || TRANSPARENT)));
|
|
206
|
+
} else if (tag === 'image' && (el.getAttribute('href') || el.getAttribute('xlink:href'))) {
|
|
207
|
+
const full = absolute(el.getAttribute('href') || el.getAttribute('xlink:href'), doc.baseURI);
|
|
208
|
+
if (full) {
|
|
209
|
+
waits.push(files.dataUrl(full).then((data) => {
|
|
210
|
+
out.removeAttribute('xlink:href');
|
|
211
|
+
out.setAttribute('href', data || TRANSPARENT);
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
} else if (tag === 'canvas') {
|
|
215
|
+
// Without scripts (as in an image), a canvas shows what it holds instead of its drawing
|
|
216
|
+
asImage(el, out, canvasData(el));
|
|
217
|
+
} else if (tag === 'video') {
|
|
218
|
+
const frame = videoFrame(el);
|
|
219
|
+
waits.push((frame || !el.poster ? Promise.resolve(frame) : files.dataUrl(el.poster)).then((data) => asImage(el, out, data, 'contain')));
|
|
220
|
+
} else if (tag === 'iframe' || tag === 'frame' || tag === 'embed' || tag === 'object') {
|
|
221
|
+
for (const attribute of ['src', 'srcdoc', 'data']) out.removeAttribute(attribute);
|
|
222
|
+
} else if (tag === 'input') {
|
|
223
|
+
if (el.type === 'checkbox' || el.type === 'radio') out.toggleAttribute('checked', el.checked);
|
|
224
|
+
else if (el.type !== 'file') out.setAttribute('value', el.value);
|
|
225
|
+
} else if (tag === 'textarea') {
|
|
226
|
+
out.textContent = el.value;
|
|
227
|
+
} else if (tag === 'option') {
|
|
228
|
+
out.toggleAttribute('selected', el.selected);
|
|
229
|
+
}
|
|
230
|
+
const style = out.getAttribute('style');
|
|
231
|
+
if (style && /url\(/i.test(style)) waits.push(inlineUrls(style, doc.baseURI, files).then((css) => out.setAttribute('style', css)));
|
|
232
|
+
});
|
|
233
|
+
await Promise.all(waits);
|
|
234
|
+
return copy;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// An <img> of what a canvas or a video shows, in its place: its attributes (for the page's CSS) and
|
|
238
|
+
// the size the page gave it.
|
|
239
|
+
function asImage(el, out, data, fit = 'fill') {
|
|
240
|
+
const img = el.ownerDocument.createElement('img');
|
|
241
|
+
for (const { name, value } of out.attributes) {
|
|
242
|
+
if (!['src', 'poster', 'autoplay', 'controls', 'width', 'height'].includes(name)) img.setAttribute(name, value);
|
|
243
|
+
}
|
|
244
|
+
const style = el.ownerDocument.defaultView.getComputedStyle(el);
|
|
245
|
+
img.style.width = style.width;
|
|
246
|
+
img.style.height = style.height;
|
|
247
|
+
img.style.objectFit = fit;
|
|
248
|
+
img.src = data || TRANSPARENT;
|
|
249
|
+
out.replaceWith(img);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function canvasData(canvas) {
|
|
253
|
+
try {
|
|
254
|
+
return canvas.toDataURL();
|
|
255
|
+
} catch {
|
|
256
|
+
return 'data:,'; // drawn from another site: unreadable
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function videoFrame(video) {
|
|
261
|
+
try {
|
|
262
|
+
if (video.readyState < 2 || !video.videoWidth) return '';
|
|
263
|
+
const canvas = document.createElement('canvas');
|
|
264
|
+
canvas.width = video.videoWidth;
|
|
265
|
+
canvas.height = video.videoHeight;
|
|
266
|
+
canvas.getContext('2d').drawImage(video, 0, 0);
|
|
267
|
+
return canvas.toDataURL('image/jpeg', 0.9);
|
|
268
|
+
} catch {
|
|
269
|
+
return '';
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The colour that fills the page behind its content: the root's background, or the body's, or white */
|
|
274
|
+
function backdrop(doc, win) {
|
|
275
|
+
for (const el of [doc.documentElement, doc.body]) {
|
|
276
|
+
if (!el) continue;
|
|
277
|
+
const color = win.getComputedStyle(el).backgroundColor;
|
|
278
|
+
if (color && !/^transparent$|,\s*0\)$|\/\s*0\)$/.test(color)) return color;
|
|
279
|
+
}
|
|
280
|
+
return '#FFFFFF';
|
|
281
|
+
}
|
package/src/designs.js
CHANGED
|
@@ -120,12 +120,36 @@ function parseSettings(content) {
|
|
|
120
120
|
return settings;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
|
|
123
|
+
// A variant's own setting, as the htmlboard tag says it: width=390, height=844, category=mobile
|
|
124
|
+
const VARIANT_SETTING = /^(width|height|category)=[^,;\s]+[,;]?$/i;
|
|
125
|
+
const LABEL_START = /^[·|:,\-–—]+\s*/;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* <meta name="htmlboard:variant" content="?tema=escuro · escuro">: the file once more, with a
|
|
129
|
+
* query string, a size (width=390, height=844) or both, then a label. A query may leave out its "?".
|
|
130
|
+
*/
|
|
124
131
|
function parseVariant(content) {
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
132
|
+
const tokens = content.trim().split(/\s+/);
|
|
133
|
+
let query = '';
|
|
134
|
+
const settings = [];
|
|
135
|
+
let i = 0;
|
|
136
|
+
for (; i < tokens.length; i++) {
|
|
137
|
+
const token = tokens[i];
|
|
138
|
+
if (VARIANT_SETTING.test(token)) settings.push(token.replace(/[,;]$/, ''));
|
|
139
|
+
else if (token.startsWith('?') && !query) query = token;
|
|
140
|
+
else if (i === 0 && !LABEL_START.test(token)) query = `?${token}`;
|
|
141
|
+
else break;
|
|
142
|
+
}
|
|
143
|
+
if (query === '?') query = '';
|
|
144
|
+
const { width, height, category } = parseSettings(settings.join(','));
|
|
145
|
+
if (!query && !width && !height) return null;
|
|
146
|
+
return { query, label: tokens.slice(i).join(' ').replace(LABEL_START, '').trim(), ...compact({ width, height, category }) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A variant as its tag's content says it: "?theme=dark width=390, height=844 · dark mobile" */
|
|
150
|
+
export function formatVariant({ query = '', label = '', width, height, category }) {
|
|
151
|
+
const size = [width && `width=${width}`, height && `height=${height}`, category && `category=${category}`].filter(Boolean).join(', ');
|
|
152
|
+
return [query, size].filter(Boolean).join(' ') + (label ? ` · ${label}` : '');
|
|
129
153
|
}
|
|
130
154
|
|
|
131
155
|
/** What an HTML file says about itself, from its <head> (see the top of this file) */
|
|
@@ -196,12 +220,24 @@ function fileDesign(file, folder, meta) {
|
|
|
196
220
|
});
|
|
197
221
|
}
|
|
198
222
|
|
|
199
|
-
|
|
223
|
+
// A variant of a file's design: another query string (a state the page builds from its URL), another
|
|
224
|
+
// size (the same page at a phone's width), or both. Without a height of its own, a variant that
|
|
225
|
+
// changes the width grows with its page. One that only changes the size loads the same URL as its
|
|
226
|
+
// file, so its id comes from its label.
|
|
227
|
+
function variantDesign(base, { query = '', label, width, height, category }) {
|
|
228
|
+
const sized = Boolean(width || height);
|
|
229
|
+
const size = sized ? { width: width ?? base.width, height: height ?? (width ? undefined : base.height) } : {};
|
|
230
|
+
const sizeName = sized ? `${size.width}${size.height ? ` × ${size.height}` : ''}` : '';
|
|
231
|
+
const name = label || [query.slice(1), sizeName].filter(Boolean).join(' · ');
|
|
200
232
|
return compact({
|
|
201
233
|
...base,
|
|
202
|
-
|
|
234
|
+
...size,
|
|
235
|
+
key: base.file + query + (sized ? `@${size.width}x${size.height ?? ''}` : ''),
|
|
203
236
|
file: base.file + query,
|
|
204
|
-
title: `${base.baseTitle} · ${
|
|
237
|
+
title: `${base.baseTitle} · ${name}`,
|
|
238
|
+
// A new size is a new device: its category comes from its size, unless it says one
|
|
239
|
+
category: sized ? category : category ?? base.category,
|
|
240
|
+
id: sized ? `${designId(base.file + query)}-${slugify(name)}` : undefined,
|
|
205
241
|
variantOf: base.file
|
|
206
242
|
});
|
|
207
243
|
}
|
package/src/migrate.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { loadDesignsManifest, parseDesignMeta, stripPrefix } from './designs.js';
|
|
4
|
+
import { loadDesignsManifest, parseDesignMeta, stripPrefix, formatVariant } from './designs.js';
|
|
5
5
|
|
|
6
6
|
// `htmlboard migrate`: moves what a designs.json says about each design into the design's own file
|
|
7
7
|
// (its size, category, label, variants and description) and leaves designs.json with what only it
|
|
@@ -110,7 +110,7 @@ export function planMigration(designsDir) {
|
|
|
110
110
|
}
|
|
111
111
|
for (const key of ['description', 'tags', 'id']) if (item.entry[key] !== undefined) item.keep[key] = item.entry[key];
|
|
112
112
|
}
|
|
113
|
-
for (const v of declared) lines.push(`<meta name="htmlboard:variant" content="${escapeAttr(
|
|
113
|
+
for (const v of declared) lines.push(`<meta name="htmlboard:variant" content="${escapeAttr(formatVariant(v))}">`);
|
|
114
114
|
|
|
115
115
|
const next = rewriteHead(html, lines);
|
|
116
116
|
if (next !== html) files.push({ file: base, lines, html: next });
|
package/src/server.js
CHANGED
|
@@ -224,7 +224,7 @@ export function createServer(options = {}) {
|
|
|
224
224
|
const wanted = only.map((item) => item.replace(/\\/g, '/').replace(/^\.?\/+|\/+$/g, ''));
|
|
225
225
|
const designs = loadDesignsManifest(targetDir, rootDir).sections
|
|
226
226
|
.flatMap((section) => section.artboards)
|
|
227
|
-
.map(({ file, title, width, height, fit }) => ({ file, title, width, height, fit }))
|
|
227
|
+
.map(({ id, file, title, width, height, fit }) => ({ id, file, title, width, height, fit }))
|
|
228
228
|
.filter(({ file }) => {
|
|
229
229
|
const own = (designsPath ? file.slice(designsPath.length + 1) : file).split('?')[0];
|
|
230
230
|
return !wanted.length || wanted.some((item) => !item || own === item || own.startsWith(`${item}/`));
|
package/src/skills.js
CHANGED
|
@@ -21,7 +21,8 @@ export const SKILL_TARGETS = {
|
|
|
21
21
|
function bundledSkills() {
|
|
22
22
|
return fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
|
|
23
23
|
.filter((entry) => entry.isDirectory() && fs.existsSync(path.join(SKILLS_DIR, entry.name, 'SKILL.md')))
|
|
24
|
-
.map((entry) => entry.name)
|
|
24
|
+
.map((entry) => entry.name)
|
|
25
|
+
.sort();
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
function resolveTargets(agent = 'all') {
|