@codepress/design-kit 0.10.0 → 0.11.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 CHANGED
@@ -526,7 +526,7 @@ node kit.mjs find button # ≤ 30 lines: id, kind, width, bytes, tit
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
  `find` lists the rows holding every word of the query in their id, title,
@@ -568,6 +568,23 @@ fragments included. `--theme dark` applies the manifest's theme. Elements marked
568
568
  or snippet id exits 2, naming near matches. `--kit <dir>` (or
569
569
  `DESIGN_KIT_DIR`) points at a kit other than the tool's own folder.
570
570
 
571
+ `compose` inlines only the CSS the page can use. Once the body is final, it
572
+ collects every class and id on the page's elements (hidden ones and
573
+ `<template>` content included), every word in your inline scripts (so a class
574
+ a script adds keeps its rules) and the theme classes. A style rule is dropped
575
+ only when every selector in its list needs a class or id the page lacks;
576
+ pseudo-classes, pseudo-elements, attribute parts and the arguments of `:is`,
577
+ `:where`, `:has` and `:not` never count, and escapes (`hover\:bg-muted`,
578
+ `w-\[38px\]`) are read as the browser reads them. Rules inside `@media`,
579
+ `@supports`, `@layer`, `@container` and `@scope` are pruned the same way and
580
+ an emptied block goes (a named `@layer` stays, since it sets the layer
581
+ order). `@font-face`, `@keyframes`, `@property`, other at-rules and rules with
582
+ no class or id are kept as written. A rule repeated in the same at-rule
583
+ context is written once, its last copy. A composed screen drops from
584
+ megabytes to a few hundred KB with identical pixels. A class your script
585
+ builds from pieces (`"is-" + state`) is not seen; `--css full` (option
586
+ `css: "full"`) keeps every rule, as before 0.11.
587
+
571
588
  ### Clickable mockups
572
589
 
573
590
  A composed page can switch tabs, open and close menus and swap between a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codepress/design-kit",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Capture your Storybook and real app pages as a static design kit, and compose self-contained mockups from it",
5
5
  "type": "module",
6
6
  "bin": {
package/src/kit.d.ts CHANGED
@@ -95,9 +95,21 @@ export function compose(
95
95
  showNew?: boolean;
96
96
  /** Bytes of markup the page may grow to through snippets. */
97
97
  maxBytes?: number;
98
+ /**
99
+ * `pruned` (the default) keeps only the CSS rules the page's classes and
100
+ * ids can use, each repeated rule once; `full` keeps every rule.
101
+ */
102
+ css?: "pruned" | "full";
98
103
  },
99
104
  ): string;
100
105
 
106
+ /**
107
+ * The stylesheet without the style rules that need a class or id missing
108
+ * from `names`, and with each rule repeated in the same at-rule context
109
+ * written once (its last copy).
110
+ */
111
+ export function pruneCss(css: string, names: Set<string>): string;
112
+
101
113
  /** The default `maxBytes` of `compose`: 32 MB. */
102
114
  export const COMPOSE_MAX_BYTES: number;
103
115
 
package/src/kit.mjs CHANGED
@@ -7,7 +7,7 @@
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. The only
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
- ${sanitizeCss(css)}${showNewCss}
1656
+ ${pageCss}${showNewCss}
1292
1657
  </style>
1293
1658
  </head>
1294
1659
  <body${bodyAttrs}>
@@ -1652,7 +2017,9 @@ const USAGE = `usage: kit.mjs [--kit <dir>] <command>
1652
2017
  info print the manifest
1653
2018
  find <query> list up to ${FIND_LIMIT} rows whose id, title or on-screen text holds every word
1654
2019
  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>`;
2020
+ compose --frame <id> [--slot <name>=<file>]… [--theme light|dark] [--show-new] [--css pruned|full] --out <file>
2021
+ --css full keeps every rule of the kit's CSS; the default
2022
+ keeps only rules the page's classes and ids can use`;
1656
2023
 
1657
2024
  function parseArgs(argv) {
1658
2025
  const options = { slots: [], positional: [] };
@@ -1669,6 +2036,7 @@ function parseArgs(argv) {
1669
2036
  else if (arg === "--slot") options.slots.push(value());
1670
2037
  else if (arg === "--theme") options.theme = value();
1671
2038
  else if (arg === "--out") options.out = value();
2039
+ else if (arg === "--css") options.css = value();
1672
2040
  else if (arg === "--full") options.full = true;
1673
2041
  else if (arg === "--show-new") options.showNew = true;
1674
2042
  else if (arg === "--help" || arg === "-h") options.help = true;
@@ -1718,6 +2086,7 @@ export function run(
1718
2086
  slots,
1719
2087
  theme: options.theme ?? "light",
1720
2088
  showNew: options.showNew,
2089
+ css: options.css ?? "pruned",
1721
2090
  });
1722
2091
  writeFileSync(resolve(options.out), page);
1723
2092
  io.out(