@codepress/design-kit 0.10.0 → 0.12.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 +45 -6
- package/package.json +1 -1
- package/src/kit.d.ts +20 -0
- package/src/kit.mjs +591 -39
- package/src/templates/design-kit-skill.md +1 -1
package/README.md
CHANGED
|
@@ -521,23 +521,45 @@ keeps the kit's totals and adds `page_sources.<source>` with each one's own.
|
|
|
521
521
|
The tool needs only Node: no install and no network.
|
|
522
522
|
|
|
523
523
|
```bash
|
|
524
|
-
node kit.mjs info
|
|
525
|
-
node kit.mjs find button # ≤ 30 lines: id
|
|
524
|
+
node kit.mjs info [--json] # one line: sha, build time, counts (--json: the manifest)
|
|
525
|
+
node kit.mjs find button # ≤ 30 lines: id (widths grouped), size, title
|
|
526
526
|
node kit.mjs find "transfer app" # the words on screen work too
|
|
527
527
|
node kit.mjs show <id> [--full] # path, size and an outline (≤ 2 KB) or the markup
|
|
528
528
|
node kit.mjs compose --frame route:dashboard@1280 --slot main=new.html \
|
|
529
|
-
[--theme light|dark] [--show-new] --out page.html
|
|
529
|
+
[--theme light|dark] [--show-new] [--css pruned|full] --out page.html
|
|
530
530
|
```
|
|
531
531
|
|
|
532
|
-
`
|
|
532
|
+
`info` prints one line, such as
|
|
533
|
+
`480e11d built 2026-11-15T18:58:59.954Z · stories 12821 · routes 133 · failed 13`,
|
|
534
|
+
and names the failed ids from `gaps.json` when there are five or fewer;
|
|
535
|
+
`info --json` prints `manifest.json` as it is.
|
|
536
|
+
|
|
537
|
+
`find` prints one line per frame: rows that differ only by `@<width>` share
|
|
538
|
+
it, with the widths in braces and one size per width (or one size when they
|
|
539
|
+
print the same), for example
|
|
540
|
+
`route:orchestrator@{1280,390} 131/136 KB Orchestrator project`. A frame
|
|
541
|
+
captured at one width prints its plain id. The title is left out when it only
|
|
542
|
+
restates the id (`Chat/SubAgentBlock/Orchestrator Row` for
|
|
543
|
+
`story:chat-subagentblock--orchestrator-row`), and a route's trailing
|
|
544
|
+
` (1280)` is dropped; a frame whose widths have different titles prints each,
|
|
545
|
+
separated by `|`. `find` lists the rows holding every word of the query in their id, title,
|
|
533
546
|
component, source file or visible text. Rows with the words in the id come
|
|
534
547
|
first, then the title, component or source file, then the text only; among
|
|
535
548
|
rows that tie, one whose text holds the whole query comes first. A row found
|
|
536
549
|
only through its text ends with a quoted excerpt of about 60 characters
|
|
537
550
|
around the words, so you see why it matched. When more rows match than the
|
|
538
551
|
30 lines hold, rows found by name fill the list but its last 5 lines are kept
|
|
539
|
-
for rows found by text, and the closing line counts
|
|
540
|
-
(`+37 more by name, +3 more by on-screen text; narrow the query`). The
|
|
552
|
+
for rows found by text, and the closing line counts the rows each left out
|
|
553
|
+
(`+37 more by name, +3 more by on-screen text; narrow the query`). The
|
|
554
|
+
formatter is exported as `findMatchLines(matches)` from
|
|
555
|
+
`@codepress/design-kit/kit`, so the cloud Orchestrator's `design_kit_find`
|
|
556
|
+
prints the same lines.
|
|
557
|
+
|
|
558
|
+
`show` prints the row's header (id, kind and size, title, file, source,
|
|
559
|
+
slots) and an outline of at most 2 KB: one line per element down to depth 4,
|
|
560
|
+
a run of identical siblings (each with what is nested under it) printed once
|
|
561
|
+
with `×N`, and generated ids (`radix-…`, React's `_r…_` and the capture's
|
|
562
|
+
renumbered `_k…_`) left out. `show --full` prints the markup. The text
|
|
541
563
|
is `text.json`, built
|
|
542
564
|
whenever a capture writes the kit: each row's markup without its scripts,
|
|
543
565
|
styles, SVG, head and templates, tags and attribute values dropped, entities
|
|
@@ -568,6 +590,23 @@ fragments included. `--theme dark` applies the manifest's theme. Elements marked
|
|
|
568
590
|
or snippet id exits 2, naming near matches. `--kit <dir>` (or
|
|
569
591
|
`DESIGN_KIT_DIR`) points at a kit other than the tool's own folder.
|
|
570
592
|
|
|
593
|
+
`compose` inlines only the CSS the page can use. Once the body is final, it
|
|
594
|
+
collects every class and id on the page's elements (hidden ones and
|
|
595
|
+
`<template>` content included), every word in your inline scripts (so a class
|
|
596
|
+
a script adds keeps its rules) and the theme classes. A style rule is dropped
|
|
597
|
+
only when every selector in its list needs a class or id the page lacks;
|
|
598
|
+
pseudo-classes, pseudo-elements, attribute parts and the arguments of `:is`,
|
|
599
|
+
`:where`, `:has` and `:not` never count, and escapes (`hover\:bg-muted`,
|
|
600
|
+
`w-\[38px\]`) are read as the browser reads them. Rules inside `@media`,
|
|
601
|
+
`@supports`, `@layer`, `@container` and `@scope` are pruned the same way and
|
|
602
|
+
an emptied block goes (a named `@layer` stays, since it sets the layer
|
|
603
|
+
order). `@font-face`, `@keyframes`, `@property`, other at-rules and rules with
|
|
604
|
+
no class or id are kept as written. A rule repeated in the same at-rule
|
|
605
|
+
context is written once, its last copy. A composed screen drops from
|
|
606
|
+
megabytes to a few hundred KB with identical pixels. A class your script
|
|
607
|
+
builds from pieces (`"is-" + state`) is not seen; `--css full` (option
|
|
608
|
+
`css: "full"`) keeps every rule, as before 0.11.
|
|
609
|
+
|
|
571
610
|
### Clickable mockups
|
|
572
611
|
|
|
573
612
|
A composed page can switch tabs, open and close menus and swap between a
|
package/package.json
CHANGED
package/src/kit.d.ts
CHANGED
|
@@ -51,6 +51,14 @@ export function findMatches(kit: Kit, query: string): KitMatch[];
|
|
|
51
51
|
/** The rows of `findMatches`, in the same order. */
|
|
52
52
|
export function find(kit: Kit, query: string): KitRow[];
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* `kit find`'s lines for `findMatches` results, at most 30: rows that differ
|
|
56
|
+
* only by width share one line (`route:x@{1280,390} 131/136 KB Title`), a
|
|
57
|
+
* title that only restates the id is left out, and when the matches do not
|
|
58
|
+
* fit, a last line counts the rows not shown.
|
|
59
|
+
*/
|
|
60
|
+
export function findMatchLines(matches: KitMatch[]): string[];
|
|
61
|
+
|
|
54
62
|
/** Characters of visible text text.json keeps per row: 600. */
|
|
55
63
|
export const TEXT_LIMIT: number;
|
|
56
64
|
|
|
@@ -95,9 +103,21 @@ export function compose(
|
|
|
95
103
|
showNew?: boolean;
|
|
96
104
|
/** Bytes of markup the page may grow to through snippets. */
|
|
97
105
|
maxBytes?: number;
|
|
106
|
+
/**
|
|
107
|
+
* `pruned` (the default) keeps only the CSS rules the page's classes and
|
|
108
|
+
* ids can use, each repeated rule once; `full` keeps every rule.
|
|
109
|
+
*/
|
|
110
|
+
css?: "pruned" | "full";
|
|
98
111
|
},
|
|
99
112
|
): string;
|
|
100
113
|
|
|
114
|
+
/**
|
|
115
|
+
* The stylesheet without the style rules that need a class or id missing
|
|
116
|
+
* from `names`, and with each rule repeated in the same at-rule context
|
|
117
|
+
* written once (its last copy).
|
|
118
|
+
*/
|
|
119
|
+
export function pruneCss(css: string, names: Set<string>): string;
|
|
120
|
+
|
|
101
121
|
/** The default `maxBytes` of `compose`: 32 MB. */
|
|
102
122
|
export const COMPOSE_MAX_BYTES: number;
|
|
103
123
|
|
package/src/kit.mjs
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
* The design kit's own tool. It ships inside every kit, so tool and data
|
|
4
4
|
* always match. Pure Node: no dependencies, no network.
|
|
5
5
|
*
|
|
6
|
-
* node kit.mjs info
|
|
6
|
+
* node kit.mjs info [--json]
|
|
7
7
|
* node kit.mjs find <query> (ids, titles and the words on screen)
|
|
8
8
|
* node kit.mjs show <id> [--full]
|
|
9
9
|
* node kit.mjs compose --frame <id> [--slot <name>=<file>]… [--theme light|dark]
|
|
10
|
-
* [--show-new] --out <file>
|
|
10
|
+
* [--show-new] [--css pruned|full] --out <file>
|
|
11
11
|
*
|
|
12
12
|
* `--kit <dir>` (or DESIGN_KIT_DIR) points at a kit; the default is the
|
|
13
13
|
* folder this file sits in. Format: the @codepress/design-kit README.
|
|
@@ -993,6 +993,348 @@ function restoreAuthorScripts(html, key, held) {
|
|
|
993
993
|
);
|
|
994
994
|
}
|
|
995
995
|
|
|
996
|
+
// ── CSS pruning ─────────────────────────────────────────────────────────────
|
|
997
|
+
//
|
|
998
|
+
// A composed page carries the kit's whole stylesheets, megabytes of rules for
|
|
999
|
+
// classes the page never names. compose keeps only the rules a page can use:
|
|
1000
|
+
// a style rule goes only when every selector in its list needs a class or id
|
|
1001
|
+
// the page lacks, and of rules repeated in the same at-rule context only the
|
|
1002
|
+
// last stays (it is the one the cascade uses). Everything else (@font-face,
|
|
1003
|
+
// @keyframes, @property, rules with no class or id) stays as written.
|
|
1004
|
+
|
|
1005
|
+
/** At-rules whose blocks hold rules that are pruned like the top level. */
|
|
1006
|
+
const CSS_WRAPPERS = new Set([
|
|
1007
|
+
"media",
|
|
1008
|
+
"supports",
|
|
1009
|
+
"layer",
|
|
1010
|
+
"container",
|
|
1011
|
+
"scope",
|
|
1012
|
+
]);
|
|
1013
|
+
|
|
1014
|
+
const CSS_IDENT_CHAR = /[A-Za-z0-9_\-\u0080-]/;
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Where the CSS token at `i` ends when it is one whose characters never count
|
|
1018
|
+
* as structure (an escape, a comment, a string or an unquoted `url(…)`);
|
|
1019
|
+
* otherwise -1.
|
|
1020
|
+
*/
|
|
1021
|
+
function cssSkip(css, i) {
|
|
1022
|
+
const ch = css[i];
|
|
1023
|
+
if (ch === "\\") return Math.min(i + 2, css.length);
|
|
1024
|
+
if (ch === "/" && css[i + 1] === "*") {
|
|
1025
|
+
const end = css.indexOf("*/", i + 2);
|
|
1026
|
+
return end < 0 ? css.length : end + 2;
|
|
1027
|
+
}
|
|
1028
|
+
if (ch === '"' || ch === "'") {
|
|
1029
|
+
let j = i + 1;
|
|
1030
|
+
while (j < css.length) {
|
|
1031
|
+
const c = css[j];
|
|
1032
|
+
if (c === "\\") j += 2;
|
|
1033
|
+
else if (c === ch) return j + 1;
|
|
1034
|
+
// An unterminated string ends at the line's end, as the browser reads it.
|
|
1035
|
+
else if (c === "\n") return j;
|
|
1036
|
+
else j += 1;
|
|
1037
|
+
}
|
|
1038
|
+
return css.length;
|
|
1039
|
+
}
|
|
1040
|
+
if (
|
|
1041
|
+
(ch === "u" || ch === "U") &&
|
|
1042
|
+
(i === 0 || !CSS_IDENT_CHAR.test(css[i - 1])) &&
|
|
1043
|
+
/^url\(\s*[^\s"')]/i.test(css.slice(i, i + 64))
|
|
1044
|
+
) {
|
|
1045
|
+
let j = css.indexOf("(", i) + 1;
|
|
1046
|
+
while (j < css.length && css[j] !== ")") j += css[j] === "\\" ? 2 : 1;
|
|
1047
|
+
return Math.min(j + 1, css.length);
|
|
1048
|
+
}
|
|
1049
|
+
return -1;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** The first `{`, `;` or `}` outside brackets from `i`, or `end`. */
|
|
1053
|
+
function cssPreludeEnd(css, i, end) {
|
|
1054
|
+
let nested = 0;
|
|
1055
|
+
while (i < end) {
|
|
1056
|
+
const skip = cssSkip(css, i);
|
|
1057
|
+
if (skip >= 0) {
|
|
1058
|
+
i = skip;
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
const ch = css[i];
|
|
1062
|
+
if (ch === "(" || ch === "[") nested += 1;
|
|
1063
|
+
else if ((ch === ")" || ch === "]") && nested > 0) nested -= 1;
|
|
1064
|
+
else if (!nested && (ch === "{" || ch === ";" || ch === "}")) return i;
|
|
1065
|
+
i += 1;
|
|
1066
|
+
}
|
|
1067
|
+
return end;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/** Just past the `}` that closes the block opening at `open`, or -1. */
|
|
1071
|
+
function cssBlockEnd(css, open, end) {
|
|
1072
|
+
let depth = 0;
|
|
1073
|
+
let i = open;
|
|
1074
|
+
while (i < end) {
|
|
1075
|
+
const skip = cssSkip(css, i);
|
|
1076
|
+
if (skip >= 0) {
|
|
1077
|
+
i = skip;
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
if (css[i] === "{") depth += 1;
|
|
1081
|
+
else if (css[i] === "}" && --depth === 0) return i + 1;
|
|
1082
|
+
i += 1;
|
|
1083
|
+
}
|
|
1084
|
+
return -1;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* A list of rules as nodes: `rule` (a style rule, kept or dropped whole),
|
|
1089
|
+
* `wrapper` (an at-rule from CSS_WRAPPERS, pruned inside) and `keep`
|
|
1090
|
+
* (everything else, written back as it was). Comments between rules go.
|
|
1091
|
+
*/
|
|
1092
|
+
function parseCssList(css, start, end) {
|
|
1093
|
+
const nodes = [];
|
|
1094
|
+
let i = start;
|
|
1095
|
+
while (i < end) {
|
|
1096
|
+
const ch = css[i];
|
|
1097
|
+
if (/\s/.test(ch)) {
|
|
1098
|
+
i += 1;
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
if (ch === "/" && css[i + 1] === "*") {
|
|
1102
|
+
i = cssSkip(css, i);
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
const stop = cssPreludeEnd(css, i, end);
|
|
1106
|
+
if (stop >= end || css[stop] !== "{") {
|
|
1107
|
+
// A statement (`@layer a, b;`) or junk the browser skips: as written.
|
|
1108
|
+
const through = Math.min(stop + 1, end);
|
|
1109
|
+
nodes.push({ type: "keep", text: css.slice(i, through) });
|
|
1110
|
+
i = through;
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
const closedAt = cssBlockEnd(css, stop, end);
|
|
1114
|
+
const closed = closedAt >= 0;
|
|
1115
|
+
const blockEnd = closed ? closedAt : end;
|
|
1116
|
+
const prelude = css.slice(i, stop);
|
|
1117
|
+
const atRule = /^@([A-Za-z-]+)/.exec(prelude);
|
|
1118
|
+
const name = atRule?.[1].toLowerCase();
|
|
1119
|
+
if (name && CSS_WRAPPERS.has(name)) {
|
|
1120
|
+
const layerName = prelude.slice(atRule[0].length).trim();
|
|
1121
|
+
nodes.push({
|
|
1122
|
+
type: "wrapper",
|
|
1123
|
+
head: css.slice(i, stop + 1),
|
|
1124
|
+
// A named layer's first block sets the layer order, so even an
|
|
1125
|
+
// emptied one stays; each unnamed layer is a layer of its own.
|
|
1126
|
+
named: name === "layer" && layerName !== "",
|
|
1127
|
+
anonymous: name === "layer" && layerName === "",
|
|
1128
|
+
children: parseCssList(css, stop + 1, closed ? blockEnd - 1 : blockEnd),
|
|
1129
|
+
});
|
|
1130
|
+
} else if (name) {
|
|
1131
|
+
nodes.push({ type: "keep", text: css.slice(i, blockEnd) });
|
|
1132
|
+
} else {
|
|
1133
|
+
nodes.push({ type: "rule", prelude, text: css.slice(i, blockEnd) });
|
|
1134
|
+
}
|
|
1135
|
+
i = blockEnd;
|
|
1136
|
+
}
|
|
1137
|
+
return nodes;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* A rule's text with comments gone and whitespace outside strings collapsed,
|
|
1142
|
+
* so the same rule written by two sheets compares equal.
|
|
1143
|
+
*/
|
|
1144
|
+
function cssKey(text) {
|
|
1145
|
+
let out = "";
|
|
1146
|
+
// Whether `out` ends in an escape, string or url(): its last character is
|
|
1147
|
+
// then not the punctuation it looks like (`.\\{ .a` keeps its space).
|
|
1148
|
+
let quoted = false;
|
|
1149
|
+
let i = 0;
|
|
1150
|
+
while (i < text.length) {
|
|
1151
|
+
const skip = cssSkip(text, i);
|
|
1152
|
+
if (skip >= 0) {
|
|
1153
|
+
if (text[i] !== "/") {
|
|
1154
|
+
out += text.slice(i, skip);
|
|
1155
|
+
quoted = true;
|
|
1156
|
+
}
|
|
1157
|
+
i = skip;
|
|
1158
|
+
continue;
|
|
1159
|
+
}
|
|
1160
|
+
if (/\s/.test(text[i])) {
|
|
1161
|
+
while (i < text.length && /\s/.test(text[i])) i += 1;
|
|
1162
|
+
const afterPunctuation = !quoted && /[{};,]$/.test(out);
|
|
1163
|
+
if (!afterPunctuation && !/^[{};,]/.test(text[i] ?? "")) out += " ";
|
|
1164
|
+
continue;
|
|
1165
|
+
}
|
|
1166
|
+
out += text[i];
|
|
1167
|
+
quoted = false;
|
|
1168
|
+
i += 1;
|
|
1169
|
+
}
|
|
1170
|
+
return out.trim();
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/** The parts of a selector list, split on commas outside brackets. */
|
|
1174
|
+
function selectorParts(prelude) {
|
|
1175
|
+
const parts = [];
|
|
1176
|
+
let nested = 0;
|
|
1177
|
+
let from = 0;
|
|
1178
|
+
let i = 0;
|
|
1179
|
+
while (i < prelude.length) {
|
|
1180
|
+
const skip = cssSkip(prelude, i);
|
|
1181
|
+
if (skip >= 0) {
|
|
1182
|
+
i = skip;
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
const ch = prelude[i];
|
|
1186
|
+
if (ch === "(" || ch === "[") nested += 1;
|
|
1187
|
+
else if ((ch === ")" || ch === "]") && nested > 0) nested -= 1;
|
|
1188
|
+
else if (ch === "," && !nested) {
|
|
1189
|
+
parts.push(prelude.slice(from, i));
|
|
1190
|
+
from = i + 1;
|
|
1191
|
+
}
|
|
1192
|
+
i += 1;
|
|
1193
|
+
}
|
|
1194
|
+
parts.push(prelude.slice(from));
|
|
1195
|
+
return parts;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/** Just past the bracket closing the one at `i`. */
|
|
1199
|
+
function closeBracket(text, i) {
|
|
1200
|
+
let depth = 0;
|
|
1201
|
+
while (i < text.length) {
|
|
1202
|
+
const skip = cssSkip(text, i);
|
|
1203
|
+
if (skip >= 0) {
|
|
1204
|
+
i = skip;
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
const ch = text[i];
|
|
1208
|
+
if (ch === "(" || ch === "[") depth += 1;
|
|
1209
|
+
else if ((ch === ")" || ch === "]") && --depth === 0) return i + 1;
|
|
1210
|
+
i += 1;
|
|
1211
|
+
}
|
|
1212
|
+
return text.length;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/** The identifier at `i`, escapes resolved, and where it ends. */
|
|
1216
|
+
function readCssIdent(text, i) {
|
|
1217
|
+
let j = i;
|
|
1218
|
+
while (j < text.length) {
|
|
1219
|
+
if (text[j] === "\\" && j + 1 < text.length && text[j + 1] !== "\n") {
|
|
1220
|
+
const hex = /^[0-9a-fA-F]{1,6}\s?/.exec(text.slice(j + 1, j + 8));
|
|
1221
|
+
j += 1 + (hex ? hex[0].length : 1);
|
|
1222
|
+
} else if (CSS_IDENT_CHAR.test(text[j])) j += 1;
|
|
1223
|
+
else break;
|
|
1224
|
+
}
|
|
1225
|
+
return [unescapeCss(text.slice(i, j)), j];
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Whether one selector can match on the page: every class and id it needs is
|
|
1230
|
+
* there. Attribute parts and the arguments of functional pseudo-classes and
|
|
1231
|
+
* pseudo-elements (`:is`, `:where`, `:has`, `:not`, `::part`, …) never count.
|
|
1232
|
+
*/
|
|
1233
|
+
function selectorCanMatch(selector, names) {
|
|
1234
|
+
let i = 0;
|
|
1235
|
+
while (i < selector.length) {
|
|
1236
|
+
const ch = selector[i];
|
|
1237
|
+
if (ch === "[" || ch === "(") {
|
|
1238
|
+
i = closeBracket(selector, i);
|
|
1239
|
+
continue;
|
|
1240
|
+
}
|
|
1241
|
+
const skip = cssSkip(selector, i);
|
|
1242
|
+
if (skip >= 0) {
|
|
1243
|
+
i = skip;
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
if (ch === "." || ch === "#") {
|
|
1247
|
+
const [name, next] = readCssIdent(selector, i + 1);
|
|
1248
|
+
if (name && !names.has(name)) return false;
|
|
1249
|
+
i = Math.max(next, i + 1);
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1252
|
+
if (CSS_IDENT_CHAR.test(ch)) {
|
|
1253
|
+
// A tag name or pseudo name: read whole, so a `.` cannot start inside.
|
|
1254
|
+
i = readCssIdent(selector, i)[1];
|
|
1255
|
+
continue;
|
|
1256
|
+
}
|
|
1257
|
+
i += 1;
|
|
1258
|
+
}
|
|
1259
|
+
return true;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* Kept rules as text, last-first so the last copy of a repeated rule is the
|
|
1264
|
+
* one kept. `context` names the at-rules around them; `seen` is shared.
|
|
1265
|
+
*/
|
|
1266
|
+
function prunedCss(nodes, names, context, seen, counter) {
|
|
1267
|
+
const out = [];
|
|
1268
|
+
for (let n = nodes.length - 1; n >= 0; n -= 1) {
|
|
1269
|
+
const node = nodes[n];
|
|
1270
|
+
if (node.type === "rule") {
|
|
1271
|
+
if (
|
|
1272
|
+
!selectorParts(node.prelude).some((part) =>
|
|
1273
|
+
selectorCanMatch(part, names),
|
|
1274
|
+
)
|
|
1275
|
+
)
|
|
1276
|
+
continue;
|
|
1277
|
+
const key = `${context}\u0000${cssKey(node.text)}`;
|
|
1278
|
+
if (seen.has(key)) continue;
|
|
1279
|
+
seen.add(key);
|
|
1280
|
+
out.push(node.text);
|
|
1281
|
+
} else if (node.type === "wrapper") {
|
|
1282
|
+
const inner = prunedCss(
|
|
1283
|
+
node.children,
|
|
1284
|
+
names,
|
|
1285
|
+
`${context}\u0001${cssKey(node.head)}${node.anonymous ? `#${(counter.n += 1)}` : ""}`,
|
|
1286
|
+
seen,
|
|
1287
|
+
counter,
|
|
1288
|
+
);
|
|
1289
|
+
if (!inner.length && !node.named) continue;
|
|
1290
|
+
out.push(`${node.head}\n${inner.join("\n")}\n}`);
|
|
1291
|
+
} else {
|
|
1292
|
+
out.push(node.text);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
return out.reverse();
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* The stylesheet with every style rule no element can match removed and
|
|
1300
|
+
* repeated rules written once (see the section comment). `names` holds every
|
|
1301
|
+
* class and id the page can have.
|
|
1302
|
+
*/
|
|
1303
|
+
export function pruneCss(css, names) {
|
|
1304
|
+
return prunedCss(parseCssList(css, 0, css.length), names, "", new Set(), {
|
|
1305
|
+
n: 0,
|
|
1306
|
+
}).join("\n");
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* Every class and id a composed page can show: those on its elements (hidden
|
|
1311
|
+
* ones and <template> content included), every word in the author's own
|
|
1312
|
+
* scripts (one may add a class), and the theme classes.
|
|
1313
|
+
*/
|
|
1314
|
+
function pageNames(elementList, scripts, theme) {
|
|
1315
|
+
const names = new Set(["dark", "light"]);
|
|
1316
|
+
if (theme.attribute === "class") {
|
|
1317
|
+
for (const value of [theme.dark, theme.light]) if (value) names.add(value);
|
|
1318
|
+
}
|
|
1319
|
+
for (const element of elementList) {
|
|
1320
|
+
const { class: classes, id } = element.attrs;
|
|
1321
|
+
if (classes) {
|
|
1322
|
+
for (const name of decodeEntities(classes).split(/[\t\n\f\r ]+/)) {
|
|
1323
|
+
if (name) names.add(name);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
if (id) names.add(decodeEntities(id));
|
|
1327
|
+
}
|
|
1328
|
+
for (const script of scripts) {
|
|
1329
|
+
for (const word of script.split(/[\s"'`]+/)) {
|
|
1330
|
+
if (!word) continue;
|
|
1331
|
+
names.add(word);
|
|
1332
|
+
names.add(word.replace(/^[.#]+/, ""));
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
return names;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
996
1338
|
// ── compose ─────────────────────────────────────────────────────────────────
|
|
997
1339
|
|
|
998
1340
|
/**
|
|
@@ -1185,7 +1527,9 @@ function attributes(captured, extraClass, theme, choice, target, fixed) {
|
|
|
1185
1527
|
/**
|
|
1186
1528
|
* One self-contained page: the frame's markup with slots filled and
|
|
1187
1529
|
* snippets expanded, its CSS (and that of any other source a snippet came
|
|
1188
|
-
* from) inlined with fonts as data URIs, and no remote URLs.
|
|
1530
|
+
* from) inlined with fonts as data URIs, and no remote URLs. Only the rules
|
|
1531
|
+
* the page's classes and ids can use are kept, each once (see pruneCss);
|
|
1532
|
+
* `css: "full"` keeps every rule. The only
|
|
1189
1533
|
* scripts are the inline ones the slot fragments brought and, when the page
|
|
1190
1534
|
* uses a behaviour attribute, the behaviour layer; nothing executable from
|
|
1191
1535
|
* the kit's own markup survives.
|
|
@@ -1198,12 +1542,16 @@ export function compose(
|
|
|
1198
1542
|
theme = "light",
|
|
1199
1543
|
showNew = false,
|
|
1200
1544
|
maxBytes = COMPOSE_MAX_BYTES,
|
|
1545
|
+
css: cssMode = "pruned",
|
|
1201
1546
|
},
|
|
1202
1547
|
) {
|
|
1203
1548
|
const row = requireRow(kit, frame, "frame");
|
|
1204
1549
|
if (theme !== "light" && theme !== "dark") {
|
|
1205
1550
|
throw new KitError(`unknown theme "${theme}". Use light or dark.`);
|
|
1206
1551
|
}
|
|
1552
|
+
if (cssMode !== "pruned" && cssMode !== "full") {
|
|
1553
|
+
throw new KitError(`unknown css "${cssMode}". Use pruned or full.`);
|
|
1554
|
+
}
|
|
1207
1555
|
const used = new Set([rowCssFile(kit, row)]);
|
|
1208
1556
|
// The author's inline scripts sit out the sanitizer; everything else in
|
|
1209
1557
|
// the fragments (handlers, remote scripts, snippets) goes through it.
|
|
@@ -1272,6 +1620,23 @@ export function compose(
|
|
|
1272
1620
|
"body",
|
|
1273
1621
|
{},
|
|
1274
1622
|
);
|
|
1623
|
+
// The kit's CSS, cut to the rules this page's classes and ids can use
|
|
1624
|
+
// (`css: "full"` keeps every rule).
|
|
1625
|
+
const safeCss = sanitizeCss(css);
|
|
1626
|
+
const pageCss =
|
|
1627
|
+
cssMode === "full"
|
|
1628
|
+
? safeCss
|
|
1629
|
+
: pruneCss(
|
|
1630
|
+
safeCss,
|
|
1631
|
+
pageNames(
|
|
1632
|
+
[
|
|
1633
|
+
...bodyElements,
|
|
1634
|
+
...elements(`<html${htmlAttrs}><body${bodyAttrs}>`),
|
|
1635
|
+
],
|
|
1636
|
+
heldScripts,
|
|
1637
|
+
switcher,
|
|
1638
|
+
),
|
|
1639
|
+
);
|
|
1275
1640
|
// What this page used, for whoever wants to count it later. Recorded in
|
|
1276
1641
|
// the page only; kit.mjs never sends it anywhere.
|
|
1277
1642
|
const usage = {
|
|
@@ -1288,7 +1653,7 @@ export function compose(
|
|
|
1288
1653
|
<meta name="design-kit-usage" content="${escapeAttr(JSON.stringify(usage))}">
|
|
1289
1654
|
<title>${escapeAttr(row.title ?? frame)}</title>
|
|
1290
1655
|
<style>
|
|
1291
|
-
${
|
|
1656
|
+
${pageCss}${showNewCss}
|
|
1292
1657
|
</style>
|
|
1293
1658
|
</head>
|
|
1294
1659
|
<body${bodyAttrs}>
|
|
@@ -1469,10 +1834,16 @@ function excerpt(words, at, length) {
|
|
|
1469
1834
|
|
|
1470
1835
|
// ── show / find / info ──────────────────────────────────────────────────────
|
|
1471
1836
|
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1837
|
+
/**
|
|
1838
|
+
* An id attribute a library or React generated (`radix-…`, React's `useId`
|
|
1839
|
+
* values as the capture renumbers them): it changes between builds and says
|
|
1840
|
+
* nothing about the element, so the outline leaves it out.
|
|
1841
|
+
*/
|
|
1842
|
+
const GENERATED_ID = /^radix-|_r_?[0-9a-z]+_|_k\d+_|:r[0-9a-z]+:/;
|
|
1843
|
+
|
|
1844
|
+
/** One outline line per element down to depth 4, with its depth. */
|
|
1845
|
+
function outlineEntries(html) {
|
|
1846
|
+
const entries = [];
|
|
1476
1847
|
for (const element of elements(html)) {
|
|
1477
1848
|
if (element.depth > 4) continue;
|
|
1478
1849
|
const classes = (element.attrs.class ?? "")
|
|
@@ -1483,10 +1854,71 @@ export function outline(html, limit = SHOW_LIMIT) {
|
|
|
1483
1854
|
.join("");
|
|
1484
1855
|
const marks = ["id", "role", "data-kit-slot", "aria-label", "data-testid"]
|
|
1485
1856
|
.filter((name) => element.attrs[name])
|
|
1857
|
+
.filter((name) => name !== "id" || !GENERATED_ID.test(element.attrs.id))
|
|
1486
1858
|
.map((name) => `[${name}=${element.attrs[name].slice(0, 40)}]`)
|
|
1487
1859
|
.join("");
|
|
1488
1860
|
const text = element.text.replace(/\s+/g, " ").trim().slice(0, 40);
|
|
1489
|
-
|
|
1861
|
+
entries.push({
|
|
1862
|
+
depth: element.depth,
|
|
1863
|
+
line: `${" ".repeat(element.depth)}<${element.name}${classes}${marks}>${text ? ` "${text}"` : ""}`,
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
return entries;
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
/**
|
|
1870
|
+
* Runs of identical siblings, each with the lines nested under it, printed
|
|
1871
|
+
* once with `×N` after the first line.
|
|
1872
|
+
*/
|
|
1873
|
+
function collapseRuns(entries) {
|
|
1874
|
+
// ends[i]: the index after the last line nested under line i.
|
|
1875
|
+
const ends = new Array(entries.length).fill(entries.length);
|
|
1876
|
+
const open = [];
|
|
1877
|
+
entries.forEach((entry, i) => {
|
|
1878
|
+
while (open.length && entries[open.at(-1)].depth >= entry.depth)
|
|
1879
|
+
ends[open.pop()] = i;
|
|
1880
|
+
open.push(i);
|
|
1881
|
+
});
|
|
1882
|
+
const same = (a, b, length) => {
|
|
1883
|
+
for (let k = 0; k < length; k += 1)
|
|
1884
|
+
if (entries[a + k].line !== entries[b + k].line) return false;
|
|
1885
|
+
return true;
|
|
1886
|
+
};
|
|
1887
|
+
const lines = [];
|
|
1888
|
+
const walk = (from, to) => {
|
|
1889
|
+
let i = from;
|
|
1890
|
+
while (i < to) {
|
|
1891
|
+
const length = ends[i] - i;
|
|
1892
|
+
let next = ends[i];
|
|
1893
|
+
let count = 1;
|
|
1894
|
+
while (
|
|
1895
|
+
next < to &&
|
|
1896
|
+
entries[next].depth === entries[i].depth &&
|
|
1897
|
+
ends[next] - next === length &&
|
|
1898
|
+
same(i, next, length)
|
|
1899
|
+
) {
|
|
1900
|
+
count += 1;
|
|
1901
|
+
next = ends[next];
|
|
1902
|
+
}
|
|
1903
|
+
lines.push(`${entries[i].line}${count > 1 ? ` ×${count}` : ""}`);
|
|
1904
|
+
walk(i + 1, ends[i]);
|
|
1905
|
+
i = next;
|
|
1906
|
+
}
|
|
1907
|
+
};
|
|
1908
|
+
walk(0, entries.length);
|
|
1909
|
+
return lines;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
/**
|
|
1913
|
+
* A markup outline of at most `limit` bytes: one line per element down to
|
|
1914
|
+
* depth 4, identical sibling runs collapsed to one line with `×N`, and
|
|
1915
|
+
* generated ids left out.
|
|
1916
|
+
*/
|
|
1917
|
+
export function outline(html, limit = SHOW_LIMIT) {
|
|
1918
|
+
const lines = [];
|
|
1919
|
+
let size = 0;
|
|
1920
|
+
let truncated = false;
|
|
1921
|
+
for (const line of collapseRuns(outlineEntries(html))) {
|
|
1490
1922
|
// The limit is bytes: an outline line can carry non-ASCII text.
|
|
1491
1923
|
const bytes = Buffer.byteLength(line) + 1;
|
|
1492
1924
|
if (size + bytes > limit) {
|
|
@@ -1571,32 +2003,103 @@ export function find(kit, query) {
|
|
|
1571
2003
|
return findMatches(kit, query).map(({ row }) => row);
|
|
1572
2004
|
}
|
|
1573
2005
|
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
2006
|
+
/** Letters and digits only, lowercased: how a title can restate an id. */
|
|
2007
|
+
function flat(text) {
|
|
2008
|
+
return text.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
/** `story:x@1280` → `story:x`: the id without its width. */
|
|
2012
|
+
function baseId(id) {
|
|
2013
|
+
return id.replace(/@\d+$/, "");
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
/** The width an id ends in, as written. */
|
|
2017
|
+
function idWidth(id) {
|
|
2018
|
+
return /@(\d+)$/.exec(id)?.[1] ?? null;
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
/** Sizes of a width group: `131/136 KB`, `443 B/1.2 KB`, or `4.9 KB` when they print the same. */
|
|
2022
|
+
function groupBytes(rows) {
|
|
2023
|
+
const sizes = rows.map((row) => row.bytes ?? 0);
|
|
2024
|
+
const kb = (bytes) =>
|
|
2025
|
+
bytes >= 10 * 1024
|
|
2026
|
+
? `${Math.round(bytes / 1024)}`
|
|
2027
|
+
: `${(bytes / 1024).toFixed(1)}`;
|
|
2028
|
+
// Widths that print the same size print it once.
|
|
2029
|
+
const once = (values) => [...new Set(values)].join("/");
|
|
2030
|
+
if (sizes.every((bytes) => bytes >= 1024)) return `${once(sizes.map(kb))} KB`;
|
|
2031
|
+
return once(
|
|
2032
|
+
sizes.map((bytes) => (bytes >= 1024 ? `${kb(bytes)} KB` : `${bytes} B`)),
|
|
2033
|
+
);
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
/**
|
|
2037
|
+
* Matches gathered into lines: rows that differ only by `@<width>` and found
|
|
2038
|
+
* the same way (the same excerpt, or none) share one line, at the place of
|
|
2039
|
+
* the first of them.
|
|
2040
|
+
*/
|
|
2041
|
+
function widthGroups(matches) {
|
|
2042
|
+
const groups = new Map();
|
|
2043
|
+
for (const match of matches) {
|
|
2044
|
+
// An id without a width never shares a line.
|
|
2045
|
+
const id = match.row.id;
|
|
2046
|
+
const key = `${idWidth(id) ? baseId(id) : `${id}\0`}\0${match.excerpt ?? ""}`;
|
|
2047
|
+
const group = groups.get(key);
|
|
2048
|
+
if (group) group.rows.push(match.row);
|
|
2049
|
+
else groups.set(key, { rows: [match.row], excerpt: match.excerpt });
|
|
1584
2050
|
}
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
2051
|
+
return [...groups.values()];
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
/** One find line: id (with its widths), sizes, title unless it restates the id, excerpt. */
|
|
2055
|
+
function groupLine({ rows, excerpt: cut }) {
|
|
2056
|
+
const base = baseId(rows[0].id);
|
|
2057
|
+
const widths = rows.map((row) => idWidth(row.id));
|
|
2058
|
+
const id = rows.length === 1 ? rows[0].id : `${base}@{${widths.join(",")}}`;
|
|
2059
|
+
const name = flat(base.replace(/^[a-z]+:/, ""));
|
|
2060
|
+
const titles = [];
|
|
2061
|
+
for (const row of rows) {
|
|
2062
|
+
const title = (row.title ?? "").replace(/\s*\(\d+\)$/, "").trim();
|
|
2063
|
+
if (title && flat(title) !== name && !titles.includes(title))
|
|
2064
|
+
titles.push(title);
|
|
2065
|
+
}
|
|
2066
|
+
return [
|
|
2067
|
+
id,
|
|
2068
|
+
groupBytes(rows),
|
|
2069
|
+
...(titles.length ? [titles.join(" | ")] : []),
|
|
2070
|
+
...(cut === undefined ? [] : [`"${cut}"`]),
|
|
2071
|
+
].join(" ");
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
/**
|
|
2075
|
+
* `kit find`'s lines for `findMatches` results, at most FIND_LIMIT: rows that
|
|
2076
|
+
* differ only by width share a line (`route:x@{1280,390}`), a title that only
|
|
2077
|
+
* restates the id is left out, and a last line counts the rows not shown.
|
|
2078
|
+
* Rows found by name come first, but a few lines are kept for rows found only
|
|
2079
|
+
* by their on-screen text.
|
|
2080
|
+
*/
|
|
2081
|
+
export function findMatchLines(matches) {
|
|
2082
|
+
const count = (groups) =>
|
|
2083
|
+
groups.reduce((sum, group) => sum + group.rows.length, 0);
|
|
1589
2084
|
// A row with an excerpt was found by on-screen text; any other, by name.
|
|
1590
|
-
const
|
|
2085
|
+
const byName = widthGroups(
|
|
2086
|
+
matches.filter((match) => match.excerpt === undefined),
|
|
2087
|
+
);
|
|
2088
|
+
const byText = widthGroups(
|
|
2089
|
+
matches.filter((match) => match.excerpt !== undefined),
|
|
2090
|
+
);
|
|
2091
|
+
// Keep the order findMatches ranked them in when everything fits.
|
|
2092
|
+
if (byName.length + byText.length <= FIND_LIMIT)
|
|
2093
|
+
return widthGroups(matches).map(groupLine);
|
|
2094
|
+
const room = FIND_LIMIT - 1;
|
|
1591
2095
|
if (!byText.length) {
|
|
1592
2096
|
return [
|
|
1593
|
-
...
|
|
1594
|
-
`… ${
|
|
2097
|
+
...byName.slice(0, room).map(groupLine),
|
|
2098
|
+
`… ${count(byName.slice(room))} more; add a word to narrow it`,
|
|
1595
2099
|
];
|
|
1596
2100
|
}
|
|
1597
2101
|
// Names fill the list first, but the last lines are kept for text hits,
|
|
1598
2102
|
// so an agent sees them even when names alone would fill it.
|
|
1599
|
-
const byName = matches.filter((match) => match.excerpt === undefined);
|
|
1600
2103
|
const textRoom = Math.min(
|
|
1601
2104
|
byText.length,
|
|
1602
2105
|
Math.max(FIND_TEXT_LINES, room - byName.length),
|
|
@@ -1605,19 +2108,33 @@ function findLines(kit, query) {
|
|
|
1605
2108
|
const texts = byText.slice(0, room - names.length);
|
|
1606
2109
|
const left = [
|
|
1607
2110
|
...(byName.length > names.length
|
|
1608
|
-
? [`+${byName.
|
|
2111
|
+
? [`+${count(byName.slice(names.length))} more by name`]
|
|
1609
2112
|
: []),
|
|
1610
2113
|
...(byText.length > texts.length
|
|
1611
|
-
? [`+${byText.
|
|
2114
|
+
? [`+${count(byText.slice(texts.length))} more by on-screen text`]
|
|
1612
2115
|
: []),
|
|
1613
2116
|
];
|
|
1614
2117
|
return [
|
|
1615
|
-
...names.map(
|
|
1616
|
-
...texts.map(
|
|
2118
|
+
...names.map(groupLine),
|
|
2119
|
+
...texts.map(groupLine),
|
|
1617
2120
|
`${left.join(", ")}; narrow the query`,
|
|
1618
2121
|
];
|
|
1619
2122
|
}
|
|
1620
2123
|
|
|
2124
|
+
function findLines(kit, query) {
|
|
2125
|
+
const matches = findMatches(kit, query);
|
|
2126
|
+
if (!matches.length) {
|
|
2127
|
+
const near = nearMatches(
|
|
2128
|
+
query,
|
|
2129
|
+
kit.rows.map((row) => row.id),
|
|
2130
|
+
);
|
|
2131
|
+
throw new KitError(
|
|
2132
|
+
`nothing matches "${query}".${near.length ? ` Near: ${near.join(", ")}` : ""}`,
|
|
2133
|
+
);
|
|
2134
|
+
}
|
|
2135
|
+
return findMatchLines(matches);
|
|
2136
|
+
}
|
|
2137
|
+
|
|
1621
2138
|
function showText(kit, id, full) {
|
|
1622
2139
|
const row = requireRow(kit, id, "id");
|
|
1623
2140
|
const html = readSnippet(kit, row);
|
|
@@ -1638,21 +2155,53 @@ function showText(kit, id, full) {
|
|
|
1638
2155
|
return [head, ...lines, ...(truncated ? [tail] : [])].join("\n");
|
|
1639
2156
|
}
|
|
1640
2157
|
|
|
1641
|
-
|
|
2158
|
+
/** gaps.json's ids: the frames a capture failed on. */
|
|
2159
|
+
function failedIds(dir) {
|
|
2160
|
+
try {
|
|
2161
|
+
const gaps = JSON.parse(readFileSync(join(dir, "gaps.json"), "utf8"));
|
|
2162
|
+
if (!Array.isArray(gaps)) return [];
|
|
2163
|
+
return gaps.map((gap) => gap?.id).filter((id) => typeof id === "string");
|
|
2164
|
+
} catch {
|
|
2165
|
+
// Optional: info prints the count without the ids.
|
|
2166
|
+
return [];
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
/**
|
|
2171
|
+
* One line: `sha7 built <iso> · stories N · routes N · failed N`, with the
|
|
2172
|
+
* failed ids when there are five or fewer. `json` prints manifest.json.
|
|
2173
|
+
*/
|
|
2174
|
+
function infoText(kit, json) {
|
|
2175
|
+
const manifest = kit.manifest ?? { missing: "manifest.json" };
|
|
2176
|
+
if (json) return JSON.stringify(manifest, null, 2);
|
|
1642
2177
|
const routes = kit.rows.filter((row) => rowSource(row) === "routes").length;
|
|
2178
|
+
const sha = typeof manifest.sha === "string" ? manifest.sha.slice(0, 7) : "";
|
|
2179
|
+
const built =
|
|
2180
|
+
typeof manifest.built_at === "string" ? `built ${manifest.built_at}` : "";
|
|
2181
|
+
const ids = failedIds(kit.dir);
|
|
2182
|
+
const counted = manifest.counts?.failed;
|
|
2183
|
+
const failed = typeof counted === "number" ? counted : ids.length;
|
|
2184
|
+
const head = kit.manifest
|
|
2185
|
+
? [sha, built].filter(Boolean).join(" ") || "manifest.json"
|
|
2186
|
+
: "no manifest.json";
|
|
1643
2187
|
return [
|
|
1644
|
-
|
|
1645
|
-
`
|
|
1646
|
-
|
|
2188
|
+
head,
|
|
2189
|
+
`stories ${kit.rows.length - routes}`,
|
|
2190
|
+
`routes ${routes}`,
|
|
2191
|
+
`failed ${failed}${failed > 0 && failed <= 5 && ids.length ? ` (${ids.join(", ")})` : ""}`,
|
|
2192
|
+
].join(" · ");
|
|
1647
2193
|
}
|
|
1648
2194
|
|
|
1649
2195
|
// ── CLI ─────────────────────────────────────────────────────────────────────
|
|
1650
2196
|
|
|
1651
2197
|
const USAGE = `usage: kit.mjs [--kit <dir>] <command>
|
|
1652
|
-
info
|
|
1653
|
-
find <query>
|
|
2198
|
+
info [--json] sha, build time and counts (--json: the manifest)
|
|
2199
|
+
find <query> up to ${FIND_LIMIT} lines of rows whose id, title or on-screen text holds
|
|
2200
|
+
every word; widths of one frame share a line: id@{1280,390}
|
|
1654
2201
|
show <id> [--full] path, size and an outline (--full: the markup)
|
|
1655
|
-
compose --frame <id> [--slot <name>=<file>]… [--theme light|dark] [--show-new] --out <file
|
|
2202
|
+
compose --frame <id> [--slot <name>=<file>]… [--theme light|dark] [--show-new] [--css pruned|full] --out <file>
|
|
2203
|
+
--css full keeps every rule of the kit's CSS; the default
|
|
2204
|
+
keeps only rules the page's classes and ids can use`;
|
|
1656
2205
|
|
|
1657
2206
|
function parseArgs(argv) {
|
|
1658
2207
|
const options = { slots: [], positional: [] };
|
|
@@ -1669,7 +2218,9 @@ function parseArgs(argv) {
|
|
|
1669
2218
|
else if (arg === "--slot") options.slots.push(value());
|
|
1670
2219
|
else if (arg === "--theme") options.theme = value();
|
|
1671
2220
|
else if (arg === "--out") options.out = value();
|
|
2221
|
+
else if (arg === "--css") options.css = value();
|
|
1672
2222
|
else if (arg === "--full") options.full = true;
|
|
2223
|
+
else if (arg === "--json") options.json = true;
|
|
1673
2224
|
else if (arg === "--show-new") options.showNew = true;
|
|
1674
2225
|
else if (arg === "--help" || arg === "-h") options.help = true;
|
|
1675
2226
|
else if (arg.startsWith("--")) throw new KitError(`unknown option ${arg}`);
|
|
@@ -1695,7 +2246,7 @@ export function run(
|
|
|
1695
2246
|
const dir = resolve(options.kit ?? env.DESIGN_KIT_DIR ?? defaultDir);
|
|
1696
2247
|
const kit = loadKit(dir);
|
|
1697
2248
|
if (command === "info") {
|
|
1698
|
-
io.out(infoText(kit));
|
|
2249
|
+
io.out(infoText(kit, options.json));
|
|
1699
2250
|
} else if (command === "find") {
|
|
1700
2251
|
if (!rest.length) throw new KitError("find needs a query");
|
|
1701
2252
|
io.out(findLines(kit, rest.join(" ")).join("\n"));
|
|
@@ -1718,6 +2269,7 @@ export function run(
|
|
|
1718
2269
|
slots,
|
|
1719
2270
|
theme: options.theme ?? "light",
|
|
1720
2271
|
showNew: options.showNew,
|
|
2272
|
+
css: options.css ?? "pruned",
|
|
1721
2273
|
});
|
|
1722
2274
|
writeFileSync(resolve(options.out), page);
|
|
1723
2275
|
io.out(
|
|
@@ -71,7 +71,7 @@ is missing.
|
|
|
71
71
|
## 3. Find what is real
|
|
72
72
|
|
|
73
73
|
```bash
|
|
74
|
-
node "$KIT/kit.mjs" find button # ≤ 30 lines: id
|
|
74
|
+
node "$KIT/kit.mjs" find button # ≤ 30 lines: id (widths grouped), size, title
|
|
75
75
|
node "$KIT/kit.mjs" show route:home@1280 # path, size and an outline with slots
|
|
76
76
|
```
|
|
77
77
|
|