@codepress/design-kit 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +11 -0
- package/README.md +228 -2
- package/bin/design-kit.mjs +4 -0
- package/package.json +66 -4
- package/schema/design-kit.config.schema.json +335 -0
- package/src/capture-pages.mjs +725 -0
- package/src/capture-stories.mjs +1226 -0
- package/src/cli.mjs +175 -0
- package/src/config.mjs +276 -0
- package/src/glob.mjs +46 -0
- package/src/index.d.ts +137 -0
- package/src/index.mjs +17 -0
- package/src/kit.mjs +1087 -0
- package/src/pages.mjs +286 -0
- package/src/playwright.mjs +46 -0
package/src/kit.mjs
ADDED
|
@@ -0,0 +1,1087 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The design kit's own tool. It ships inside every kit, so tool and data
|
|
4
|
+
* always match. Pure Node: no dependencies, no network.
|
|
5
|
+
*
|
|
6
|
+
* node kit.mjs info
|
|
7
|
+
* node kit.mjs find <query>
|
|
8
|
+
* node kit.mjs show <id> [--full]
|
|
9
|
+
* node kit.mjs compose --frame <id> [--slot <name>=<file>]… [--theme light|dark]
|
|
10
|
+
* [--show-new] --out <file>
|
|
11
|
+
*
|
|
12
|
+
* `--kit <dir>` (or DESIGN_KIT_DIR) points at a kit; the default is the
|
|
13
|
+
* folder this file sits in. Format: the @codepress/design-kit README.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { basename, dirname, extname, join, resolve, sep } from "node:path";
|
|
17
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
|
+
|
|
19
|
+
const FIND_LIMIT = 30;
|
|
20
|
+
const SHOW_LIMIT = 2048;
|
|
21
|
+
const SNIPPET_DEPTH = 8;
|
|
22
|
+
|
|
23
|
+
class KitError extends Error {}
|
|
24
|
+
|
|
25
|
+
// ── kit loading ─────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function readJson(file, fallback) {
|
|
28
|
+
if (!existsSync(file)) return fallback;
|
|
29
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Story rows from index.json plus route rows from routes.json, by id. */
|
|
33
|
+
export function loadKit(dir) {
|
|
34
|
+
const manifest = readJson(join(dir, "manifest.json"), null);
|
|
35
|
+
const rows = new Map();
|
|
36
|
+
for (const row of [
|
|
37
|
+
...readJson(join(dir, "index.json"), []),
|
|
38
|
+
...readJson(join(dir, "routes.json"), []),
|
|
39
|
+
]) {
|
|
40
|
+
if (row && typeof row.id === "string" && !rows.has(row.id)) {
|
|
41
|
+
rows.set(row.id, row);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const sorted = [...rows.values()].sort((a, b) =>
|
|
45
|
+
a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
|
|
46
|
+
);
|
|
47
|
+
return { dir, manifest, rows: sorted, byId: new Map(rows) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function rowSource(row) {
|
|
51
|
+
return row.kind === "route" || row.id.startsWith("route:")
|
|
52
|
+
? "routes"
|
|
53
|
+
: "stories";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A source's stylesheet: `manifest.css[source]`, else `css/<source>.css`. */
|
|
57
|
+
function cssFile(kit, source) {
|
|
58
|
+
const named = kit.manifest?.css?.[source];
|
|
59
|
+
return typeof named === "string" && named && !named.includes("..")
|
|
60
|
+
? named
|
|
61
|
+
: `css/${source}.css`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A row's markup, found only through its `file` (never derived from the id). */
|
|
65
|
+
function snippetPath(kit, row) {
|
|
66
|
+
if (typeof row.file !== "string" || !row.file) {
|
|
67
|
+
throw new KitError(`${row.id}: its index row has no file`);
|
|
68
|
+
}
|
|
69
|
+
return join(kit.dir, row.file);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readSnippet(kit, row) {
|
|
73
|
+
const file = snippetPath(kit, row);
|
|
74
|
+
if (!existsSync(file)) {
|
|
75
|
+
throw new KitError(`${row.id}: its markup ${file} is missing from the kit`);
|
|
76
|
+
}
|
|
77
|
+
return readFileSync(file, "utf8");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── near matches ────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
function distance(a, b) {
|
|
83
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
84
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
85
|
+
let previous = row[0];
|
|
86
|
+
row[0] = i;
|
|
87
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
88
|
+
const current = row[j];
|
|
89
|
+
row[j] = Math.min(
|
|
90
|
+
row[j] + 1,
|
|
91
|
+
row[j - 1] + 1,
|
|
92
|
+
previous + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
93
|
+
);
|
|
94
|
+
previous = current;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return row[b.length];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Up to five candidates, closest first. */
|
|
101
|
+
export function nearMatches(wanted, candidates) {
|
|
102
|
+
const needle = wanted.toLowerCase();
|
|
103
|
+
const bare = needle.replace(/^(story|route):/, "").replace(/@\d+$/, "");
|
|
104
|
+
return candidates
|
|
105
|
+
.map((candidate) => {
|
|
106
|
+
const hay = candidate.toLowerCase();
|
|
107
|
+
const contains =
|
|
108
|
+
bare.length > 0 && (hay.includes(bare) || bare.includes(hay));
|
|
109
|
+
return {
|
|
110
|
+
candidate,
|
|
111
|
+
score: contains ? -1 : distance(needle, hay),
|
|
112
|
+
};
|
|
113
|
+
})
|
|
114
|
+
.filter(({ score }) => score < Math.max(4, Math.floor(needle.length / 2)))
|
|
115
|
+
.sort((a, b) => a.score - b.score || (a.candidate < b.candidate ? -1 : 1))
|
|
116
|
+
.slice(0, 5)
|
|
117
|
+
.map(({ candidate }) => candidate);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function unknown(what, wanted, candidates) {
|
|
121
|
+
const near = nearMatches(wanted, candidates);
|
|
122
|
+
const hint = near.length
|
|
123
|
+
? ` Did you mean: ${near.join(", ")}?`
|
|
124
|
+
: candidates.length
|
|
125
|
+
? ""
|
|
126
|
+
: " There are none.";
|
|
127
|
+
return new KitError(`unknown ${what} "${wanted}".${hint}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function requireRow(kit, id, what) {
|
|
131
|
+
const row = kit.byId.get(id);
|
|
132
|
+
if (!row) {
|
|
133
|
+
throw unknown(
|
|
134
|
+
what,
|
|
135
|
+
id,
|
|
136
|
+
kit.rows.map((r) => r.id),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return row;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── a small HTML scanner (enough for browser-serialized markup) ──────────────
|
|
143
|
+
|
|
144
|
+
const VOID = new Set(
|
|
145
|
+
"area base br col embed hr img input link meta source track wbr".split(" "),
|
|
146
|
+
);
|
|
147
|
+
const RAW = new Set(["script", "style", "textarea", "title"]);
|
|
148
|
+
|
|
149
|
+
function parseAttributes(source) {
|
|
150
|
+
const attrs = {};
|
|
151
|
+
const pattern = /([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
152
|
+
let match;
|
|
153
|
+
while ((match = pattern.exec(source))) {
|
|
154
|
+
attrs[match[1].toLowerCase()] = match[2] ?? match[3] ?? match[4] ?? "";
|
|
155
|
+
}
|
|
156
|
+
return attrs;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Tokens with offsets: open / close / text / comment. */
|
|
160
|
+
export function tokenize(html) {
|
|
161
|
+
const tokens = [];
|
|
162
|
+
let i = 0;
|
|
163
|
+
while (i < html.length) {
|
|
164
|
+
const lt = html.indexOf("<", i);
|
|
165
|
+
if (lt < 0) {
|
|
166
|
+
tokens.push({ type: "text", start: i, end: html.length });
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
if (lt > i) tokens.push({ type: "text", start: i, end: lt });
|
|
170
|
+
if (html.startsWith("<!--", lt)) {
|
|
171
|
+
const close = html.indexOf("-->", lt + 4);
|
|
172
|
+
const end = close < 0 ? html.length : close + 3;
|
|
173
|
+
tokens.push({ type: "comment", start: lt, end });
|
|
174
|
+
i = end;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const closing = html[lt + 1] === "/";
|
|
178
|
+
const nameMatch = /^[a-zA-Z][^\s/>]*/.exec(
|
|
179
|
+
html.slice(lt + (closing ? 2 : 1), lt + 200),
|
|
180
|
+
);
|
|
181
|
+
if (!nameMatch) {
|
|
182
|
+
tokens.push({ type: "text", start: lt, end: lt + 1 });
|
|
183
|
+
i = lt + 1;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
// Find the tag's end, respecting quoted attribute values.
|
|
187
|
+
let j = lt + (closing ? 2 : 1) + nameMatch[0].length;
|
|
188
|
+
let quote = null;
|
|
189
|
+
for (; j < html.length; j += 1) {
|
|
190
|
+
const ch = html[j];
|
|
191
|
+
if (quote) {
|
|
192
|
+
if (ch === quote) quote = null;
|
|
193
|
+
} else if (ch === '"' || ch === "'") quote = ch;
|
|
194
|
+
else if (ch === ">") break;
|
|
195
|
+
}
|
|
196
|
+
const end = Math.min(j + 1, html.length);
|
|
197
|
+
const name = nameMatch[0].toLowerCase();
|
|
198
|
+
if (closing) {
|
|
199
|
+
tokens.push({ type: "close", name, start: lt, end });
|
|
200
|
+
i = end;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const inner = html.slice(lt + 1 + nameMatch[0].length, end - 1);
|
|
204
|
+
const selfClosing = /\/\s*$/.test(inner);
|
|
205
|
+
tokens.push({
|
|
206
|
+
type: "open",
|
|
207
|
+
name,
|
|
208
|
+
attrs: parseAttributes(inner.replace(/\/\s*$/, "")),
|
|
209
|
+
start: lt,
|
|
210
|
+
end,
|
|
211
|
+
selfClosing: selfClosing || VOID.has(name),
|
|
212
|
+
});
|
|
213
|
+
i = end;
|
|
214
|
+
if (RAW.has(name) && !selfClosing) {
|
|
215
|
+
const closeAt = html.toLowerCase().indexOf(`</${name}`, end);
|
|
216
|
+
const stop = closeAt < 0 ? html.length : closeAt;
|
|
217
|
+
if (stop > end) tokens.push({ type: "text", start: end, end: stop });
|
|
218
|
+
i = stop;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return tokens;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Each element with its inner range: [{name, attrs, start, end, innerStart, innerEnd, depth}]. */
|
|
225
|
+
export function elements(html) {
|
|
226
|
+
const out = [];
|
|
227
|
+
const stack = [];
|
|
228
|
+
for (const token of tokenize(html)) {
|
|
229
|
+
if (token.type === "open") {
|
|
230
|
+
const element = {
|
|
231
|
+
name: token.name,
|
|
232
|
+
attrs: token.attrs,
|
|
233
|
+
start: token.start,
|
|
234
|
+
innerStart: token.end,
|
|
235
|
+
innerEnd: token.end,
|
|
236
|
+
end: token.end,
|
|
237
|
+
depth: stack.length,
|
|
238
|
+
text: "",
|
|
239
|
+
};
|
|
240
|
+
out.push(element);
|
|
241
|
+
if (!token.selfClosing) stack.push(element);
|
|
242
|
+
} else if (token.type === "close") {
|
|
243
|
+
const at = stack.map((e) => e.name).lastIndexOf(token.name);
|
|
244
|
+
if (at < 0) continue;
|
|
245
|
+
while (stack.length > at) {
|
|
246
|
+
const element = stack.pop();
|
|
247
|
+
element.innerEnd = token.start;
|
|
248
|
+
element.end = stack.length === at ? token.end : token.start;
|
|
249
|
+
}
|
|
250
|
+
} else if (token.type === "text" && stack.length) {
|
|
251
|
+
const top = stack[stack.length - 1];
|
|
252
|
+
if (top.text.length < 80) {
|
|
253
|
+
top.text += html.slice(token.start, token.end);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
for (const element of stack) {
|
|
258
|
+
element.innerEnd = html.length;
|
|
259
|
+
element.end = html.length;
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Storybook's story root: `#storybook-root` (7 and later) or `#root` (6). */
|
|
265
|
+
const STORY_ROOTS = new Set(["storybook-root", "root"]);
|
|
266
|
+
|
|
267
|
+
/** The element's children only, for a story snippet's root. */
|
|
268
|
+
function unwrapStoryRoot(html) {
|
|
269
|
+
const root = elements(html).find(
|
|
270
|
+
(e) => e.depth === 0 && STORY_ROOTS.has(e.attrs.id),
|
|
271
|
+
);
|
|
272
|
+
if (!root) return html;
|
|
273
|
+
return (
|
|
274
|
+
html.slice(0, root.start) +
|
|
275
|
+
html.slice(root.innerStart, root.innerEnd) +
|
|
276
|
+
html.slice(root.end)
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── sanitizing (self-contained, script-free output) ─────────────────────────
|
|
281
|
+
|
|
282
|
+
const URL_ATTRS = new Set([
|
|
283
|
+
"src",
|
|
284
|
+
"href",
|
|
285
|
+
"xlink:href",
|
|
286
|
+
"poster",
|
|
287
|
+
"action",
|
|
288
|
+
"formaction",
|
|
289
|
+
"data",
|
|
290
|
+
"background",
|
|
291
|
+
"ping",
|
|
292
|
+
"cite",
|
|
293
|
+
"longdesc",
|
|
294
|
+
"lowsrc",
|
|
295
|
+
"manifest",
|
|
296
|
+
"codebase",
|
|
297
|
+
]);
|
|
298
|
+
const SRCSET_ATTRS = new Set(["srcset", "imagesrcset"]);
|
|
299
|
+
const DROPPED = new Set(["script", "link", "base", "meta", "set", "animate"]);
|
|
300
|
+
/** Dropped with everything inside them: scripts, and SVG animation, which can
|
|
301
|
+
* set an href to a remote URL after load. */
|
|
302
|
+
const DROPPED_WITH_CONTENT = new Set(["script", "set", "animate"]);
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Named character references the decoder knows: every HTML name for an ASCII
|
|
306
|
+
* punctuation or whitespace character (the ones that can reshape a URL or a
|
|
307
|
+
* CSS function), plus common text entities. Lookup ignores case, which can
|
|
308
|
+
* only make the check see more than the browser does. An attribute holding
|
|
309
|
+
* any other `&name;` is dropped (see UNKNOWN_ENTITY).
|
|
310
|
+
*/
|
|
311
|
+
const NAMED = {
|
|
312
|
+
excl: "!",
|
|
313
|
+
quot: '"',
|
|
314
|
+
num: "#",
|
|
315
|
+
dollar: "$",
|
|
316
|
+
percnt: "%",
|
|
317
|
+
amp: "&",
|
|
318
|
+
apos: "'",
|
|
319
|
+
lpar: "(",
|
|
320
|
+
rpar: ")",
|
|
321
|
+
ast: "*",
|
|
322
|
+
midast: "*",
|
|
323
|
+
plus: "+",
|
|
324
|
+
comma: ",",
|
|
325
|
+
period: ".",
|
|
326
|
+
sol: "/",
|
|
327
|
+
colon: ":",
|
|
328
|
+
semi: ";",
|
|
329
|
+
lt: "<",
|
|
330
|
+
equals: "=",
|
|
331
|
+
gt: ">",
|
|
332
|
+
quest: "?",
|
|
333
|
+
commat: "@",
|
|
334
|
+
lsqb: "[",
|
|
335
|
+
lbrack: "[",
|
|
336
|
+
bsol: "\\",
|
|
337
|
+
rsqb: "]",
|
|
338
|
+
rbrack: "]",
|
|
339
|
+
hat: "^",
|
|
340
|
+
lowbar: "_",
|
|
341
|
+
underbar: "_",
|
|
342
|
+
grave: "`",
|
|
343
|
+
diacriticalgrave: "`",
|
|
344
|
+
lcub: "{",
|
|
345
|
+
lbrace: "{",
|
|
346
|
+
verbar: "|",
|
|
347
|
+
vert: "|",
|
|
348
|
+
verticalline: "|",
|
|
349
|
+
rcub: "}",
|
|
350
|
+
rbrace: "}",
|
|
351
|
+
tab: "\t",
|
|
352
|
+
newline: "\n",
|
|
353
|
+
nbsp: " ",
|
|
354
|
+
nonbreakingspace: " ",
|
|
355
|
+
shy: "",
|
|
356
|
+
ensp: " ",
|
|
357
|
+
emsp: " ",
|
|
358
|
+
thinsp: " ",
|
|
359
|
+
hairsp: " ",
|
|
360
|
+
zwnj: "",
|
|
361
|
+
zwj: "",
|
|
362
|
+
copy: "©",
|
|
363
|
+
reg: "®",
|
|
364
|
+
trade: "™",
|
|
365
|
+
hellip: "…",
|
|
366
|
+
mdash: "—",
|
|
367
|
+
ndash: "–",
|
|
368
|
+
lsquo: "‘",
|
|
369
|
+
rsquo: "’",
|
|
370
|
+
ldquo: "“",
|
|
371
|
+
rdquo: "”",
|
|
372
|
+
middot: "·",
|
|
373
|
+
bull: "•",
|
|
374
|
+
times: "×",
|
|
375
|
+
divide: "÷",
|
|
376
|
+
larr: "←",
|
|
377
|
+
rarr: "→",
|
|
378
|
+
uarr: "↑",
|
|
379
|
+
darr: "↓",
|
|
380
|
+
harr: "↔",
|
|
381
|
+
deg: "°",
|
|
382
|
+
plusmn: "±",
|
|
383
|
+
para: "¶",
|
|
384
|
+
sect: "§",
|
|
385
|
+
laquo: "«",
|
|
386
|
+
raquo: "»",
|
|
387
|
+
euro: "€",
|
|
388
|
+
pound: "£",
|
|
389
|
+
yen: "¥",
|
|
390
|
+
cent: "¢",
|
|
391
|
+
check: "✓",
|
|
392
|
+
hearts: "♥",
|
|
393
|
+
star: "☆",
|
|
394
|
+
starf: "★",
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
/** A terminated named reference the decoder does not know. */
|
|
398
|
+
export function hasUnknownEntity(value) {
|
|
399
|
+
for (const match of value.matchAll(/&([a-z][a-z0-9]*);/gi)) {
|
|
400
|
+
if (!(match[1].toLowerCase() in NAMED)) return true;
|
|
401
|
+
}
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** An attribute value as the browser reads it: character references decoded. */
|
|
406
|
+
export function decodeEntities(value) {
|
|
407
|
+
return value.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);?/gi, (whole, ref) => {
|
|
408
|
+
if (ref[0] === "#") {
|
|
409
|
+
const code =
|
|
410
|
+
ref[1] === "x" || ref[1] === "X"
|
|
411
|
+
? Number.parseInt(ref.slice(2), 16)
|
|
412
|
+
: Number.parseInt(ref.slice(1), 10);
|
|
413
|
+
return Number.isFinite(code) && code <= 0x10ffff
|
|
414
|
+
? String.fromCodePoint(code)
|
|
415
|
+
: whole;
|
|
416
|
+
}
|
|
417
|
+
return NAMED[ref.toLowerCase()] ?? whole;
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function encodeAttr(value) {
|
|
422
|
+
return value.replace(/&/g, "&").replace(/"/g, """);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The allowlist for a decoded URL: relative references, fragments and
|
|
427
|
+
* `data:`. Any other scheme (http, https, javascript, file, …) and
|
|
428
|
+
* protocol-relative `//host` URLs are refused. Browsers drop ASCII tabs and
|
|
429
|
+
* newlines inside URLs and ignore leading control characters and spaces, so
|
|
430
|
+
* those are removed before the scheme is read.
|
|
431
|
+
*/
|
|
432
|
+
export function isSafeUrl(decoded) {
|
|
433
|
+
const url = decoded.replace(/[\u0000-\u0020\u007f]+/g, "").toLowerCase();
|
|
434
|
+
if (url.startsWith("//") || url.startsWith("\\\\") || url.startsWith("/\\"))
|
|
435
|
+
return false;
|
|
436
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/.exec(url);
|
|
437
|
+
return !scheme || scheme[1] === "data";
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* The candidate URLs of a srcset, parsed as the HTML spec does: a URL runs
|
|
442
|
+
* to the next whitespace (trailing commas trimmed), and its descriptors run
|
|
443
|
+
* to the next comma outside parentheses. `a.png 1x,https://…/b.png 2x` is
|
|
444
|
+
* two candidates; a bare comma separates them as well as ", ".
|
|
445
|
+
*/
|
|
446
|
+
export function srcsetUrls(value) {
|
|
447
|
+
const urls = [];
|
|
448
|
+
let pos = 0;
|
|
449
|
+
while (pos < value.length) {
|
|
450
|
+
while (pos < value.length && /[\s,]/.test(value[pos])) pos += 1;
|
|
451
|
+
if (pos >= value.length) break;
|
|
452
|
+
const start = pos;
|
|
453
|
+
while (pos < value.length && !/\s/.test(value[pos])) pos += 1;
|
|
454
|
+
const url = value.slice(start, pos);
|
|
455
|
+
urls.push(url.replace(/,+$/, ""));
|
|
456
|
+
if (url.endsWith(",")) continue;
|
|
457
|
+
let depth = 0;
|
|
458
|
+
for (; pos < value.length; pos += 1) {
|
|
459
|
+
const ch = value[pos];
|
|
460
|
+
if (ch === "(") depth += 1;
|
|
461
|
+
else if (ch === ")") depth = Math.max(0, depth - 1);
|
|
462
|
+
else if (ch === "," && depth === 0) {
|
|
463
|
+
pos += 1;
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return urls;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Every candidate must be safe, and no token after whitespace or a comma may
|
|
473
|
+
* start a remote or script URL either: a second, cruder reading that can only
|
|
474
|
+
* refuse more (a `data:` URI holding ",https:" is dropped too).
|
|
475
|
+
*/
|
|
476
|
+
function isSafeSrcset(decoded) {
|
|
477
|
+
if (!srcsetUrls(decoded).every((url) => isSafeUrl(url))) return false;
|
|
478
|
+
return decoded
|
|
479
|
+
.split(/[\s,]+/)
|
|
480
|
+
.filter(Boolean)
|
|
481
|
+
.every((token) => isSafeUrl(token));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Drops scripts, links, handlers and every remote or javascript: URL. */
|
|
485
|
+
export function sanitizeMarkup(html) {
|
|
486
|
+
let out = "";
|
|
487
|
+
let i = 0;
|
|
488
|
+
const tokens = tokenize(html);
|
|
489
|
+
for (let t = 0; t < tokens.length; t += 1) {
|
|
490
|
+
const token = tokens[t];
|
|
491
|
+
if (token.type !== "open") continue;
|
|
492
|
+
if (DROPPED.has(token.name)) {
|
|
493
|
+
out += html.slice(i, token.start);
|
|
494
|
+
i = token.end;
|
|
495
|
+
if (DROPPED_WITH_CONTENT.has(token.name) && !token.selfClosing) {
|
|
496
|
+
const close = tokens.findIndex(
|
|
497
|
+
(n, k) => k > t && n.type === "close" && n.name === token.name,
|
|
498
|
+
);
|
|
499
|
+
if (close >= 0) {
|
|
500
|
+
i = tokens[close].end;
|
|
501
|
+
t = close;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
let changed = false;
|
|
507
|
+
const kept = [];
|
|
508
|
+
for (const [name, value] of Object.entries(token.attrs)) {
|
|
509
|
+
if (name.startsWith("on")) {
|
|
510
|
+
changed = true;
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if (name === "srcdoc") {
|
|
514
|
+
changed = true;
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
// Fail closed: a reference this decoder cannot read (say `(` or a
|
|
518
|
+
// rarer name) could hide a comma, a parenthesis or a scheme, so the
|
|
519
|
+
// attribute goes. Captured markup only ever carries & "
|
|
520
|
+
// < >.
|
|
521
|
+
if (hasUnknownEntity(value)) {
|
|
522
|
+
changed = true;
|
|
523
|
+
if (name === "href") kept.push(["href", "#"]);
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
const unsafe =
|
|
527
|
+
(URL_ATTRS.has(name) && !isSafeUrl(decodeEntities(value))) ||
|
|
528
|
+
(SRCSET_ATTRS.has(name) && !isSafeSrcset(decodeEntities(value)));
|
|
529
|
+
if (unsafe) {
|
|
530
|
+
changed = true;
|
|
531
|
+
if (name === "href") kept.push(["href", "#"]);
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
// `style`, and any other attribute whose value holds a url() (SVG's
|
|
535
|
+
// fill, stroke, mask, clip-path, filter, marker-*…), is CSS to the
|
|
536
|
+
// browser: read it as the browser will (entities decoded) and run it
|
|
537
|
+
// through the same allowlist.
|
|
538
|
+
const decoded = decodeEntities(value);
|
|
539
|
+
if (
|
|
540
|
+
name === "style" ||
|
|
541
|
+
/url\s*\(/i.test(unescapeFetchingNames(decoded))
|
|
542
|
+
) {
|
|
543
|
+
const cleaned = sanitizeCss(decoded);
|
|
544
|
+
if (cleaned !== decoded) {
|
|
545
|
+
changed = true;
|
|
546
|
+
kept.push([name, encodeAttr(cleaned)]);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
kept.push([name, value.replace(/"/g, """)]);
|
|
551
|
+
}
|
|
552
|
+
if (changed) {
|
|
553
|
+
// Values are already encoded for a double-quoted attribute.
|
|
554
|
+
const attrs = kept.map(([name, value]) => ` ${name}="${value}"`).join("");
|
|
555
|
+
out += `${html.slice(i, token.start)}<${token.name}${attrs}${token.selfClosing && !VOID.has(token.name) ? " /" : ""}>`;
|
|
556
|
+
i = token.end;
|
|
557
|
+
}
|
|
558
|
+
const text = tokens[t + 1];
|
|
559
|
+
if (token.name === "style" && text?.type === "text") {
|
|
560
|
+
out +=
|
|
561
|
+
html.slice(i, text.start) +
|
|
562
|
+
sanitizeCss(html.slice(text.start, text.end));
|
|
563
|
+
i = text.end;
|
|
564
|
+
t += 1;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return out + html.slice(i);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** A CSS string or url() body as the browser reads it: escapes resolved. */
|
|
571
|
+
function unescapeCss(value) {
|
|
572
|
+
return value
|
|
573
|
+
.replace(/\\([0-9a-f]{1,6})\s?/gi, (_, hex) => {
|
|
574
|
+
const code = Number.parseInt(hex, 16);
|
|
575
|
+
return code > 0 && code <= 0x10ffff
|
|
576
|
+
? String.fromCodePoint(code)
|
|
577
|
+
: "\ufffd";
|
|
578
|
+
})
|
|
579
|
+
.replace(/\\\n/g, "")
|
|
580
|
+
.replace(/\\(.)/g, "$1");
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const CSS_URL_OR_STRING =
|
|
584
|
+
/url\(\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|((?:\\.|[^)\\])*))\s*\)|"((?:\\[\s\S]|[^"\\])*)"|'((?:\\[\s\S]|[^'\\])*)'/gi;
|
|
585
|
+
|
|
586
|
+
/** An identifier with CSS escapes in it (`u\\72l`, `\\75rl`, `@\\69mport`). */
|
|
587
|
+
const ESCAPED_NAME =
|
|
588
|
+
/(@?)((?:[a-z0-9_-]|\\[0-9a-f]{1,6}\s?|\\[^\n\r\f0-9a-f])*\\(?:[0-9a-f]{1,6}\s?|[^\n\r\f0-9a-f])(?:[a-z0-9_-]|\\[0-9a-f]{1,6}\s?|\\[^\n\r\f0-9a-f])*)(?=(\()?)/gi;
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* A function name or at-keyword can be written with escapes and still work
|
|
592
|
+
* (`u\\72l(https://…)` is a url()), so an escaped `url`/`src` before `(`
|
|
593
|
+
* and an escaped `@import` are written plainly first, for the checks below
|
|
594
|
+
* to see. Other escaped identifiers are left alone.
|
|
595
|
+
*/
|
|
596
|
+
function unescapeFetchingNames(css) {
|
|
597
|
+
return css.replace(ESCAPED_NAME, (whole, at, name, paren) => {
|
|
598
|
+
const plain = unescapeCss(name).toLowerCase();
|
|
599
|
+
if (at && plain === "import") return "@import";
|
|
600
|
+
if (!at && paren && (plain === "url" || plain === "src")) return plain;
|
|
601
|
+
return whole;
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Remote references become inert: every `@import` is removed, a `url()`
|
|
607
|
+
* outside the allowlist (see isSafeUrl) becomes `none`, and so does a bare
|
|
608
|
+
* string that names one (`image-set("https://…")`), after CSS escapes are
|
|
609
|
+
* resolved.
|
|
610
|
+
*/
|
|
611
|
+
export function sanitizeCss(css) {
|
|
612
|
+
return unescapeFetchingNames(css)
|
|
613
|
+
.replace(/@import[^;]*;?/gi, "")
|
|
614
|
+
.replace(CSS_URL_OR_STRING, (whole, dq, sq, bare, str, sstr) => {
|
|
615
|
+
if (dq !== undefined || sq !== undefined || bare !== undefined) {
|
|
616
|
+
return isSafeUrl(unescapeCss((dq ?? sq ?? bare).trim()))
|
|
617
|
+
? whole
|
|
618
|
+
: "none";
|
|
619
|
+
}
|
|
620
|
+
const text = unescapeCss(str ?? sstr);
|
|
621
|
+
return /^\s*[a-z][a-z0-9+.-]*:|^\s*\/\//i.test(text) && !isSafeUrl(text)
|
|
622
|
+
? '""'
|
|
623
|
+
: whole;
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const ASSET_TYPES = {
|
|
628
|
+
".woff2": "font/woff2",
|
|
629
|
+
".woff": "font/woff",
|
|
630
|
+
".ttf": "font/ttf",
|
|
631
|
+
".otf": "font/otf",
|
|
632
|
+
".eot": "application/vnd.ms-fontobject",
|
|
633
|
+
".png": "image/png",
|
|
634
|
+
".jpg": "image/jpeg",
|
|
635
|
+
".jpeg": "image/jpeg",
|
|
636
|
+
".gif": "image/gif",
|
|
637
|
+
".webp": "image/webp",
|
|
638
|
+
".avif": "image/avif",
|
|
639
|
+
".svg": "image/svg+xml",
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Every relative `url()` in a kit stylesheet that names a file inside the kit
|
|
644
|
+
* (`../fonts/x.woff2`, `../assets/logo.svg`, …) becomes a data URI. One that
|
|
645
|
+
* names nothing in the kit becomes `none`, so the page never asks for it.
|
|
646
|
+
* `fonts/x` without `../` is read from the kit's fonts folder as well.
|
|
647
|
+
* Fragments, `data:` and absolute URLs are left to the sanitizer.
|
|
648
|
+
*/
|
|
649
|
+
export function inlineAssets(kit, css, cssDir = join(kit.dir, "css")) {
|
|
650
|
+
const root = resolve(kit.dir);
|
|
651
|
+
return css.replace(
|
|
652
|
+
/url\(\s*(["']?)([^"')]+)\1\s*\)/g,
|
|
653
|
+
(whole, _quote, raw) => {
|
|
654
|
+
const ref = raw.trim();
|
|
655
|
+
if (!ref || ref.startsWith("#") || /^[a-z][a-z0-9+.-]*:/i.test(ref))
|
|
656
|
+
return whole;
|
|
657
|
+
if (ref.startsWith("//") || ref.startsWith("/")) return whole;
|
|
658
|
+
const path = ref.replace(/[?#].*$/, "");
|
|
659
|
+
let file = resolve(cssDir, path);
|
|
660
|
+
const inside = (candidate) =>
|
|
661
|
+
candidate.startsWith(root + sep) && existsSync(candidate);
|
|
662
|
+
if (!inside(file) && /^(?:\.\.\/|\.\/)?fonts\//.test(path)) {
|
|
663
|
+
file = join(root, "fonts", basename(path));
|
|
664
|
+
}
|
|
665
|
+
if (!inside(file)) return "none";
|
|
666
|
+
const type = ASSET_TYPES[extname(file).toLowerCase()];
|
|
667
|
+
if (!type) return "none";
|
|
668
|
+
return `url("data:${type};base64,${readFileSync(file).toString("base64")}")`;
|
|
669
|
+
},
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ── compose ─────────────────────────────────────────────────────────────────
|
|
674
|
+
|
|
675
|
+
function expandSnippets(kit, html, used, depth = 0) {
|
|
676
|
+
const found = elements(html).filter((e) => e.name === "kit-snippet");
|
|
677
|
+
if (!found.length) return html;
|
|
678
|
+
if (depth >= SNIPPET_DEPTH) {
|
|
679
|
+
throw new KitError(
|
|
680
|
+
`<kit-snippet> nesting is deeper than ${SNIPPET_DEPTH}; is a snippet including itself?`,
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
let out = "";
|
|
684
|
+
let i = 0;
|
|
685
|
+
for (const element of found) {
|
|
686
|
+
if (element.start < i) continue; // inside an earlier placeholder
|
|
687
|
+
const id = element.attrs.id;
|
|
688
|
+
if (!id) throw new KitError("<kit-snippet> without an id attribute");
|
|
689
|
+
const row = requireRow(kit, id, "<kit-snippet id>");
|
|
690
|
+
used.add(rowSource(row));
|
|
691
|
+
let markup = readSnippet(kit, row);
|
|
692
|
+
if (rowSource(row) === "stories") markup = unwrapStoryRoot(markup);
|
|
693
|
+
out +=
|
|
694
|
+
html.slice(i, element.start) +
|
|
695
|
+
expandSnippets(kit, markup, used, depth + 1);
|
|
696
|
+
i = element.end;
|
|
697
|
+
}
|
|
698
|
+
return out + html.slice(i);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function fillSlots(kit, row, html, slots) {
|
|
702
|
+
if (!slots.length) return html;
|
|
703
|
+
const marked = elements(html).filter((e) => "data-kit-slot" in e.attrs);
|
|
704
|
+
const names = [
|
|
705
|
+
...new Set([
|
|
706
|
+
...(row.slots ?? []),
|
|
707
|
+
...marked.map((e) => e.attrs["data-kit-slot"]),
|
|
708
|
+
]),
|
|
709
|
+
];
|
|
710
|
+
const replacements = [];
|
|
711
|
+
for (const { name, content } of slots) {
|
|
712
|
+
const element = marked.find((e) => e.attrs["data-kit-slot"] === name);
|
|
713
|
+
if (!element) throw unknown(`slot of ${row.id}`, name, names);
|
|
714
|
+
replacements.push({ element, content });
|
|
715
|
+
}
|
|
716
|
+
replacements.sort((a, b) => b.element.innerStart - a.element.innerStart);
|
|
717
|
+
for (const { element, content } of replacements) {
|
|
718
|
+
html =
|
|
719
|
+
html.slice(0, element.innerStart) +
|
|
720
|
+
content +
|
|
721
|
+
html.slice(element.innerEnd);
|
|
722
|
+
}
|
|
723
|
+
return html;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function escapeAttr(value) {
|
|
727
|
+
return String(value).replace(/&/g, "&").replace(/"/g, """);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const ATTR_NAME = /^(?:class|style|lang|dir|data-[a-z0-9-]+)$/;
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* How the kit's app switches theme, as the capture recorded it in
|
|
734
|
+
* `manifest.theme`: a class (the default, `dark` on `<html>` and `<body>`)
|
|
735
|
+
* or an attribute such as `data-theme`, set on `targets`. A theme with no
|
|
736
|
+
* value (light, by default) means "the dark value removed".
|
|
737
|
+
*/
|
|
738
|
+
export const DEFAULT_THEME = Object.freeze({
|
|
739
|
+
attribute: "class",
|
|
740
|
+
dark: "dark",
|
|
741
|
+
targets: ["html", "body"],
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
export function kitTheme(manifest) {
|
|
745
|
+
const theme = manifest?.theme;
|
|
746
|
+
if (!theme || typeof theme !== "object") return DEFAULT_THEME;
|
|
747
|
+
const attribute =
|
|
748
|
+
typeof theme.attribute === "string" && theme.attribute
|
|
749
|
+
? theme.attribute
|
|
750
|
+
: "class";
|
|
751
|
+
return {
|
|
752
|
+
attribute,
|
|
753
|
+
dark: typeof theme.dark === "string" ? theme.dark : "dark",
|
|
754
|
+
...(typeof theme.light === "string" ? { light: theme.light } : {}),
|
|
755
|
+
targets:
|
|
756
|
+
Array.isArray(theme.targets) && theme.targets.length
|
|
757
|
+
? theme.targets.filter((t) => t === "html" || t === "body")
|
|
758
|
+
: attribute === "class"
|
|
759
|
+
? ["html", "body"]
|
|
760
|
+
: ["html"],
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* The captured <html>/<body> attributes (class, style, lang, dir, data-*),
|
|
766
|
+
* with the theme value replaced by the one asked for when `target` is one of
|
|
767
|
+
* the theme's targets.
|
|
768
|
+
*/
|
|
769
|
+
function attributes(captured, extraClass, theme, choice, target, fixed) {
|
|
770
|
+
const attrs = {};
|
|
771
|
+
for (const [name, value] of Object.entries(captured ?? {})) {
|
|
772
|
+
if (ATTR_NAME.test(name) && typeof value === "string") attrs[name] = value;
|
|
773
|
+
}
|
|
774
|
+
const applies = theme.targets.includes(target);
|
|
775
|
+
const value = choice === "dark" ? theme.dark : theme.light;
|
|
776
|
+
const themeValues = new Set(
|
|
777
|
+
[theme.dark, theme.light].filter((name) => typeof name === "string"),
|
|
778
|
+
);
|
|
779
|
+
// Class themes: the default class theme also drops a captured `light`.
|
|
780
|
+
if (theme.attribute === "class" && theme.light === undefined)
|
|
781
|
+
themeValues.add("light");
|
|
782
|
+
const classes = [
|
|
783
|
+
...new Set(
|
|
784
|
+
`${attrs.class ?? ""} ${extraClass ?? ""}`
|
|
785
|
+
.split(/\s+/)
|
|
786
|
+
.filter(
|
|
787
|
+
(name) =>
|
|
788
|
+
name && !(theme.attribute === "class" && themeValues.has(name)),
|
|
789
|
+
),
|
|
790
|
+
),
|
|
791
|
+
];
|
|
792
|
+
if (applies && theme.attribute === "class" && value) classes.push(value);
|
|
793
|
+
if (applies && theme.attribute !== "class") {
|
|
794
|
+
const name = theme.attribute.toLowerCase();
|
|
795
|
+
if (value) attrs[name] = value;
|
|
796
|
+
else if (attrs[name] === theme.dark) delete attrs[name];
|
|
797
|
+
}
|
|
798
|
+
delete attrs.class;
|
|
799
|
+
if (attrs.style !== undefined) attrs.style = sanitizeCss(attrs.style);
|
|
800
|
+
const all = {
|
|
801
|
+
...(classes.length ? { class: classes.join(" ") } : {}),
|
|
802
|
+
...attrs,
|
|
803
|
+
...fixed,
|
|
804
|
+
};
|
|
805
|
+
return Object.entries(all)
|
|
806
|
+
.filter(([, value]) => value !== "")
|
|
807
|
+
.map(([name, value]) => ` ${name}="${escapeAttr(value)}"`)
|
|
808
|
+
.join("");
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* One self-contained page: the frame's markup with slots filled and
|
|
813
|
+
* snippets expanded, its CSS (and that of any other source a snippet came
|
|
814
|
+
* from) inlined with fonts as data URIs, and no scripts or remote URLs.
|
|
815
|
+
*/
|
|
816
|
+
export function compose(
|
|
817
|
+
kit,
|
|
818
|
+
{ frame, slots = [], theme = "light", showNew = false },
|
|
819
|
+
) {
|
|
820
|
+
const row = requireRow(kit, frame, "frame");
|
|
821
|
+
if (theme !== "light" && theme !== "dark") {
|
|
822
|
+
throw new KitError(`unknown theme "${theme}". Use light or dark.`);
|
|
823
|
+
}
|
|
824
|
+
const used = new Set([rowSource(row)]);
|
|
825
|
+
const filled = fillSlots(kit, row, readSnippet(kit, row), slots);
|
|
826
|
+
const body = expandSnippets(kit, filled, used);
|
|
827
|
+
|
|
828
|
+
const css = ["stories", "routes"]
|
|
829
|
+
.filter((source) => used.has(source))
|
|
830
|
+
.map((source) => {
|
|
831
|
+
const name = cssFile(kit, source);
|
|
832
|
+
const file = join(kit.dir, name);
|
|
833
|
+
if (!existsSync(file)) {
|
|
834
|
+
throw new KitError(`the kit has no ${name} for ${frame}`);
|
|
835
|
+
}
|
|
836
|
+
return inlineAssets(kit, readFileSync(file, "utf8"), dirname(file));
|
|
837
|
+
})
|
|
838
|
+
.join("\n");
|
|
839
|
+
const showNewCss = showNew
|
|
840
|
+
? "\n[data-kit-new]{outline:2px dashed #e5484d !important;outline-offset:2px}"
|
|
841
|
+
: "";
|
|
842
|
+
|
|
843
|
+
const switcher = kitTheme(kit.manifest);
|
|
844
|
+
const sha = kit.manifest?.sha ?? "unknown";
|
|
845
|
+
const htmlAttrs = attributes(row.html_attrs, "", switcher, theme, "html", {
|
|
846
|
+
"data-kit-sha": sha,
|
|
847
|
+
"data-kit-id": frame,
|
|
848
|
+
lang: row.html_attrs?.lang ?? "en",
|
|
849
|
+
});
|
|
850
|
+
const bodyAttrs = attributes(
|
|
851
|
+
row.body_attrs,
|
|
852
|
+
row.body_class,
|
|
853
|
+
switcher,
|
|
854
|
+
theme,
|
|
855
|
+
"body",
|
|
856
|
+
{},
|
|
857
|
+
);
|
|
858
|
+
const page = `<!doctype html>
|
|
859
|
+
<html${htmlAttrs}>
|
|
860
|
+
<head>
|
|
861
|
+
<meta charset="utf-8">
|
|
862
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
863
|
+
<title>${escapeAttr(row.title ?? frame)}</title>
|
|
864
|
+
<style>
|
|
865
|
+
${sanitizeCss(css)}${showNewCss}
|
|
866
|
+
</style>
|
|
867
|
+
</head>
|
|
868
|
+
<body${bodyAttrs}>
|
|
869
|
+
${sanitizeMarkup(body)}
|
|
870
|
+
</body>
|
|
871
|
+
</html>
|
|
872
|
+
`;
|
|
873
|
+
return page;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// ── show / find / info ──────────────────────────────────────────────────────
|
|
877
|
+
|
|
878
|
+
export function outline(html, limit = SHOW_LIMIT) {
|
|
879
|
+
const lines = [];
|
|
880
|
+
let size = 0;
|
|
881
|
+
let truncated = false;
|
|
882
|
+
for (const element of elements(html)) {
|
|
883
|
+
if (element.depth > 4) continue;
|
|
884
|
+
const classes = (element.attrs.class ?? "")
|
|
885
|
+
.split(/\s+/)
|
|
886
|
+
.filter(Boolean)
|
|
887
|
+
.slice(0, 3)
|
|
888
|
+
.map((name) => `.${name}`)
|
|
889
|
+
.join("");
|
|
890
|
+
const marks = ["id", "role", "data-kit-slot", "aria-label", "data-testid"]
|
|
891
|
+
.filter((name) => element.attrs[name])
|
|
892
|
+
.map((name) => `[${name}=${element.attrs[name].slice(0, 40)}]`)
|
|
893
|
+
.join("");
|
|
894
|
+
const text = element.text.replace(/\s+/g, " ").trim().slice(0, 40);
|
|
895
|
+
const line = `${" ".repeat(element.depth)}<${element.name}${classes}${marks}>${text ? ` "${text}"` : ""}`;
|
|
896
|
+
// The limit is bytes: an outline line can carry non-ASCII text.
|
|
897
|
+
const bytes = Buffer.byteLength(line) + 1;
|
|
898
|
+
if (size + bytes > limit) {
|
|
899
|
+
truncated = true;
|
|
900
|
+
break;
|
|
901
|
+
}
|
|
902
|
+
lines.push(line);
|
|
903
|
+
size += bytes;
|
|
904
|
+
}
|
|
905
|
+
return { lines, truncated };
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function formatBytes(bytes) {
|
|
909
|
+
return bytes >= 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${bytes} B`;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
export function find(kit, query) {
|
|
913
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
914
|
+
const scored = [];
|
|
915
|
+
for (const row of kit.rows) {
|
|
916
|
+
const id = row.id.toLowerCase();
|
|
917
|
+
const hay = [row.id, row.title, row.component, row.source_file]
|
|
918
|
+
.filter(Boolean)
|
|
919
|
+
.join(" ")
|
|
920
|
+
.toLowerCase();
|
|
921
|
+
if (!terms.every((term) => hay.includes(term))) continue;
|
|
922
|
+
const score = terms.reduce(
|
|
923
|
+
(sum, term) => sum + (id.includes(term) ? 0 : 1),
|
|
924
|
+
0,
|
|
925
|
+
);
|
|
926
|
+
scored.push({ row, score });
|
|
927
|
+
}
|
|
928
|
+
scored.sort((a, b) => a.score - b.score || (a.row.id < b.row.id ? -1 : 1));
|
|
929
|
+
return scored.map(({ row }) => row);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function findLines(kit, query) {
|
|
933
|
+
const rows = find(kit, query);
|
|
934
|
+
if (!rows.length) {
|
|
935
|
+
const near = nearMatches(
|
|
936
|
+
query,
|
|
937
|
+
kit.rows.map((row) => row.id),
|
|
938
|
+
);
|
|
939
|
+
throw new KitError(
|
|
940
|
+
`nothing matches "${query}".${near.length ? ` Near: ${near.join(", ")}` : ""}`,
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
const shown = rows.length > FIND_LIMIT ? rows.slice(0, FIND_LIMIT - 1) : rows;
|
|
944
|
+
const lines = shown.map(
|
|
945
|
+
(row) =>
|
|
946
|
+
`${row.id} ${row.kind ?? rowSource(row)} ${row.width ?? "?"}w ${formatBytes(row.bytes ?? 0)} ${row.title ?? ""}`,
|
|
947
|
+
);
|
|
948
|
+
if (rows.length > shown.length) {
|
|
949
|
+
lines.push(`… ${rows.length - shown.length} more; add a word to narrow it`);
|
|
950
|
+
}
|
|
951
|
+
return lines;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function showText(kit, id, full) {
|
|
955
|
+
const row = requireRow(kit, id, "id");
|
|
956
|
+
const html = readSnippet(kit, row);
|
|
957
|
+
if (full) return html;
|
|
958
|
+
const head = [
|
|
959
|
+
`${row.id} (${row.kind ?? rowSource(row)}, ${row.width ?? "?"}x${row.height ?? "?"})`,
|
|
960
|
+
`title: ${row.title ?? ""}`,
|
|
961
|
+
`file: ${snippetPath(kit, row)} ${formatBytes(Buffer.byteLength(html))}`,
|
|
962
|
+
...(row.source_file ? [`source: ${row.source_file}`] : []),
|
|
963
|
+
...(row.slots?.length ? [`slots: ${row.slots.join(", ")}`] : []),
|
|
964
|
+
"outline:",
|
|
965
|
+
].join("\n");
|
|
966
|
+
const tail = "… (outline cut; --full prints the markup)";
|
|
967
|
+
const { lines, truncated } = outline(
|
|
968
|
+
html,
|
|
969
|
+
SHOW_LIMIT - Buffer.byteLength(head) - Buffer.byteLength(tail) - 2,
|
|
970
|
+
);
|
|
971
|
+
return [head, ...lines, ...(truncated ? [tail] : [])].join("\n");
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function infoText(kit) {
|
|
975
|
+
const routes = kit.rows.filter((row) => rowSource(row) === "routes").length;
|
|
976
|
+
return [
|
|
977
|
+
JSON.stringify(kit.manifest ?? { missing: "manifest.json" }, null, 2),
|
|
978
|
+
`rows: ${kit.rows.length} (stories ${kit.rows.length - routes}, routes ${routes})`,
|
|
979
|
+
].join("\n");
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// ── CLI ─────────────────────────────────────────────────────────────────────
|
|
983
|
+
|
|
984
|
+
const USAGE = `usage: kit.mjs [--kit <dir>] <command>
|
|
985
|
+
info print the manifest
|
|
986
|
+
find <query> list up to ${FIND_LIMIT} matching ids
|
|
987
|
+
show <id> [--full] path, size and an outline (--full: the markup)
|
|
988
|
+
compose --frame <id> [--slot <name>=<file>]… [--theme light|dark] [--show-new] --out <file>`;
|
|
989
|
+
|
|
990
|
+
function parseArgs(argv) {
|
|
991
|
+
const options = { slots: [], positional: [] };
|
|
992
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
993
|
+
const arg = argv[i];
|
|
994
|
+
const value = () => {
|
|
995
|
+
const next = argv[i + 1];
|
|
996
|
+
if (next === undefined) throw new KitError(`${arg} needs a value`);
|
|
997
|
+
i += 1;
|
|
998
|
+
return next;
|
|
999
|
+
};
|
|
1000
|
+
if (arg === "--kit") options.kit = value();
|
|
1001
|
+
else if (arg === "--frame") options.frame = value();
|
|
1002
|
+
else if (arg === "--slot") options.slots.push(value());
|
|
1003
|
+
else if (arg === "--theme") options.theme = value();
|
|
1004
|
+
else if (arg === "--out") options.out = value();
|
|
1005
|
+
else if (arg === "--full") options.full = true;
|
|
1006
|
+
else if (arg === "--show-new") options.showNew = true;
|
|
1007
|
+
else if (arg === "--help" || arg === "-h") options.help = true;
|
|
1008
|
+
else if (arg.startsWith("--")) throw new KitError(`unknown option ${arg}`);
|
|
1009
|
+
else options.positional.push(arg);
|
|
1010
|
+
}
|
|
1011
|
+
return options;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/** Runs one command; returns the exit code. */
|
|
1015
|
+
export function run(
|
|
1016
|
+
argv,
|
|
1017
|
+
io = { out: console.log, err: console.error },
|
|
1018
|
+
env = process.env,
|
|
1019
|
+
defaultDir = dirname(fileURLToPath(import.meta.url)),
|
|
1020
|
+
) {
|
|
1021
|
+
try {
|
|
1022
|
+
const options = parseArgs(argv);
|
|
1023
|
+
const [command, ...rest] = options.positional;
|
|
1024
|
+
if (options.help || !command) {
|
|
1025
|
+
io.out(USAGE);
|
|
1026
|
+
return command || options.help ? 0 : 2;
|
|
1027
|
+
}
|
|
1028
|
+
const dir = resolve(options.kit ?? env.DESIGN_KIT_DIR ?? defaultDir);
|
|
1029
|
+
const kit = loadKit(dir);
|
|
1030
|
+
if (command === "info") {
|
|
1031
|
+
io.out(infoText(kit));
|
|
1032
|
+
} else if (command === "find") {
|
|
1033
|
+
if (!rest.length) throw new KitError("find needs a query");
|
|
1034
|
+
io.out(findLines(kit, rest.join(" ")).join("\n"));
|
|
1035
|
+
} else if (command === "show") {
|
|
1036
|
+
if (rest.length !== 1) throw new KitError("show needs one id");
|
|
1037
|
+
io.out(showText(kit, rest[0], options.full));
|
|
1038
|
+
} else if (command === "compose") {
|
|
1039
|
+
if (!options.frame) throw new KitError("compose needs --frame <id>");
|
|
1040
|
+
if (!options.out) throw new KitError("compose needs --out <file>");
|
|
1041
|
+
const slots = options.slots.map((spec) => {
|
|
1042
|
+
const at = spec.indexOf("=");
|
|
1043
|
+
if (at <= 0) throw new KitError(`--slot ${spec}: use <name>=<file>`);
|
|
1044
|
+
const file = resolve(spec.slice(at + 1));
|
|
1045
|
+
if (!existsSync(file))
|
|
1046
|
+
throw new KitError(`--slot ${spec}: no such file`);
|
|
1047
|
+
return { name: spec.slice(0, at), content: readFileSync(file, "utf8") };
|
|
1048
|
+
});
|
|
1049
|
+
const page = compose(kit, {
|
|
1050
|
+
frame: options.frame,
|
|
1051
|
+
slots,
|
|
1052
|
+
theme: options.theme ?? "light",
|
|
1053
|
+
showNew: options.showNew,
|
|
1054
|
+
});
|
|
1055
|
+
writeFileSync(resolve(options.out), page);
|
|
1056
|
+
io.out(
|
|
1057
|
+
`${resolve(options.out)} ${formatBytes(Buffer.byteLength(page))}`,
|
|
1058
|
+
);
|
|
1059
|
+
} else {
|
|
1060
|
+
throw new KitError(`unknown command "${command}"\n${USAGE}`);
|
|
1061
|
+
}
|
|
1062
|
+
return 0;
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
if (error instanceof KitError) {
|
|
1065
|
+
io.err(`kit: ${error.message}`);
|
|
1066
|
+
return 2;
|
|
1067
|
+
}
|
|
1068
|
+
throw error;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/** Whether this file is the script node was started with (symlinks resolved). */
|
|
1073
|
+
function isMain() {
|
|
1074
|
+
if (!process.argv[1]) return false;
|
|
1075
|
+
try {
|
|
1076
|
+
return (
|
|
1077
|
+
realpathSync(fileURLToPath(import.meta.url)) ===
|
|
1078
|
+
realpathSync(resolve(process.argv[1]))
|
|
1079
|
+
);
|
|
1080
|
+
} catch {
|
|
1081
|
+
return import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
if (isMain()) {
|
|
1086
|
+
process.exitCode = run(process.argv.slice(2));
|
|
1087
|
+
}
|