@codepress/design-kit 0.9.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 +72 -4
- package/package.json +1 -1
- package/schema/design-kit.config.schema.json +6 -0
- package/src/capture-pages.mjs +1 -0
- package/src/kit.d.ts +12 -0
- package/src/kit.mjs +719 -28
package/README.md
CHANGED
|
@@ -351,7 +351,7 @@ fresh browser context, signed in from `auth.storageState` (a Playwright
|
|
|
351
351
|
storage state file) or from the state the `auth.setup` module's default
|
|
352
352
|
export returns (`async ({ browser, baseUrl, config }) => state`).
|
|
353
353
|
|
|
354
|
-
- `pages[]`: `{name, title?, path, ready, sourceFile?, slots, layoutSlots?}`.
|
|
354
|
+
- `pages[]`: `{name, title?, path, ready, sourceFile?, slots, layoutSlots?, widths?}`. `widths` names which of `pages.widths` a page is captured at, for a view the app draws at some sizes only; it defaults to all of them.
|
|
355
355
|
`ready` is a Playwright selector that shows once the page has its content.
|
|
356
356
|
`slots` names the regions a mockup replaces: the first selector that matches
|
|
357
357
|
a visible, non-empty element gets `data-kit-slot="<name>"`, and `main` is
|
|
@@ -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,
|
|
@@ -558,11 +558,79 @@ the frame, the snippet ids and how many `data-kit-new` elements the page has.
|
|
|
558
558
|
The record stays in the page: `kit.mjs` makes no network call. Scripts, event handlers,
|
|
559
559
|
`<link>`s and SVG animation are removed, and URLs pass an allowlist: only
|
|
560
560
|
relative references, fragments and `data:` survive, read the way the browser
|
|
561
|
-
reads them.
|
|
562
|
-
`
|
|
561
|
+
reads them. The one exception is your own code: an inline `<script>` (no
|
|
562
|
+
`src`) in a `--slot` fragment file is kept in place, with only its `type` and
|
|
563
|
+
`id`. Nothing executable from the kit's captured markup survives, a fragment's
|
|
564
|
+
`<script src>` is removed, and `on…=` handlers are removed everywhere, your
|
|
565
|
+
fragments included. `--theme dark` applies the manifest's theme. Elements marked
|
|
566
|
+
`data-kit-new` get a visible outline with `--show-new`, and behaviour triggers
|
|
567
|
+
(below) a blue one. An unknown frame, slot
|
|
563
568
|
or snippet id exits 2, naming near matches. `--kit <dir>` (or
|
|
564
569
|
`DESIGN_KIT_DIR`) points at a kit other than the tool's own folder.
|
|
565
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
|
+
|
|
588
|
+
### Clickable mockups
|
|
589
|
+
|
|
590
|
+
A composed page can switch tabs, open and close menus and swap between a
|
|
591
|
+
component's captured states on click or tap. Mark it up with these attributes
|
|
592
|
+
in your slot fragment, and `compose` inlines a small behaviour script once, at
|
|
593
|
+
the end of the body (no network, no storage; it works in a
|
|
594
|
+
`sandbox="allow-scripts"` frame). A page that uses none of them composes
|
|
595
|
+
exactly as before.
|
|
596
|
+
|
|
597
|
+
| Attribute | What a click does |
|
|
598
|
+
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
599
|
+
| `data-kit-toggle="<id>"` on a trigger | Shows or hides the element with that id and sets `aria-expanded`. A click outside it, or Esc, closes what a toggle opened. |
|
|
600
|
+
| `data-kit-tabs` on a container | Its `[data-kit-tab="<name>"]` triggers show the matching `[data-kit-panel="<name>"]` and hide the others, with `aria-selected`. The first tab starts selected unless one has `aria-selected="true"`. |
|
|
601
|
+
| `data-kit-state="<group>:<name>"` on each state | Nothing by itself: the group starts in the state of its first element without `hidden`. |
|
|
602
|
+
| `data-kit-set="<group>:<name>"` on a trigger | Shows every element of the group in that state and hides the rest of the group. |
|
|
603
|
+
|
|
604
|
+
To make captured stories interactive, compose each state as its own snippet
|
|
605
|
+
and put the attributes on the `<kit-snippet>`: they (and `hidden`) are copied
|
|
606
|
+
onto every top-level element the snippet expands to, a story's portal
|
|
607
|
+
included.
|
|
608
|
+
|
|
609
|
+
```html
|
|
610
|
+
<kit-snippet
|
|
611
|
+
id="story:primitives-dropdownmenu--closed@1280"
|
|
612
|
+
data-kit-state="menu:closed"
|
|
613
|
+
data-kit-set="menu:open"
|
|
614
|
+
></kit-snippet>
|
|
615
|
+
<kit-snippet
|
|
616
|
+
id="story:primitives-dropdownmenu--open@1280"
|
|
617
|
+
data-kit-state="menu:open"
|
|
618
|
+
data-kit-set="menu:closed"
|
|
619
|
+
hidden
|
|
620
|
+
></kit-snippet>
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
A click anywhere on the closed story shows the open one, and a click on the
|
|
624
|
+
open one goes back. Links and buttons inside a trigger only switch the state;
|
|
625
|
+
they do not navigate or submit. A trigger that already has `data-state`
|
|
626
|
+
(Radix-style markup) gets `open`/`closed` or `active`/`inactive` there too, so
|
|
627
|
+
its own styling follows, and a trigger that is not a button or link gets
|
|
628
|
+
`tabindex="0"` so Enter and Space work. Elements the behaviours hide are
|
|
629
|
+
hidden even when their own CSS sets a `display`. Native interactivity needs
|
|
630
|
+
nothing: `<details>`/`<summary>`, `popover` with `popovertarget`, form
|
|
631
|
+
controls and `aria-*` all survive compose. Anything else is a few lines of
|
|
632
|
+
your own `<script>` in the fragment.
|
|
633
|
+
|
|
566
634
|
## Programmatic use
|
|
567
635
|
|
|
568
636
|
```js
|
package/package.json
CHANGED
|
@@ -280,6 +280,12 @@
|
|
|
280
280
|
"description": "The page's source module, shown by `kit show` and searched by `kit find`."
|
|
281
281
|
},
|
|
282
282
|
"slots": { "$ref": "#/$defs/slots" },
|
|
283
|
+
"widths": {
|
|
284
|
+
"type": "array",
|
|
285
|
+
"minItems": 1,
|
|
286
|
+
"items": { "type": "integer", "minimum": 1 },
|
|
287
|
+
"description": "Only these of `pages.widths`, for a page the app draws at some sizes only (a laptop-only rail tab). Default: every width."
|
|
288
|
+
},
|
|
283
289
|
"layoutSlots": {
|
|
284
290
|
"type": "object",
|
|
285
291
|
"description": "Slots for a layout the detector reports, replacing `slots` there.",
|
package/src/capture-pages.mjs
CHANGED
|
@@ -543,6 +543,7 @@ export async function capturePages(config, options = {}) {
|
|
|
543
543
|
for (const viewport of settings.widths) {
|
|
544
544
|
for (const page of pages) {
|
|
545
545
|
if (session.interrupted) return 130;
|
|
546
|
+
if (page.widths && !page.widths.includes(viewport.width)) continue;
|
|
546
547
|
const id = kitRouteId(page.name, viewport.width);
|
|
547
548
|
const context = await session.browser.newContext({
|
|
548
549
|
baseURL: baseUrl,
|
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,11 +7,12 @@
|
|
|
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.
|
|
14
14
|
*/
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
15
16
|
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
16
17
|
import { basename, dirname, extname, join, resolve, sep } from "node:path";
|
|
17
18
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -185,11 +186,34 @@ function parseAttributes(source) {
|
|
|
185
186
|
const pattern = /([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
186
187
|
let match;
|
|
187
188
|
while ((match = pattern.exec(source))) {
|
|
188
|
-
|
|
189
|
+
const name = match[1].toLowerCase();
|
|
190
|
+
// The browser keeps the first of two same-named attributes, so the
|
|
191
|
+
// sanitizer must check that one: `<img src="https://…" src="a.png">`
|
|
192
|
+
// loads the remote one.
|
|
193
|
+
if (!Object.hasOwn(attrs, name))
|
|
194
|
+
attrs[name] = match[2] ?? match[3] ?? match[4] ?? "";
|
|
189
195
|
}
|
|
190
196
|
return attrs;
|
|
191
197
|
}
|
|
192
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Where the comment opening at `lt` ends, as the browser reads it: `<!-->`
|
|
201
|
+
* and `<!--->` are empty comments, and `--!>` closes a comment as well as
|
|
202
|
+
* `-->`. Reading a comment as longer than the browser does would let markup
|
|
203
|
+
* after its real end pass the sanitizer as comment text.
|
|
204
|
+
*/
|
|
205
|
+
function commentEnd(html, lt) {
|
|
206
|
+
if (html.startsWith(">", lt + 4)) return lt + 5;
|
|
207
|
+
if (html.startsWith("->", lt + 4)) return lt + 6;
|
|
208
|
+
const ends = [
|
|
209
|
+
[html.indexOf("-->", lt + 4), 3],
|
|
210
|
+
[html.indexOf("--!>", lt + 4), 4],
|
|
211
|
+
].filter(([at]) => at >= 0);
|
|
212
|
+
if (!ends.length) return html.length;
|
|
213
|
+
const [at, length] = ends.reduce((a, b) => (b[0] < a[0] ? b : a));
|
|
214
|
+
return at + length;
|
|
215
|
+
}
|
|
216
|
+
|
|
193
217
|
/** Tokens with offsets: open / close / text / comment. */
|
|
194
218
|
export function tokenize(html) {
|
|
195
219
|
return [...scan(html)];
|
|
@@ -207,10 +231,8 @@ function* scan(html) {
|
|
|
207
231
|
}
|
|
208
232
|
if (lt > i) yield { type: "text", start: i, end: lt };
|
|
209
233
|
if (html.startsWith("<!--", lt)) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
yield { type: "comment", start: lt, end };
|
|
213
|
-
i = end;
|
|
234
|
+
yield { type: "comment", start: lt, end: commentEnd(html, lt) };
|
|
235
|
+
i = commentEnd(html, lt);
|
|
214
236
|
continue;
|
|
215
237
|
}
|
|
216
238
|
const closing = html[lt + 1] === "/";
|
|
@@ -339,6 +361,8 @@ const DROPPED = new Set(["script", "link", "base", "meta", "set", "animate"]);
|
|
|
339
361
|
/** Dropped with everything inside them: scripts, and SVG animation, which can
|
|
340
362
|
* set an href to a remote URL after load. */
|
|
341
363
|
const DROPPED_WITH_CONTENT = new Set(["script", "set", "animate"]);
|
|
364
|
+
/** Elements this scanner reads as raw text that foreign content parses as markup. */
|
|
365
|
+
const RAW_TEXT = new Set(["style", "title", "textarea"]);
|
|
342
366
|
|
|
343
367
|
/**
|
|
344
368
|
* Named character references the decoder knows: every HTML name for an ASCII
|
|
@@ -528,10 +552,23 @@ export function sanitizeMarkup(html) {
|
|
|
528
552
|
for (let t = 0; t < tokens.length; t += 1) {
|
|
529
553
|
const token = tokens[t];
|
|
530
554
|
if (token.type !== "open") continue;
|
|
531
|
-
|
|
555
|
+
// Inside <svg> or <math> the browser parses a <style>, <title> or
|
|
556
|
+
// <textarea>'s content as markup (`<svg><style><img onerror=…>`), where
|
|
557
|
+
// this scanner reads raw text. One whose text holds anything tag-like is
|
|
558
|
+
// dropped with its content, wherever it sits.
|
|
559
|
+
const text = tokens[t + 1];
|
|
560
|
+
const markupInRaw =
|
|
561
|
+
RAW_TEXT.has(token.name) &&
|
|
562
|
+
!token.selfClosing &&
|
|
563
|
+
text?.type === "text" &&
|
|
564
|
+
/<[a-z!/?]/i.test(html.slice(text.start, text.end));
|
|
565
|
+
if (DROPPED.has(token.name) || markupInRaw) {
|
|
532
566
|
out += html.slice(i, token.start);
|
|
533
567
|
i = token.end;
|
|
534
|
-
if (
|
|
568
|
+
if (
|
|
569
|
+
(DROPPED_WITH_CONTENT.has(token.name) || markupInRaw) &&
|
|
570
|
+
!token.selfClosing
|
|
571
|
+
) {
|
|
535
572
|
const close = tokens.findIndex(
|
|
536
573
|
(n, k) => k > t && n.type === "close" && n.name === token.name,
|
|
537
574
|
);
|
|
@@ -594,7 +631,6 @@ export function sanitizeMarkup(html) {
|
|
|
594
631
|
out += `${html.slice(i, token.start)}<${token.name}${attrs}${token.selfClosing && !VOID.has(token.name) ? " /" : ""}>`;
|
|
595
632
|
i = token.end;
|
|
596
633
|
}
|
|
597
|
-
const text = tokens[t + 1];
|
|
598
634
|
if (token.name === "style" && text?.type === "text") {
|
|
599
635
|
out +=
|
|
600
636
|
html.slice(i, text.start) +
|
|
@@ -709,6 +745,596 @@ export function inlineAssets(kit, css, cssDir = join(kit.dir, "css")) {
|
|
|
709
745
|
);
|
|
710
746
|
}
|
|
711
747
|
|
|
748
|
+
// ── behaviours (clickable mockups) ──────────────────────────────────────────
|
|
749
|
+
|
|
750
|
+
/** Attributes that make compose inline the behaviour script. */
|
|
751
|
+
const BEHAVIOUR_ATTRS = [
|
|
752
|
+
"data-kit-toggle",
|
|
753
|
+
"data-kit-tabs",
|
|
754
|
+
"data-kit-tab",
|
|
755
|
+
"data-kit-panel",
|
|
756
|
+
"data-kit-state",
|
|
757
|
+
"data-kit-set",
|
|
758
|
+
];
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Hides what the behaviours hide even when the element's own CSS sets a
|
|
762
|
+
* display (a captured story root with `display:flex` would ignore `hidden`).
|
|
763
|
+
* The script marks toggle targets with `data-kit-target`.
|
|
764
|
+
*/
|
|
765
|
+
const BEHAVIOURS_CSS =
|
|
766
|
+
"\n[data-kit-panel][hidden],[data-kit-state][hidden],[data-kit-target][hidden]{display:none !important}";
|
|
767
|
+
|
|
768
|
+
/** --show-new outlines behaviour triggers too, in a second colour. */
|
|
769
|
+
const SHOW_TRIGGERS_CSS =
|
|
770
|
+
"\n[data-kit-toggle],[data-kit-tab],[data-kit-set]{outline:2px dashed #0090ff !important;outline-offset:2px}";
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* The behaviour layer compose inlines once, at the end of the body, when the
|
|
774
|
+
* page uses one of BEHAVIOUR_ATTRS. Plain DOM, no network and no storage, so
|
|
775
|
+
* it runs in a `sandbox="allow-scripts"` frame with an opaque origin.
|
|
776
|
+
*
|
|
777
|
+
* - `data-kit-toggle="<id>"`: a click shows or hides the element with that
|
|
778
|
+
* id and sets `aria-expanded` on its triggers; a click outside it, or Esc,
|
|
779
|
+
* closes what a toggle opened.
|
|
780
|
+
* - `data-kit-tabs` holds `[data-kit-tab="<name>"]` triggers and
|
|
781
|
+
* `[data-kit-panel="<name>"]` panels; a click shows its panel, hides the
|
|
782
|
+
* others and sets `aria-selected`.
|
|
783
|
+
* - `data-kit-set="<group>:<name>"` shows every `[data-kit-state]` of the
|
|
784
|
+
* group in that state and hides the rest of the group.
|
|
785
|
+
*
|
|
786
|
+
* A trigger that already has `data-state` (Radix-style markup) gets
|
|
787
|
+
* open/closed or active/inactive there as well, so its captured styling
|
|
788
|
+
* follows. Triggers that are not focusable get `tabindex="0"`, and Enter or
|
|
789
|
+
* Space clicks them.
|
|
790
|
+
*/
|
|
791
|
+
const BEHAVIOURS_JS = `(function () {
|
|
792
|
+
"use strict";
|
|
793
|
+
if (window.__designKitBehaviours) return;
|
|
794
|
+
window.__designKitBehaviours = true;
|
|
795
|
+
var doc = document;
|
|
796
|
+
var TRIGGERS = "[data-kit-toggle],[data-kit-tab],[data-kit-set]";
|
|
797
|
+
var NATIVE = /^(A|BUTTON|INPUT|SELECT|TEXTAREA|SUMMARY)$/;
|
|
798
|
+
var opened = [];
|
|
799
|
+
function list(selector, root) {
|
|
800
|
+
return Array.prototype.slice.call((root || doc).querySelectorAll(selector));
|
|
801
|
+
}
|
|
802
|
+
function show(el, on) {
|
|
803
|
+
if (on) el.removeAttribute("hidden");
|
|
804
|
+
else el.setAttribute("hidden", "");
|
|
805
|
+
}
|
|
806
|
+
function pair(value) {
|
|
807
|
+
var at = (value || "").indexOf(":");
|
|
808
|
+
return at > 0 ? [value.slice(0, at), value.slice(at + 1)] : null;
|
|
809
|
+
}
|
|
810
|
+
function mirror(el, value) {
|
|
811
|
+
if (el.hasAttribute("data-state")) el.setAttribute("data-state", value);
|
|
812
|
+
}
|
|
813
|
+
function focusable(el) {
|
|
814
|
+
if (NATIVE.test(el.tagName) || el.hasAttribute("tabindex")) return;
|
|
815
|
+
el.setAttribute("tabindex", "0");
|
|
816
|
+
if (!el.hasAttribute("role")) {
|
|
817
|
+
el.setAttribute("role", el.hasAttribute("data-kit-tab") ? "tab" : "button");
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function triggersOf(id) {
|
|
822
|
+
return list("[data-kit-toggle]").filter(function (t) {
|
|
823
|
+
return t.getAttribute("data-kit-toggle") === id;
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
function setOpen(target, open) {
|
|
827
|
+
show(target, open);
|
|
828
|
+
triggersOf(target.id).forEach(function (t) {
|
|
829
|
+
t.setAttribute("aria-expanded", open ? "true" : "false");
|
|
830
|
+
mirror(t, open ? "open" : "closed");
|
|
831
|
+
});
|
|
832
|
+
var at = opened.indexOf(target);
|
|
833
|
+
if (open && at < 0) opened.push(target);
|
|
834
|
+
if (!open && at >= 0) opened.splice(at, 1);
|
|
835
|
+
}
|
|
836
|
+
function closeOpened(clicked) {
|
|
837
|
+
opened.slice().forEach(function (target) {
|
|
838
|
+
var inside =
|
|
839
|
+
clicked &&
|
|
840
|
+
(target.contains(clicked) ||
|
|
841
|
+
triggersOf(target.id).some(function (t) {
|
|
842
|
+
return t.contains(clicked);
|
|
843
|
+
}));
|
|
844
|
+
if (!inside) setOpen(target, false);
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function own(box, selector) {
|
|
849
|
+
return list(selector, box).filter(function (el) {
|
|
850
|
+
return el.closest("[data-kit-tabs]") === box;
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
function selectTab(box, name) {
|
|
854
|
+
own(box, "[data-kit-tab]").forEach(function (t) {
|
|
855
|
+
var on = t.getAttribute("data-kit-tab") === name;
|
|
856
|
+
t.setAttribute("aria-selected", on ? "true" : "false");
|
|
857
|
+
mirror(t, on ? "active" : "inactive");
|
|
858
|
+
});
|
|
859
|
+
own(box, "[data-kit-panel]").forEach(function (p) {
|
|
860
|
+
var on = p.getAttribute("data-kit-panel") === name;
|
|
861
|
+
show(p, on);
|
|
862
|
+
mirror(p, on ? "active" : "inactive");
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function setState(group, name) {
|
|
867
|
+
list("[data-kit-state]").forEach(function (el) {
|
|
868
|
+
var state = pair(el.getAttribute("data-kit-state"));
|
|
869
|
+
if (state && state[0] === group) show(el, state[1] === name);
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
list("[data-kit-toggle]").forEach(function (t) {
|
|
874
|
+
focusable(t);
|
|
875
|
+
var target = doc.getElementById(t.getAttribute("data-kit-toggle"));
|
|
876
|
+
if (!target) return;
|
|
877
|
+
target.setAttribute("data-kit-target", "");
|
|
878
|
+
t.setAttribute("aria-controls", target.id);
|
|
879
|
+
t.setAttribute("aria-expanded", target.hasAttribute("hidden") ? "false" : "true");
|
|
880
|
+
});
|
|
881
|
+
list("[data-kit-tabs]").forEach(function (box) {
|
|
882
|
+
var tabs = own(box, "[data-kit-tab]");
|
|
883
|
+
if (!tabs.length) return;
|
|
884
|
+
tabs.forEach(focusable);
|
|
885
|
+
var current = tabs.filter(function (t) {
|
|
886
|
+
return t.getAttribute("aria-selected") === "true" || t.getAttribute("data-state") === "active";
|
|
887
|
+
})[0] || tabs[0];
|
|
888
|
+
selectTab(box, current.getAttribute("data-kit-tab"));
|
|
889
|
+
});
|
|
890
|
+
// Each group starts in the state of its first visible element.
|
|
891
|
+
var groups = Object.create(null);
|
|
892
|
+
list("[data-kit-state]").forEach(function (el) {
|
|
893
|
+
var state = pair(el.getAttribute("data-kit-state"));
|
|
894
|
+
if (state && !(state[0] in groups) && !el.hasAttribute("hidden")) groups[state[0]] = state[1];
|
|
895
|
+
});
|
|
896
|
+
Object.keys(groups).forEach(function (group) {
|
|
897
|
+
setState(group, groups[group]);
|
|
898
|
+
});
|
|
899
|
+
list("[data-kit-set]").forEach(focusable);
|
|
900
|
+
|
|
901
|
+
doc.addEventListener("click", function (event) {
|
|
902
|
+
var el = event.target;
|
|
903
|
+
if (!el || !el.closest) return;
|
|
904
|
+
closeOpened(el);
|
|
905
|
+
var trigger = el.closest(TRIGGERS);
|
|
906
|
+
if (!trigger) return;
|
|
907
|
+
// A link or button in a trigger (a captured story's own links) would
|
|
908
|
+
// navigate the mockup away or submit; the click only switches state.
|
|
909
|
+
var control = el.closest("a,button");
|
|
910
|
+
if (control && trigger.contains(control)) event.preventDefault();
|
|
911
|
+
var state = pair(trigger.getAttribute("data-kit-set"));
|
|
912
|
+
if (state) setState(state[0], state[1]);
|
|
913
|
+
var box = trigger.hasAttribute("data-kit-tab") && trigger.closest("[data-kit-tabs]");
|
|
914
|
+
if (box) selectTab(box, trigger.getAttribute("data-kit-tab"));
|
|
915
|
+
if (trigger.hasAttribute("data-kit-toggle")) {
|
|
916
|
+
var target = doc.getElementById(trigger.getAttribute("data-kit-toggle"));
|
|
917
|
+
if (target) setOpen(target, target.hasAttribute("hidden"));
|
|
918
|
+
}
|
|
919
|
+
});
|
|
920
|
+
doc.addEventListener("keydown", function (event) {
|
|
921
|
+
var el = event.target;
|
|
922
|
+
if (event.key === "Escape" && opened.length) {
|
|
923
|
+
var back = triggersOf(opened[opened.length - 1].id)[0];
|
|
924
|
+
closeOpened(null);
|
|
925
|
+
if (back && back.focus) back.focus();
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if (
|
|
929
|
+
(event.key === "Enter" || event.key === " ") &&
|
|
930
|
+
el && el.matches && el.matches(TRIGGERS) && !NATIVE.test(el.tagName)
|
|
931
|
+
) {
|
|
932
|
+
event.preventDefault();
|
|
933
|
+
el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
934
|
+
}
|
|
935
|
+
});
|
|
936
|
+
})();`;
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* The author's own inline scripts in a slot fragment, held out of compose:
|
|
940
|
+
* each becomes a comment placeholder carrying `key` (random per compose, so
|
|
941
|
+
* kit markup cannot name one) before snippets expand and the sanitizer runs,
|
|
942
|
+
* and comes back after. A script with `src`, `href` or `xlink:href` stays in
|
|
943
|
+
* place for the sanitizer to drop, as does an unterminated one and an earlier
|
|
944
|
+
* compose's behaviour script (compose inlines a fresh one). Only `type` and
|
|
945
|
+
* `id` are kept on the tag.
|
|
946
|
+
*/
|
|
947
|
+
function holdAuthorScripts(content, key, held) {
|
|
948
|
+
if (!/<script/i.test(content)) return content;
|
|
949
|
+
const tokens = tokenize(content);
|
|
950
|
+
let out = "";
|
|
951
|
+
let i = 0;
|
|
952
|
+
for (let t = 0; t < tokens.length; t += 1) {
|
|
953
|
+
const token = tokens[t];
|
|
954
|
+
if (token.type !== "open" || token.name !== "script" || token.selfClosing)
|
|
955
|
+
continue;
|
|
956
|
+
let close = t + 1;
|
|
957
|
+
if (tokens[close]?.type === "text") close += 1;
|
|
958
|
+
if (tokens[close]?.type !== "close" || tokens[close].name !== "script")
|
|
959
|
+
continue;
|
|
960
|
+
t = close;
|
|
961
|
+
const attrs = token.attrs;
|
|
962
|
+
if (
|
|
963
|
+
["src", "href", "xlink:href", "data-kit-behaviours"].some((name) =>
|
|
964
|
+
Object.hasOwn(attrs, name),
|
|
965
|
+
)
|
|
966
|
+
)
|
|
967
|
+
continue;
|
|
968
|
+
const kept = ["type", "id"]
|
|
969
|
+
.filter((name) => Object.hasOwn(attrs, name))
|
|
970
|
+
.map((name) => ` ${name}="${escapeAttr(decodeEntities(attrs[name]))}"`)
|
|
971
|
+
.join("");
|
|
972
|
+
out += `${content.slice(i, token.start)}<!--kit-author-script ${key} ${held.length}-->`;
|
|
973
|
+
held.push(
|
|
974
|
+
`<script${kept}>${content.slice(token.end, tokens[close].start)}</script>`,
|
|
975
|
+
);
|
|
976
|
+
i = tokens[close].end;
|
|
977
|
+
}
|
|
978
|
+
return out + content.slice(i);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** Each held script back in its placeholder's place, once. */
|
|
982
|
+
function restoreAuthorScripts(html, key, held) {
|
|
983
|
+
if (!held.length) return html;
|
|
984
|
+
const placed = new Set();
|
|
985
|
+
return html.replace(
|
|
986
|
+
new RegExp(`<!--kit-author-script ${key} (\\d+)-->`, "g"),
|
|
987
|
+
(_, n) => {
|
|
988
|
+
const index = Number(n);
|
|
989
|
+
if (placed.has(index) || index >= held.length) return "";
|
|
990
|
+
placed.add(index);
|
|
991
|
+
return held[index];
|
|
992
|
+
},
|
|
993
|
+
);
|
|
994
|
+
}
|
|
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
|
+
|
|
712
1338
|
// ── compose ─────────────────────────────────────────────────────────────────
|
|
713
1339
|
|
|
714
1340
|
/**
|
|
@@ -717,23 +1343,37 @@ export function inlineAssets(kit, css, cssDir = join(kit.dir, "css")) {
|
|
|
717
1343
|
* nested `<kit-snippet>` at the top is left alone: its own expansion stamps
|
|
718
1344
|
* the elements that replace it.
|
|
719
1345
|
*/
|
|
720
|
-
function stampSnippetRoots(html, id) {
|
|
721
|
-
const roots = elements(html).filter(
|
|
722
|
-
(e) =>
|
|
723
|
-
e.depth === 0 &&
|
|
724
|
-
e.name !== "kit-snippet" &&
|
|
725
|
-
!("data-kit-snippet" in e.attrs),
|
|
726
|
-
);
|
|
1346
|
+
function stampSnippetRoots(html, id, carried = "") {
|
|
727
1347
|
let out = "";
|
|
728
1348
|
let i = 0;
|
|
729
|
-
for (const root of
|
|
1349
|
+
for (const root of elements(html)) {
|
|
1350
|
+
if (root.depth !== 0) continue;
|
|
1351
|
+
const stamp =
|
|
1352
|
+
root.name !== "kit-snippet" && !("data-kit-snippet" in root.attrs)
|
|
1353
|
+
? ` data-kit-snippet="${escapeAttr(id)}"`
|
|
1354
|
+
: "";
|
|
1355
|
+
if (!stamp && !carried) continue;
|
|
730
1356
|
const at = root.start + 1 + root.name.length;
|
|
731
|
-
out += `${html.slice(i, at)}
|
|
1357
|
+
out += `${html.slice(i, at)}${stamp}${carried}`;
|
|
732
1358
|
i = at;
|
|
733
1359
|
}
|
|
734
1360
|
return out + html.slice(i);
|
|
735
1361
|
}
|
|
736
1362
|
|
|
1363
|
+
/**
|
|
1364
|
+
* The behaviour attributes a `<kit-snippet>` passes on to the top-level
|
|
1365
|
+
* elements of the markup it expands to, so a captured story can be one state
|
|
1366
|
+
* of a group (`data-kit-state`), a trigger, a tab or a panel. They go first
|
|
1367
|
+
* in the tag, so they win over any the captured element already has.
|
|
1368
|
+
*/
|
|
1369
|
+
const CARRIED_ATTRS = ["hidden", ...BEHAVIOUR_ATTRS];
|
|
1370
|
+
|
|
1371
|
+
function carriedAttributes(attrs) {
|
|
1372
|
+
return CARRIED_ATTRS.filter((name) => Object.hasOwn(attrs, name))
|
|
1373
|
+
.map((name) => ` ${name}="${escapeAttr(decodeEntities(attrs[name]))}"`)
|
|
1374
|
+
.join("");
|
|
1375
|
+
}
|
|
1376
|
+
|
|
737
1377
|
function expandSnippets(kit, html, used, budget, depth = 0, ids = []) {
|
|
738
1378
|
const found = elements(html).filter((e) => e.name === "kit-snippet");
|
|
739
1379
|
if (!found.length) return html;
|
|
@@ -763,7 +1403,7 @@ function expandSnippets(kit, html, used, budget, depth = 0, ids = []) {
|
|
|
763
1403
|
html.slice(i, element.start) +
|
|
764
1404
|
expandSnippets(
|
|
765
1405
|
kit,
|
|
766
|
-
stampSnippetRoots(markup, row.id),
|
|
1406
|
+
stampSnippetRoots(markup, row.id, carriedAttributes(element.attrs)),
|
|
767
1407
|
used,
|
|
768
1408
|
budget,
|
|
769
1409
|
depth + 1,
|
|
@@ -887,7 +1527,12 @@ function attributes(captured, extraClass, theme, choice, target, fixed) {
|
|
|
887
1527
|
/**
|
|
888
1528
|
* One self-contained page: the frame's markup with slots filled and
|
|
889
1529
|
* snippets expanded, its CSS (and that of any other source a snippet came
|
|
890
|
-
* from) inlined with fonts as data URIs, and no
|
|
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
|
|
1533
|
+
* scripts are the inline ones the slot fragments brought and, when the page
|
|
1534
|
+
* uses a behaviour attribute, the behaviour layer; nothing executable from
|
|
1535
|
+
* the kit's own markup survives.
|
|
891
1536
|
*/
|
|
892
1537
|
export function compose(
|
|
893
1538
|
kit,
|
|
@@ -897,16 +1542,30 @@ export function compose(
|
|
|
897
1542
|
theme = "light",
|
|
898
1543
|
showNew = false,
|
|
899
1544
|
maxBytes = COMPOSE_MAX_BYTES,
|
|
1545
|
+
css: cssMode = "pruned",
|
|
900
1546
|
},
|
|
901
1547
|
) {
|
|
902
1548
|
const row = requireRow(kit, frame, "frame");
|
|
903
1549
|
if (theme !== "light" && theme !== "dark") {
|
|
904
1550
|
throw new KitError(`unknown theme "${theme}". Use light or dark.`);
|
|
905
1551
|
}
|
|
1552
|
+
if (cssMode !== "pruned" && cssMode !== "full") {
|
|
1553
|
+
throw new KitError(`unknown css "${cssMode}". Use pruned or full.`);
|
|
1554
|
+
}
|
|
906
1555
|
const used = new Set([rowCssFile(kit, row)]);
|
|
907
|
-
|
|
1556
|
+
// The author's inline scripts sit out the sanitizer; everything else in
|
|
1557
|
+
// the fragments (handlers, remote scripts, snippets) goes through it.
|
|
1558
|
+
const scriptKey = randomBytes(16).toString("hex");
|
|
1559
|
+
const heldScripts = [];
|
|
1560
|
+
const authored = Array.isArray(slots)
|
|
1561
|
+
? slots.map((slot) => ({
|
|
1562
|
+
...slot,
|
|
1563
|
+
content: holdAuthorScripts(slot.content, scriptKey, heldScripts),
|
|
1564
|
+
}))
|
|
1565
|
+
: slots;
|
|
1566
|
+
const filled = fillSlots(kit, row, readSnippet(kit, row), authored);
|
|
908
1567
|
const snippetIds = [];
|
|
909
|
-
const
|
|
1568
|
+
const sanitized = sanitizeMarkup(
|
|
910
1569
|
expandSnippets(
|
|
911
1570
|
kit,
|
|
912
1571
|
filled,
|
|
@@ -916,6 +1575,11 @@ export function compose(
|
|
|
916
1575
|
snippetIds,
|
|
917
1576
|
),
|
|
918
1577
|
);
|
|
1578
|
+
const bodyElements = elements(sanitized);
|
|
1579
|
+
const behaves = bodyElements.some((e) =>
|
|
1580
|
+
BEHAVIOUR_ATTRS.some((name) => Object.hasOwn(e.attrs, name)),
|
|
1581
|
+
);
|
|
1582
|
+
const body = restoreAuthorScripts(sanitized, scriptKey, heldScripts);
|
|
919
1583
|
|
|
920
1584
|
// Stories' sheet first, then the route sheets, as before page sources.
|
|
921
1585
|
const storiesCss = cssFile(kit, "stories");
|
|
@@ -931,8 +1595,14 @@ export function compose(
|
|
|
931
1595
|
return inlineAssets(kit, readFileSync(file, "utf8"), dirname(file));
|
|
932
1596
|
})
|
|
933
1597
|
.join("\n");
|
|
934
|
-
const showNewCss =
|
|
935
|
-
|
|
1598
|
+
const showNewCss =
|
|
1599
|
+
(showNew
|
|
1600
|
+
? "\n[data-kit-new]{outline:2px dashed #e5484d !important;outline-offset:2px}"
|
|
1601
|
+
: "") +
|
|
1602
|
+
(behaves ? BEHAVIOURS_CSS : "") +
|
|
1603
|
+
(showNew && behaves ? SHOW_TRIGGERS_CSS : "");
|
|
1604
|
+
const behaviourScript = behaves
|
|
1605
|
+
? `<script data-kit-behaviours>\n${BEHAVIOURS_JS}\n</script>\n`
|
|
936
1606
|
: "";
|
|
937
1607
|
|
|
938
1608
|
const switcher = kitTheme(kit.manifest);
|
|
@@ -950,13 +1620,30 @@ export function compose(
|
|
|
950
1620
|
"body",
|
|
951
1621
|
{},
|
|
952
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
|
+
);
|
|
953
1640
|
// What this page used, for whoever wants to count it later. Recorded in
|
|
954
1641
|
// the page only; kit.mjs never sends it anywhere.
|
|
955
1642
|
const usage = {
|
|
956
1643
|
kit_sha: sha,
|
|
957
1644
|
frame,
|
|
958
1645
|
snippets: snippetIds,
|
|
959
|
-
new_parts:
|
|
1646
|
+
new_parts: bodyElements.filter((e) => "data-kit-new" in e.attrs).length,
|
|
960
1647
|
};
|
|
961
1648
|
const page = `<!doctype html>
|
|
962
1649
|
<html${htmlAttrs}>
|
|
@@ -966,12 +1653,12 @@ export function compose(
|
|
|
966
1653
|
<meta name="design-kit-usage" content="${escapeAttr(JSON.stringify(usage))}">
|
|
967
1654
|
<title>${escapeAttr(row.title ?? frame)}</title>
|
|
968
1655
|
<style>
|
|
969
|
-
${
|
|
1656
|
+
${pageCss}${showNewCss}
|
|
970
1657
|
</style>
|
|
971
1658
|
</head>
|
|
972
1659
|
<body${bodyAttrs}>
|
|
973
1660
|
${body}
|
|
974
|
-
</body>
|
|
1661
|
+
${behaviourScript}</body>
|
|
975
1662
|
</html>
|
|
976
1663
|
`;
|
|
977
1664
|
return page;
|
|
@@ -1330,7 +2017,9 @@ const USAGE = `usage: kit.mjs [--kit <dir>] <command>
|
|
|
1330
2017
|
info print the manifest
|
|
1331
2018
|
find <query> list up to ${FIND_LIMIT} rows whose id, title or on-screen text holds every word
|
|
1332
2019
|
show <id> [--full] path, size and an outline (--full: the markup)
|
|
1333
|
-
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`;
|
|
1334
2023
|
|
|
1335
2024
|
function parseArgs(argv) {
|
|
1336
2025
|
const options = { slots: [], positional: [] };
|
|
@@ -1347,6 +2036,7 @@ function parseArgs(argv) {
|
|
|
1347
2036
|
else if (arg === "--slot") options.slots.push(value());
|
|
1348
2037
|
else if (arg === "--theme") options.theme = value();
|
|
1349
2038
|
else if (arg === "--out") options.out = value();
|
|
2039
|
+
else if (arg === "--css") options.css = value();
|
|
1350
2040
|
else if (arg === "--full") options.full = true;
|
|
1351
2041
|
else if (arg === "--show-new") options.showNew = true;
|
|
1352
2042
|
else if (arg === "--help" || arg === "-h") options.help = true;
|
|
@@ -1396,6 +2086,7 @@ export function run(
|
|
|
1396
2086
|
slots,
|
|
1397
2087
|
theme: options.theme ?? "light",
|
|
1398
2088
|
showNew: options.showNew,
|
|
2089
|
+
css: options.css ?? "pruned",
|
|
1399
2090
|
});
|
|
1400
2091
|
writeFileSync(resolve(options.out), page);
|
|
1401
2092
|
io.out(
|