@codepress/design-kit 0.8.0 → 0.10.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 +146 -7
- package/package.json +3 -2
- package/schema/design-kit.config.schema.json +54 -0
- package/src/capture-pages.mjs +1 -0
- package/src/ci.mjs +76 -113
- package/src/cli.mjs +37 -4
- package/src/config.mjs +40 -0
- package/src/init.mjs +263 -73
- package/src/kit.mjs +347 -25
- package/src/storage/bucket.mjs +237 -0
- package/src/storage/codepress.mjs +363 -0
- package/src/storage/common.mjs +125 -0
- package/src/storage/github.mjs +175 -0
- package/src/storage/index.mjs +149 -0
- package/src/storage/zip.mjs +249 -0
- package/src/templates/design-kit-skill.md +18 -18
package/src/kit.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
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,254 @@ 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
|
+
|
|
712
996
|
// ── compose ─────────────────────────────────────────────────────────────────
|
|
713
997
|
|
|
714
998
|
/**
|
|
@@ -717,23 +1001,37 @@ export function inlineAssets(kit, css, cssDir = join(kit.dir, "css")) {
|
|
|
717
1001
|
* nested `<kit-snippet>` at the top is left alone: its own expansion stamps
|
|
718
1002
|
* the elements that replace it.
|
|
719
1003
|
*/
|
|
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
|
-
);
|
|
1004
|
+
function stampSnippetRoots(html, id, carried = "") {
|
|
727
1005
|
let out = "";
|
|
728
1006
|
let i = 0;
|
|
729
|
-
for (const root of
|
|
1007
|
+
for (const root of elements(html)) {
|
|
1008
|
+
if (root.depth !== 0) continue;
|
|
1009
|
+
const stamp =
|
|
1010
|
+
root.name !== "kit-snippet" && !("data-kit-snippet" in root.attrs)
|
|
1011
|
+
? ` data-kit-snippet="${escapeAttr(id)}"`
|
|
1012
|
+
: "";
|
|
1013
|
+
if (!stamp && !carried) continue;
|
|
730
1014
|
const at = root.start + 1 + root.name.length;
|
|
731
|
-
out += `${html.slice(i, at)}
|
|
1015
|
+
out += `${html.slice(i, at)}${stamp}${carried}`;
|
|
732
1016
|
i = at;
|
|
733
1017
|
}
|
|
734
1018
|
return out + html.slice(i);
|
|
735
1019
|
}
|
|
736
1020
|
|
|
1021
|
+
/**
|
|
1022
|
+
* The behaviour attributes a `<kit-snippet>` passes on to the top-level
|
|
1023
|
+
* elements of the markup it expands to, so a captured story can be one state
|
|
1024
|
+
* of a group (`data-kit-state`), a trigger, a tab or a panel. They go first
|
|
1025
|
+
* in the tag, so they win over any the captured element already has.
|
|
1026
|
+
*/
|
|
1027
|
+
const CARRIED_ATTRS = ["hidden", ...BEHAVIOUR_ATTRS];
|
|
1028
|
+
|
|
1029
|
+
function carriedAttributes(attrs) {
|
|
1030
|
+
return CARRIED_ATTRS.filter((name) => Object.hasOwn(attrs, name))
|
|
1031
|
+
.map((name) => ` ${name}="${escapeAttr(decodeEntities(attrs[name]))}"`)
|
|
1032
|
+
.join("");
|
|
1033
|
+
}
|
|
1034
|
+
|
|
737
1035
|
function expandSnippets(kit, html, used, budget, depth = 0, ids = []) {
|
|
738
1036
|
const found = elements(html).filter((e) => e.name === "kit-snippet");
|
|
739
1037
|
if (!found.length) return html;
|
|
@@ -763,7 +1061,7 @@ function expandSnippets(kit, html, used, budget, depth = 0, ids = []) {
|
|
|
763
1061
|
html.slice(i, element.start) +
|
|
764
1062
|
expandSnippets(
|
|
765
1063
|
kit,
|
|
766
|
-
stampSnippetRoots(markup, row.id),
|
|
1064
|
+
stampSnippetRoots(markup, row.id, carriedAttributes(element.attrs)),
|
|
767
1065
|
used,
|
|
768
1066
|
budget,
|
|
769
1067
|
depth + 1,
|
|
@@ -887,7 +1185,10 @@ function attributes(captured, extraClass, theme, choice, target, fixed) {
|
|
|
887
1185
|
/**
|
|
888
1186
|
* One self-contained page: the frame's markup with slots filled and
|
|
889
1187
|
* snippets expanded, its CSS (and that of any other source a snippet came
|
|
890
|
-
* from) inlined with fonts as data URIs, and no
|
|
1188
|
+
* from) inlined with fonts as data URIs, and no remote URLs. The only
|
|
1189
|
+
* scripts are the inline ones the slot fragments brought and, when the page
|
|
1190
|
+
* uses a behaviour attribute, the behaviour layer; nothing executable from
|
|
1191
|
+
* the kit's own markup survives.
|
|
891
1192
|
*/
|
|
892
1193
|
export function compose(
|
|
893
1194
|
kit,
|
|
@@ -904,9 +1205,19 @@ export function compose(
|
|
|
904
1205
|
throw new KitError(`unknown theme "${theme}". Use light or dark.`);
|
|
905
1206
|
}
|
|
906
1207
|
const used = new Set([rowCssFile(kit, row)]);
|
|
907
|
-
|
|
1208
|
+
// The author's inline scripts sit out the sanitizer; everything else in
|
|
1209
|
+
// the fragments (handlers, remote scripts, snippets) goes through it.
|
|
1210
|
+
const scriptKey = randomBytes(16).toString("hex");
|
|
1211
|
+
const heldScripts = [];
|
|
1212
|
+
const authored = Array.isArray(slots)
|
|
1213
|
+
? slots.map((slot) => ({
|
|
1214
|
+
...slot,
|
|
1215
|
+
content: holdAuthorScripts(slot.content, scriptKey, heldScripts),
|
|
1216
|
+
}))
|
|
1217
|
+
: slots;
|
|
1218
|
+
const filled = fillSlots(kit, row, readSnippet(kit, row), authored);
|
|
908
1219
|
const snippetIds = [];
|
|
909
|
-
const
|
|
1220
|
+
const sanitized = sanitizeMarkup(
|
|
910
1221
|
expandSnippets(
|
|
911
1222
|
kit,
|
|
912
1223
|
filled,
|
|
@@ -916,6 +1227,11 @@ export function compose(
|
|
|
916
1227
|
snippetIds,
|
|
917
1228
|
),
|
|
918
1229
|
);
|
|
1230
|
+
const bodyElements = elements(sanitized);
|
|
1231
|
+
const behaves = bodyElements.some((e) =>
|
|
1232
|
+
BEHAVIOUR_ATTRS.some((name) => Object.hasOwn(e.attrs, name)),
|
|
1233
|
+
);
|
|
1234
|
+
const body = restoreAuthorScripts(sanitized, scriptKey, heldScripts);
|
|
919
1235
|
|
|
920
1236
|
// Stories' sheet first, then the route sheets, as before page sources.
|
|
921
1237
|
const storiesCss = cssFile(kit, "stories");
|
|
@@ -931,8 +1247,14 @@ export function compose(
|
|
|
931
1247
|
return inlineAssets(kit, readFileSync(file, "utf8"), dirname(file));
|
|
932
1248
|
})
|
|
933
1249
|
.join("\n");
|
|
934
|
-
const showNewCss =
|
|
935
|
-
|
|
1250
|
+
const showNewCss =
|
|
1251
|
+
(showNew
|
|
1252
|
+
? "\n[data-kit-new]{outline:2px dashed #e5484d !important;outline-offset:2px}"
|
|
1253
|
+
: "") +
|
|
1254
|
+
(behaves ? BEHAVIOURS_CSS : "") +
|
|
1255
|
+
(showNew && behaves ? SHOW_TRIGGERS_CSS : "");
|
|
1256
|
+
const behaviourScript = behaves
|
|
1257
|
+
? `<script data-kit-behaviours>\n${BEHAVIOURS_JS}\n</script>\n`
|
|
936
1258
|
: "";
|
|
937
1259
|
|
|
938
1260
|
const switcher = kitTheme(kit.manifest);
|
|
@@ -956,7 +1278,7 @@ export function compose(
|
|
|
956
1278
|
kit_sha: sha,
|
|
957
1279
|
frame,
|
|
958
1280
|
snippets: snippetIds,
|
|
959
|
-
new_parts:
|
|
1281
|
+
new_parts: bodyElements.filter((e) => "data-kit-new" in e.attrs).length,
|
|
960
1282
|
};
|
|
961
1283
|
const page = `<!doctype html>
|
|
962
1284
|
<html${htmlAttrs}>
|
|
@@ -971,7 +1293,7 @@ ${sanitizeCss(css)}${showNewCss}
|
|
|
971
1293
|
</head>
|
|
972
1294
|
<body${bodyAttrs}>
|
|
973
1295
|
${body}
|
|
974
|
-
</body>
|
|
1296
|
+
${behaviourScript}</body>
|
|
975
1297
|
</html>
|
|
976
1298
|
`;
|
|
977
1299
|
return page;
|