@caltho/site-kit 0.2.11 → 0.3.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.
Files changed (73) hide show
  1. package/README.md +84 -3
  2. package/dist/behaviors/accordion.d.ts +4 -0
  3. package/dist/behaviors/accordion.js +56 -0
  4. package/dist/behaviors/carousel.d.ts +4 -0
  5. package/dist/behaviors/carousel.js +127 -0
  6. package/dist/behaviors/filter.d.ts +1 -0
  7. package/dist/behaviors/filter.js +25 -0
  8. package/dist/behaviors/index.d.ts +14 -0
  9. package/dist/behaviors/index.js +78 -0
  10. package/dist/behaviors/motion.d.ts +13 -0
  11. package/dist/behaviors/motion.js +88 -0
  12. package/dist/behaviors/tabs.d.ts +3 -0
  13. package/dist/behaviors/tabs.js +55 -0
  14. package/dist/built/css.d.ts +2 -0
  15. package/dist/built/css.js +26 -0
  16. package/dist/built/theme.d.ts +19 -0
  17. package/dist/built/theme.js +86 -0
  18. package/dist/built/tree.d.ts +24 -0
  19. package/dist/built/tree.js +96 -0
  20. package/dist/built.test.d.ts +1 -0
  21. package/dist/built.test.js +257 -0
  22. package/dist/consent.d.ts +33 -0
  23. package/dist/consent.js +36 -0
  24. package/dist/consent.test.d.ts +1 -0
  25. package/dist/consent.test.js +61 -0
  26. package/dist/content/index.d.ts +170 -40
  27. package/dist/content/index.js +155 -0
  28. package/dist/content/lists.d.ts +69 -0
  29. package/dist/content/lists.js +77 -0
  30. package/dist/content/lists.test.d.ts +1 -0
  31. package/dist/content/lists.test.js +257 -0
  32. package/dist/content/look.d.ts +13 -13
  33. package/dist/content-migrate.d.ts +51 -0
  34. package/dist/content-migrate.js +69 -0
  35. package/dist/finish.d.ts +1 -1
  36. package/dist/finish.js +18 -5
  37. package/dist/frame.test.js +25 -0
  38. package/dist/next/built.d.ts +40 -0
  39. package/dist/next/built.js +318 -0
  40. package/dist/next/consent.d.ts +4 -0
  41. package/dist/next/consent.js +13 -0
  42. package/dist/next/data.d.ts +2 -2
  43. package/dist/next/data.js +6 -4
  44. package/dist/next/frame.d.ts +9 -1
  45. package/dist/next/frame.js +5 -2
  46. package/dist/next/index.d.ts +2 -0
  47. package/dist/next/index.js +2 -0
  48. package/dist/next/metadata.js +3 -2
  49. package/dist/next/parts/Img.d.ts +6 -1
  50. package/dist/next/parts/Img.js +11 -3
  51. package/dist/next/preview.js +242 -15
  52. package/dist/next/sections.js +5 -0
  53. package/dist/policies.js +22 -4
  54. package/dist/prepare.d.ts +10 -0
  55. package/dist/prepare.js +67 -3
  56. package/dist/preview-match.d.ts +21 -0
  57. package/dist/preview-match.js +25 -0
  58. package/dist/schema.js +3 -1
  59. package/dist/sections/icons.d.ts +2 -0
  60. package/dist/sections/icons.js +4 -0
  61. package/dist/sections/index.d.ts +38 -16
  62. package/dist/sections/index.js +7 -2
  63. package/dist/sections.css +324 -0
  64. package/dist/seo.js +2 -1
  65. package/dist/tree/behaviors.d.ts +50 -0
  66. package/dist/tree/behaviors.js +67 -0
  67. package/dist/tree/classes.d.ts +24 -0
  68. package/dist/tree/classes.js +94 -0
  69. package/dist/tree/index.d.ts +145 -0
  70. package/dist/tree/index.js +196 -0
  71. package/dist/tree/messages.d.ts +45 -0
  72. package/dist/tree/messages.js +7 -0
  73. package/package.json +15 -1
@@ -1,14 +1,22 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // A picture from the site's images, at the widths `site-kit prepare` made: `sizes` says how wide
3
3
  // it shows, the browser picks the copy. Cropped to a shape by class (`ratio-4-3`), not by a
4
4
  // style attribute, which the content security policy would block. Above the fold (`eager`) it
5
5
  // loads first; everywhere else it waits until it's near.
6
6
  import { image } from "../data.js";
7
- export function Img({ src, alt, sizes, ratio, eager = false, className }) {
7
+ /** `node`: a built section's node id, marked on the picture in CalOS's preview (data-node) so a click on it names the node. */
8
+ /** Below this width a picture's phone version (`phoneSrc`) is shown in its place. */
9
+ export const PHONE_WIDTH = 700;
10
+ export function Img({ src, alt, sizes, ratio, eager = false, className, node, phoneSrc }) {
8
11
  const m = image(src);
9
12
  const [rw, rh] = ratio ? ratio.split("/").map(Number) : [m.width, m.height];
10
13
  const width = m.width;
11
14
  const height = Math.round((width * rh) / rw);
12
15
  const classes = [ratio ? `ratio-${ratio.replace("/", "-")}` : null, className].filter(Boolean).join(" ") || undefined;
13
- return (_jsx("img", { src: m.src, srcSet: m.srcset.length ? m.srcset.map((s) => `${s.url} ${s.w}w`).join(", ") : undefined, sizes: m.srcset.length ? sizes : undefined, width: width, height: height, alt: alt, loading: eager ? "eager" : "lazy", decoding: eager ? "sync" : "async", fetchPriority: eager ? "high" : undefined, className: classes }));
16
+ const img = (_jsx("img", { src: m.src, srcSet: m.srcset.length ? m.srcset.map((s) => `${s.url} ${s.w}w`).join(", ") : undefined, sizes: m.srcset.length ? sizes : undefined, width: width, height: height, alt: alt, loading: eager ? "eager" : "lazy", decoding: eager ? "sync" : "async", fetchPriority: eager ? "high" : undefined, className: classes, "data-node": node }));
17
+ if (!phoneSrc)
18
+ return img;
19
+ // The phone version, at its own widths, chosen by the browser on a small screen (the <img> stays the picture: its class and alt).
20
+ const p = image(phoneSrc);
21
+ return (_jsxs("picture", { "data-node": node, children: [_jsx("source", { media: `(max-width: ${PHONE_WIDTH}px)`, srcSet: p.srcset.length ? p.srcset.map((x) => `${x.url} ${x.w}w`).join(", ") : p.src, sizes: "100vw", width: p.width, height: p.height }), img] }));
14
22
  }
@@ -10,10 +10,17 @@ import { jsx as _jsx } from "react/jsx-runtime";
10
10
  // page → CalOS: { type: "calos:ready" }, { type: "calos:pick", path, block },
11
11
  // { type: "calos:error", message }, { type: "calos:overflow", blocks } (the page's
12
12
  // blocks with something past the screen's edge at this width, by index)
13
+ // Built sections (../tree/messages.ts): calos:built-select, -hover and -boxes to CalOS;
14
+ // calos:built-highlight and -state from it.
15
+ import { allPages } from "../content/lists.js";
13
16
  import { Component, useEffect, useRef, useState } from "react";
14
17
  import { lookVars } from "../content/look.js";
15
18
  import { fontFiles, googleFont } from "../fonts-google.js";
16
- import { BUILT_IMAGES, fieldForImage, fieldForText, matchBlocks } from "../preview-match.js";
19
+ import { BUILT_IMAGES, fieldForImage, fieldForText, isShown, matchBlocks, withoutVariant } from "../preview-match.js";
20
+ import { showWithin, startBehaviors } from "../behaviors/index.js";
21
+ import { swatchSlugs, tailwindInput } from "../built/theme.js";
22
+ import { hasBuilt } from "../built/tree.js";
23
+ import { NODE_ATTR } from "../tree/index.js";
17
24
  import { BLOCK_MARK, InPreview } from "./block-mark.js";
18
25
  import { setPrepared } from "./data.js";
19
26
  /** `labels`: what CalOS calls each of the page's blocks, shown on its outline. */
@@ -72,6 +79,15 @@ export function SitePreview({ origins, render, look }) {
72
79
  const onMessage = (e) => {
73
80
  if (!origins.includes(e.origin) || e.source !== window.parent)
74
81
  return;
82
+ const said = e.data;
83
+ if (said?.type === "calos:built-highlight" && latest.current) {
84
+ highlight = { block: said.block, nodes: Array.isArray(said.nodes) ? said.nodes.filter((x) => typeof x === "string") : [] };
85
+ return drawHighlight(latest.current, true);
86
+ }
87
+ if (said?.type === "calos:built-state" && latest.current) {
88
+ forced = said.state === "normal" ? null : { block: said.block, node: said.node, state: said.state };
89
+ return applyState(latest.current);
90
+ }
75
91
  const sent = e.data;
76
92
  if (sent?.type !== "calos:show")
77
93
  return;
@@ -106,11 +122,29 @@ export function SitePreview({ origins, render, look }) {
106
122
  const target = e.target;
107
123
  if (!m || !target)
108
124
  return;
125
+ // A built section's own controls (a carousel's dots and arrows, which aren't nodes) work as on
126
+ // the site, so a slide can be brought up to edit.
127
+ if (target.closest(".kit-carousel-controls button, button[data-kit-to]"))
128
+ return;
109
129
  e.preventDefault();
110
130
  e.stopPropagation();
131
+ // Only what a person sees is what they clicked: the top element under the pointer that's shown,
132
+ // not a slide faded out over it (a carousel's slides are stacked in one place, the hidden ones
133
+ // still under the pointer), nor the words of one.
134
+ const stack = document.elementsFromPoint(e.clientX, e.clientY);
135
+ const shownEl = stack.find(seen) ?? target;
136
+ // A built section's element names its node: exact, nothing guessed.
137
+ const node = shownEl.closest(`[${NODE_ATTR}]`);
138
+ if (node) {
139
+ const n = blockElements(m)?.findIndex((d) => holds(d, node)) ?? -1;
140
+ // Or in the site's own header or footer (built): -1 or -2, as the editor numbers them.
141
+ const at = n >= 0 ? n : frameOf(node);
142
+ if (at !== null)
143
+ return post({ type: "calos:built-select", block: at, node: node.getAttribute(NODE_ATTR), shift: e.shiftKey });
144
+ }
111
145
  // A link anywhere (a section's, the menu's, the footer's) is edited, never followed: CalOS's page picker moves between pages.
112
146
  // A picture under the pointer counts, even when something over it (a frame, a caption's box) took the click.
113
- const under = document.elementsFromPoint(e.clientX, e.clientY).find((el) => el.tagName === "IMG" && seen(el)) ?? target;
147
+ const under = stack.find((el) => el.tagName === "IMG" && seen(el)) ?? shownEl;
114
148
  post({ type: "calos:pick", ...pick(under, m, wordsAt(e.clientX, e.clientY)) });
115
149
  };
116
150
  const onSubmit = (e) => e.preventDefault();
@@ -123,9 +157,26 @@ export function SitePreview({ origins, render, look }) {
123
157
  const selected = m ? blockElement(m) : null;
124
158
  hovered = n >= 0 ? blocks[n] : null;
125
159
  outline("section", hovered && hovered[0] !== selected?.[0] ? hovered : null, n >= 0 ? m?.labels?.[n] : undefined);
126
- outline("hover", hoverable(target));
160
+ // Over a built section, the element itself (said to CalOS, for its layers), not the words in it.
161
+ const shownEl = document.elementsFromPoint(e.clientX, e.clientY).find(seen) ?? target;
162
+ // In the site's own header or footer (built): -1 or -2, as the editor knows them.
163
+ const framed = n < 0 && shownEl ? shownEl.closest(`[${NODE_ATTR}]`) : null;
164
+ const f = framed ? frameOf(framed) : null;
165
+ const node = n >= 0 ? (shownEl?.closest(`[${NODE_ATTR}]`) ?? null) : f !== null ? framed : null;
166
+ const id = node && (f !== null || holds(blocks[n], node)) ? node.getAttribute(NODE_ATTR) : null;
167
+ const at = f ?? n;
168
+ const said = id ? `${at}:${id}` : null;
169
+ if (said !== hoverSaid) {
170
+ if (id || hoverSaid)
171
+ post({ type: "calos:built-hover", block: id ? at : Number(hoverSaid.split(":")[0]), node: id });
172
+ hoverSaid = said;
173
+ }
174
+ outline("hover", id ? node : hoverable(target));
127
175
  };
128
176
  const leave = () => {
177
+ if (hoverSaid)
178
+ post({ type: "calos:built-hover", block: Number(hoverSaid.split(":")[0]), node: null });
179
+ hoverSaid = null;
129
180
  hovered = null;
130
181
  outline("section", null);
131
182
  outline("hover", null);
@@ -243,6 +294,47 @@ export function SitePreview({ origins, render, look }) {
243
294
  window.removeEventListener("resize", keep);
244
295
  };
245
296
  }, [shown, round]);
297
+ // Built sections: their classes drawn as they're typed (Tailwind's browser build, given the same
298
+ // input as the build's compiler), their behaviors running (nothing moving by itself, so what's
299
+ // being edited stays put), where each element is said to CalOS after every drawing (and as the
300
+ // page scrolls, resizes or the classes' CSS changes), and the editor's selection and state kept.
301
+ useEffect(() => {
302
+ if (!shown)
303
+ return;
304
+ const page = allPages(shown.site).find((p) => p.path === shown.path) ?? shown.site.pages[0];
305
+ if (!page || !hasBuilt(page.blocks))
306
+ return;
307
+ loadTailwind(tailwindInput(swatchSlugs(look ? lookVars(shown.site.look, look) : undefined)));
308
+ if (!behaving) {
309
+ behaving = true;
310
+ startBehaviors(true);
311
+ }
312
+ let frame = 0;
313
+ const soon = () => {
314
+ cancelAnimationFrame(frame);
315
+ frame = requestAnimationFrame(() => {
316
+ const m = latest.current ?? shown;
317
+ applyState(m);
318
+ drawHighlight(m, false);
319
+ for (const [block, boxes] of builtBoxes(m))
320
+ post({ type: "calos:built-boxes", block, boxes });
321
+ });
322
+ };
323
+ soon();
324
+ // The browser build writes the classes' CSS a moment after they're drawn: the boxes are said again then.
325
+ const css = new MutationObserver(soon);
326
+ css.observe(document.head, { childList: true, subtree: true, characterData: true });
327
+ void document.fonts?.ready.then(soon);
328
+ window.addEventListener("resize", soon);
329
+ window.addEventListener("scroll", soon, { passive: true });
330
+ return () => {
331
+ cancelAnimationFrame(frame);
332
+ css.disconnect();
333
+ window.removeEventListener("resize", soon);
334
+ window.removeEventListener("scroll", soon);
335
+ };
336
+ // eslint-disable-next-line react-hooks/exhaustive-deps
337
+ }, [shown, round, look]);
246
338
  // The draft's Look, set on the page (LookStyle drew the published one into the build).
247
339
  // One the draft no longer sets (a colour removed, the footer back as designed) is taken off again.
248
340
  const setVars = useRef(new Set());
@@ -277,7 +369,7 @@ export function SitePreview({ origins, render, look }) {
277
369
  }, [shown, look]);
278
370
  if (!shown)
279
371
  return null;
280
- const page = shown.site.pages.find((p) => p.path === shown.path) ?? shown.site.pages[0];
372
+ const page = allPages(shown.site).find((p) => p.path === shown.path) ?? shown.site.pages[0];
281
373
  if (!page)
282
374
  return null;
283
375
  return (_jsx(InPreview.Provider, { value: true, children: _jsx(Fallback, { onError: (message) => post({ type: "calos:error", message }), children: render(page, shown.site) }, round) }));
@@ -389,13 +481,26 @@ function blockElement(m) {
389
481
  function wordsAt(x, y) {
390
482
  const doc = document;
391
483
  const node = doc.caretPositionFromPoint?.(x, y)?.offsetNode ?? doc.caretRangeFromPoint?.(x, y)?.startContainer;
392
- if (node?.nodeType !== Node.TEXT_NODE)
393
- return null;
394
484
  // The caret snaps to the nearest text, even across empty space: only words actually under the pointer count.
395
- const range = document.createRange();
396
- range.selectNodeContents(node);
397
- const under = [...range.getClientRects()].some((r) => x >= r.left && x <= r.right && y >= r.top && y <= r.bottom);
398
- return under ? (node.textContent?.trim() ?? null) || null : null;
485
+ const under = (t) => {
486
+ const range = document.createRange();
487
+ range.selectNodeContents(t);
488
+ return [...range.getClientRects()].some((r) => x >= r.left && x <= r.right && y >= r.top && y <= r.bottom);
489
+ };
490
+ const words = (t) => (t.textContent?.trim() ?? null) || null;
491
+ // Words a person sees: the caret finds text in whatever's on top, a faded-out slide stacked over
492
+ // the one showing as much as the one showing.
493
+ if (node?.nodeType === Node.TEXT_NODE && node.parentElement && seen(node.parentElement))
494
+ return under(node) ? words(node) : null;
495
+ // Then the words of the shown elements under the pointer, the top one first.
496
+ for (const el of document.elementsFromPoint(x, y)) {
497
+ if (!seen(el))
498
+ continue;
499
+ const t = [...el.childNodes].find((c) => c.nodeType === Node.TEXT_NODE && c.textContent?.trim() && under(c));
500
+ if (t)
501
+ return words(t);
502
+ }
503
+ return null;
399
504
  }
400
505
  /** What a click on `target` meant: the field, and the block it's in. `words`: the text right under the pointer, tried first. */
401
506
  function pick(target, m, words = null) {
@@ -485,14 +590,12 @@ const blockOf = (path) => (path[0] === "pages" && path[2] === "blocks" && typeof
485
590
  * slides, stacked in the same place) and not faded out to nothing.
486
591
  */
487
592
  function seen(el) {
593
+ const chain = [];
488
594
  for (let at = el; at && at !== document.body; at = at.parentElement) {
489
- if (at.getAttribute("aria-hidden") === "true")
490
- return false;
491
595
  const c = getComputedStyle(at);
492
- if (c.visibility === "hidden" || c.display === "none" || Number(c.opacity) < 0.05)
493
- return false;
596
+ chain.push({ ariaHidden: at.getAttribute("aria-hidden"), inert: at.hasAttribute("inert"), visibility: c.visibility, display: c.display, opacity: c.opacity });
494
597
  }
495
- return true;
598
+ return isShown(chain);
496
599
  }
497
600
  /** The smallest thing under the pointer that's words or a picture. */
498
601
  function hoverable(el) {
@@ -504,6 +607,130 @@ function hoverable(el) {
504
607
  }
505
608
  return null;
506
609
  }
610
+ // ---- built sections ----
611
+ /** What the editor has selected in a built section (calos:built-highlight), outlined after every drawing. */
612
+ let highlight = null;
613
+ /** A node drawn as hovered or focused while those classes are edited (calos:built-state). */
614
+ let forced = null;
615
+ /** The built node last said to be under the pointer ("block:node"). */
616
+ let hoverSaid = null;
617
+ let behaving = false;
618
+ let tailwind = null;
619
+ /**
620
+ * Tailwind's browser build, loaded once (a chunk of its own: only this page carries it, never a
621
+ * visitor's), compiling the classes on the page as they change. Its input is set first, so it
622
+ * never compiles its default (which resets the page's own styles): the theme and utilities only,
623
+ * prefixed as built sections draw them, with the draft's Look as the theme.
624
+ */
625
+ function loadTailwind(input) {
626
+ let style = document.getElementById("kit-tailwind");
627
+ if (!style) {
628
+ style = document.createElement("style");
629
+ style.id = "kit-tailwind";
630
+ style.setAttribute("type", "text/tailwindcss");
631
+ document.head.appendChild(style);
632
+ }
633
+ if (style.textContent !== input)
634
+ style.textContent = input;
635
+ tailwind ??= import("@tailwindcss/browser").catch((err) => {
636
+ tailwind = null;
637
+ console.error("site-kit: Tailwind's browser build didn't load", err);
638
+ });
639
+ }
640
+ /** The site's own header (-1) or footer (-2), as the editor numbers them: the one a built element is in, or null. */
641
+ const FRAMES = { "-1": "header", "-2": "footer" };
642
+ function frameOf(el) {
643
+ const f = el.closest("[data-kit-frame]")?.getAttribute("data-kit-frame");
644
+ return f === "header" ? -1 : f === "footer" ? -2 : null;
645
+ }
646
+ function frameEls(block) {
647
+ const which = FRAMES[String(block)];
648
+ const el = which ? document.querySelector(`[data-kit-frame="${which}"]`) : null;
649
+ return el ? [el] : [];
650
+ }
651
+ /** The elements drawn for a node of a block (a repeated node has one for each item). */
652
+ function nodeEls(m, block, node) {
653
+ const els = block < 0 ? frameEls(block) : (blockElements(m)?.[block] ?? []);
654
+ const sel = `[${NODE_ATTR}="${CSS.escape(node)}"]`;
655
+ return els.flatMap((el) => [...(el.matches(sel) ? [el] : []), ...el.querySelectorAll(sel)]);
656
+ }
657
+ /** Where each built element is on screen, by block, for CalOS's overlays: only what's shown (not a slide faded out). */
658
+ function builtBoxes(m) {
659
+ const page = m.site.pages[pageIndex(m)];
660
+ const blocks = blockElements(m);
661
+ if (!page || !blocks)
662
+ return [];
663
+ const out = [];
664
+ page.blocks.forEach((b, i) => {
665
+ if (b.type !== "built" && b.type !== "use")
666
+ return;
667
+ const boxes = (blocks[i] ?? [])
668
+ .flatMap((el) => [...(el.hasAttribute(NODE_ATTR) ? [el] : []), ...el.querySelectorAll(`[${NODE_ATTR}]`)])
669
+ .filter(seen)
670
+ .map((el) => {
671
+ const r = el.getBoundingClientRect();
672
+ return { node: el.getAttribute(NODE_ATTR), x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) };
673
+ });
674
+ out.push([i, boxes]);
675
+ });
676
+ return out;
677
+ }
678
+ /** The selected nodes outlined; brought into view when CalOS has just selected them, and shown where a behavior hides them (another slide, tab or closed item). */
679
+ const highlightBoxes = [];
680
+ function drawHighlight(m, scroll) {
681
+ const els = highlight ? highlight.nodes.flatMap((id) => nodeEls(m, highlight.block, id).slice(0, 1)) : [];
682
+ if (scroll)
683
+ els.forEach((el) => showWithin(el));
684
+ while (highlightBoxes.length < els.length) {
685
+ const box = document.createElement("div");
686
+ Object.assign(box.style, { position: "absolute", pointerEvents: "none", zIndex: "2147483647", ...looks.selected });
687
+ document.body.appendChild(box);
688
+ highlightBoxes.push(box);
689
+ }
690
+ highlightBoxes.forEach((box, i) => {
691
+ const el = els[i];
692
+ if (!el)
693
+ return void (box.style.display = "none");
694
+ const r = el.getBoundingClientRect();
695
+ Object.assign(box.style, { display: "block", top: `${r.top + window.scrollY}px`, left: `${r.left + window.scrollX}px`, width: `${r.width}px`, height: `${r.height}px` });
696
+ });
697
+ const first = els[0];
698
+ if (scroll && first) {
699
+ const r = first.getBoundingClientRect();
700
+ if (r.top < 0 || r.bottom > window.innerHeight)
701
+ window.scrollTo({ top: r.top + window.scrollY - window.innerHeight / 4, behavior: "smooth" });
702
+ }
703
+ }
704
+ /**
705
+ * A node drawn as hovered or focused: each of its classes for that state (hover:, focus:; and
706
+ * group-hover: and the like on what's inside it when it's a group) added without the state, so it
707
+ * shows while its hover classes are edited. Whatever was added before is taken off first.
708
+ */
709
+ const STATE_VARIANTS = { hover: ["hover"], focus: ["focus", "focus-visible", "focus-within"] };
710
+ const GROUP_VARIANTS = { hover: ["group-hover"], focus: ["group-focus", "group-focus-within"] };
711
+ let forcedClasses = [];
712
+ function applyState(m) {
713
+ for (const f of forcedClasses)
714
+ f.el.classList.remove(f.cls);
715
+ forcedClasses = [];
716
+ if (!forced)
717
+ return;
718
+ const force = (el, variants) => {
719
+ for (const c of [...el.classList]) {
720
+ const plain = withoutVariant(c, variants);
721
+ if (plain && !el.classList.contains(plain)) {
722
+ el.classList.add(plain);
723
+ forcedClasses.push({ el, cls: plain });
724
+ }
725
+ }
726
+ };
727
+ for (const el of nodeEls(m, forced.block, forced.node)) {
728
+ force(el, STATE_VARIANTS[forced.state]);
729
+ if ([...el.classList].some((c) => /:group$/.test(c)))
730
+ for (const inner of el.querySelectorAll(`[${NODE_ATTR}]`))
731
+ force(inner, GROUP_VARIANTS[forced.state]);
732
+ }
733
+ }
507
734
  // Outlines drawn over the page, sized through the style object (allowed by the content security
508
735
  // policy, where a style attribute in the markup isn't), never catching the pointer. A section's
509
736
  // outline carries its name.
@@ -6,6 +6,7 @@ import { image, isDraft, lookValues } from "./data.js";
6
6
  import { Img } from "./parts/Img.js";
7
7
  import { Md } from "./parts/Md.js";
8
8
  import { Slideshow } from "./slideshow.js";
9
+ import { BuiltSection, UseSection } from "./built.js";
9
10
  /** The kit's own classes, drawn from the Look by sections.css. */
10
11
  export const KIT_CLASSES = {
11
12
  section: "kit-section",
@@ -28,6 +29,10 @@ export function KitSection({ s, level = 2, classes: given }) {
28
29
  return _jsx(Slides, { s: s, level: level, classes: classes });
29
30
  case "own":
30
31
  return _jsx(Own, { s: s, level: level, classes: classes });
32
+ case "built":
33
+ return _jsx(BuiltSection, { s: s });
34
+ case "use":
35
+ return _jsx(UseSection, { s: s });
31
36
  default:
32
37
  return null;
33
38
  }
package/dist/policies.js CHANGED
@@ -6,6 +6,20 @@
6
6
  // nothing said in its place: what to say of the counting is the business's, in `extra` (CalOS
7
7
  // suggests a paragraph as they turn it on). Plain language, US-first; a business adds its own
8
8
  // paragraphs with `extra`, and should have a lawyer read them once.
9
+ import { asksConsent } from "./consent.js";
10
+ /** Whether any page (a built section, the site's own header or footer) has a form of its own built in. */
11
+ function hasBuiltForm(site) {
12
+ const seen = new WeakSet();
13
+ const visit = (v) => {
14
+ if (!v || typeof v !== "object" || seen.has(v))
15
+ return false;
16
+ seen.add(v);
17
+ if (!Array.isArray(v) && v.tag === "form")
18
+ return true;
19
+ return Object.values(v).some(visit);
20
+ };
21
+ return visit(site.pages) || visit(site.ownHeader) || visit(site.ownFooter) || visit(site.components);
22
+ }
9
23
  /**
10
24
  * `collectsFromForm`: the site has a form that takes a visitor's name, email and message, which the
11
25
  * privacy policy says (it defaults to the site having a form address).
@@ -15,8 +29,10 @@ export function policy(site, which, o = {}) {
15
29
  const host = new URL(site.url).host.replace(/^www\./, "");
16
30
  const reach = [b.email ? `email [${b.email}](mailto:${b.email})` : null, b.phone ? `call ${b.phone}` : null].filter(Boolean).join(" or ");
17
31
  const place = b.address ? `${b.address.locality}, ${b.address.region}` : null;
18
- const hasForm = o.collectsFromForm ?? !!site.form;
32
+ const hasForm = o.collectsFromForm ?? (!!site.form || hasBuiltForm(site));
19
33
  const counts = !!site.countsVisits;
34
+ // Asking before counting (the site's consent setting): said, in place of the claim of no cookies.
35
+ const asks = asksConsent(site);
20
36
  if (which === "privacy") {
21
37
  return {
22
38
  title: "Privacy policy",
@@ -31,9 +47,11 @@ export function policy(site, which, o = {}) {
31
47
  ]
32
48
  .filter(Boolean)
33
49
  .join("\n"),
34
- counts
35
- ? `This site doesn't use cookies or advertising trackers. Its fonts and pictures come from this site itself.`
36
- : `This site doesn't use cookies, advertising or analytics trackers. Its fonts and pictures come from this site itself, so visiting it doesn't tell anyone else you were here.`,
50
+ asks
51
+ ? `This site doesn't use advertising trackers. ${counts ? "It counts visits to see what's useful, but only if you agree when it asks (the banner when you first visit); until then, nothing is counted." : "It asks before using anything to count visits (the banner when you first visit), and uses nothing until you agree."} Your choice is kept in your own browser, and you can change it at any time. Its fonts and pictures come from this site itself.`
52
+ : counts
53
+ ? `This site doesn't use cookies or advertising trackers. Its fonts and pictures come from this site itself.`
54
+ : `This site doesn't use cookies, advertising or analytics trackers. Its fonts and pictures come from this site itself, so visiting it doesn't tell anyone else you were here.`,
37
55
  `## How we use it`,
38
56
  `To answer you, to quote for and do the work you ask about, and to keep the records the law requires. We keep enquiries in our customer records system, which Caltho runs for us. We don't sell or rent your information, and we don't share it for advertising. The companies that help us run this site and our business (hosting, email, records) only use it to do that for us.`,
39
57
  `## How long we keep it`,
package/dist/prepare.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type Site } from "./content/index.ts";
2
+ import type { BehaviorName } from "./tree/index.ts";
2
3
  export type PreparedImage = {
3
4
  width: number;
4
5
  height: number;
@@ -31,6 +32,11 @@ export type Prepared = {
31
32
  look?: Record<string, string>;
32
33
  /** The @font-face rules for the Google fonts its Look uses, their files fetched into public/_fonts (LookStyle writes them). */
33
34
  fontFaces?: string;
35
+ /** Built sections' CSS and their behaviors' scripts, as the site serves them (public/_kit), where its content has any: a page with a built section links them. */
36
+ built?: {
37
+ css?: string;
38
+ scripts?: Partial<Record<BehaviorName, string>>;
39
+ };
34
40
  };
35
41
  /** A site's visits counted: the id its pages send them with, where they're sent, and the script that sends them. */
36
42
  export type Analytics = {
@@ -39,6 +45,10 @@ export type Analytics = {
39
45
  script: string;
40
46
  };
41
47
  /** What CalOS says of the site's analytics, taken only when it's whole and its addresses are https (or a laptop's). */
48
+ /** The business's forms' addresses CalOS sends, by key: only http(s) addresses under plain keys. */
49
+ export declare function formsIn(body: {
50
+ forms?: unknown;
51
+ } | null): Record<string, string> | undefined;
42
52
  export declare function analyticsIn(body: {
43
53
  analytics?: unknown;
44
54
  } | null): Analytics | undefined;
package/dist/prepare.js CHANGED
@@ -13,9 +13,10 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
13
13
  }
14
14
  return path;
15
15
  };
16
+ import { allPages } from "./content/lists.js";
16
17
  import { createHash } from "node:crypto";
17
18
  import { existsSync } from "node:fs";
18
- import { copyFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
19
+ import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
19
20
  import { dirname, extname, join, relative } from "node:path";
20
21
  import sharp from "sharp";
21
22
  import { pathToFileURL } from "node:url";
@@ -23,7 +24,20 @@ import { lookVars, uploadedFontCss } from "./content/index.js";
23
24
  import { fontFaceCss, fontFiles, googleFont, servedAt } from "./fonts-google.js";
24
25
  import { imagesIn, shareImageOf } from "./images.js";
25
26
  import { makeCards } from "./share.js";
27
+ import { behaviorScript } from "./behaviors/index.js";
28
+ import { builtCss } from "./built/css.js";
29
+ import { migrateContent } from "./content-migrate.js";
30
+ import { swatchSlugs } from "./built/theme.js";
31
+ import { behaviorsIn, classesIn } from "./built/tree.js";
26
32
  /** What CalOS says of the site's analytics, taken only when it's whole and its addresses are https (or a laptop's). */
33
+ /** The business's forms' addresses CalOS sends, by key: only http(s) addresses under plain keys. */
34
+ export function formsIn(body) {
35
+ const f = body?.forms;
36
+ if (!f || typeof f !== "object" || Array.isArray(f))
37
+ return undefined;
38
+ const out = Object.fromEntries(Object.entries(f).filter(([k, v]) => /^[a-z0-9][a-z0-9-]{0,59}$/.test(k) && typeof v === "string" && /^https?:\/\//i.test(v) && URL.canParse(v)).map(([k, v]) => [k, new URL(v).href]));
39
+ return Object.keys(out).length ? out : undefined;
40
+ }
27
41
  export function analyticsIn(body) {
28
42
  const a = body?.analytics;
29
43
  const address = (v) => typeof v === "string" && /^(https:\/\/|http:\/\/(localhost|127\.0\.0\.1)(:\d+)?\/?)/i.test(v) && URL.canParse(v);
@@ -43,6 +57,7 @@ export async function prepare(root, env = process.env, log = console.log) {
43
57
  let from;
44
58
  let editors = [];
45
59
  let analytics;
60
+ let forms;
46
61
  // A site CalOS has nothing published for yet (just made, its content not brought in) builds from
47
62
  // its own until there is: 404 is "nothing published", any other refusal is an error.
48
63
  const res = env.CALOS_CONTENT_URL
@@ -64,6 +79,7 @@ export async function prepare(root, env = process.env, log = console.log) {
64
79
  throw new Error(`Couldn't get the content from CalOS (${env.CALOS_CONTENT_URL}): ${res.status}`);
65
80
  const body = (await res.json());
66
81
  raw = body.content;
82
+ forms = formsIn(body);
67
83
  editors = editorsIn(body);
68
84
  analytics = analyticsIn(body);
69
85
  from = "CalOS";
@@ -90,6 +106,13 @@ export async function prepare(root, env = process.env, log = console.log) {
90
106
  from = "content/site.json";
91
107
  }
92
108
  const definition = await loadDefinition(root);
109
+ // Content in an older format than this kit's (CalOS hasn't moved it yet) is moved first, as CalOS would.
110
+ const moved = migrateContent((raw ?? {}));
111
+ if (!moved.ok)
112
+ throw new Error(`The site's content couldn't be moved to this kit's format (${moved.error.id}${moved.error.path.length ? ` at ${moved.error.path.join(".")}` : ""}): ${moved.error.message}`);
113
+ if (moved.applied.length)
114
+ log(`site-kit: content moved from format ${moved.from} to ${moved.format} (${moved.applied.map((a) => a.id).join(", ")})`);
115
+ raw = moved.content;
93
116
  const parsed = definition.parse(raw);
94
117
  if (parsed.problems)
95
118
  throw new Error(`The site's content needs fixing (${from}):\n - ${parsed.problems.join("\n - ")}`);
@@ -99,6 +122,11 @@ export async function prepare(root, env = process.env, log = console.log) {
99
122
  site.countsVisits = true;
100
123
  else
101
124
  delete site.countsVisits;
125
+ // Its built forms' addresses, by the business's forms' keys (never in content).
126
+ if (forms)
127
+ site.forms = forms;
128
+ else
129
+ delete site.forms;
102
130
  for (const w of parsed.warnings)
103
131
  log(`site-kit: warning: ${w}`);
104
132
  const schema = definition.jsonSchema();
@@ -111,15 +139,51 @@ export async function prepare(root, env = process.env, log = console.log) {
111
139
  const preferPicture = !!definition.share && definition.share.prefer === "picture";
112
140
  const look = definition.look ? lookVars(site.look, definition.look) : undefined;
113
141
  const fontFaces = definition.look ? [await googleFontsFor(site.look, definition.look, pub, log), await uploadedFontsFor(site.look, imagesDir, pub, log)].filter(Boolean).join("\n") : "";
114
- const prepared = { site, images, csp: definition.csp, cards, ...(look ? { look } : {}), ...(fontFaces ? { fontFaces } : {}), ...(preferPicture ? { preferPicture } : {}), ...(editors.length ? { editors } : {}), ...(analytics ? { analytics } : {}) };
142
+ const built = await builtAssets(site, look, pub, log);
143
+ const prepared = { site, images, csp: definition.csp, cards, ...(look ? { look } : {}), ...(built ? { built } : {}), ...(fontFaces ? { fontFaces } : {}), ...(preferPicture ? { preferPicture } : {}), ...(editors.length ? { editors } : {}), ...(analytics ? { analytics } : {}) };
115
144
  await writeFile(join(out, "prepared.json"), JSON.stringify(prepared));
116
145
  log(`site-kit: content from ${from}, ${site.pages.length} pages, ${Object.keys(images).length} images${analytics ? ", visits counted" : ""}`);
117
146
  return prepared;
118
147
  }
148
+ // ---- built sections ----
149
+ /**
150
+ * What built sections need on the published site, where the content has any: the CSS for exactly
151
+ * the classes their trees use (Tailwind's compiler, the Look as its theme, no reset, ./built/css.ts)
152
+ * and the script of each behavior they use (./behaviors), written to public/_kit named by their
153
+ * content, so they cache for a year. A page links them only where it has a built section
154
+ * (./next/built.tsx). Made again every build; what an earlier build wrote is cleared first.
155
+ */
156
+ async function builtAssets(site, look, pub, log) {
157
+ const dir = join(pub, "_kit");
158
+ await rm(dir, { recursive: true, force: true });
159
+ const classes = classesIn(site);
160
+ const behaviors = behaviorsIn(site);
161
+ if (!classes.size && !behaviors.size)
162
+ return undefined;
163
+ await mkdir(dir, { recursive: true });
164
+ const named = (body) => createHash("sha256").update(body).digest("hex").slice(0, 10);
165
+ const out = {};
166
+ const css = await builtCss(classes, swatchSlugs(look));
167
+ if (css) {
168
+ const name = `built.${named(css)}.css`;
169
+ await writeFile(join(dir, name), css);
170
+ out.css = `/_kit/${name}`;
171
+ }
172
+ for (const b of behaviors) {
173
+ const js = behaviorScript(b);
174
+ if (!js)
175
+ continue;
176
+ const name = `${b}.${named(js)}.js`;
177
+ await writeFile(join(dir, name), js);
178
+ (out.scripts ??= {})[b] = `/_kit/${name}`;
179
+ }
180
+ log(`site-kit: built sections, ${classes.size} classes${behaviors.size ? `, ${[...behaviors].join(", ")}` : ""}`);
181
+ return out;
182
+ }
119
183
  // ---- pictures ----
120
184
  async function pictures(site, imagesDir, pub, log) {
121
185
  const used = imagesIn(site);
122
- const shared = new Set(site.pages.map((p) => shareImageOf(site, p)?.src).filter((s) => !!s));
186
+ const shared = new Set(allPages(site).map((p) => shareImageOf(site, p)?.src).filter((s) => !!s));
123
187
  const have = existsSync(imagesDir) ? await list(imagesDir) : [];
124
188
  const missing = [...used].filter((src) => !have.includes(src));
125
189
  if (missing.length) {
@@ -31,3 +31,24 @@ export declare function fieldForImage(value: unknown, base: Path, url: string, i
31
31
  * unknown, never guessed, unless the next block has no words to find it by.
32
32
  */
33
33
  export declare function matchBlocks(blocks: unknown[], drawn: string[]): (number | null)[];
34
+ /** What the preview reads of an element and each element it's in, to know whether a person sees it. */
35
+ export type Shown = {
36
+ ariaHidden: string | null;
37
+ inert?: boolean;
38
+ visibility: string;
39
+ display: string;
40
+ opacity: string;
41
+ };
42
+ /**
43
+ * Whether an element is one a person sees, from it and each element it's in (itself first): not in
44
+ * something hidden from them (a carousel's other slides, stacked in the same place and faded out,
45
+ * aria-hidden or inert) and not faded out to nothing. A click is matched only against what's seen,
46
+ * so the slide showing is the one a click on it opens, not one faded out on top of it.
47
+ */
48
+ export declare function isShown(chain: readonly Shown[]): boolean;
49
+ /**
50
+ * A built section's class (as drawn, prefixed) without the state variants given: `kit:md:hover:bg-ink`
51
+ * without hover is `kit:md:bg-ink`, which the preview adds to draw an element as hovered while its
52
+ * hover classes are edited. Null for a class without any of them.
53
+ */
54
+ export declare function withoutVariant(cls: string, variants: readonly string[]): string | null;
@@ -1,6 +1,8 @@
1
1
  // Which field of the content a click in CalOS's preview landed on, with no markers in the site's
2
2
  // components: the words clicked are matched against the content's text, a picture by its address.
3
3
  // Pure, so it's tested here and runs in the preview in the browser.
4
+ import { PREFIX } from "./built/theme.js";
5
+ import { splitVariants } from "./tree/classes.js";
4
6
  /** The preview page's list of the pictures its build made (by path), as a JSON script with this id (../finish.ts). */
5
7
  export const BUILT_IMAGES = "calos-built-images";
6
8
  /** Keys that hold no words a visitor reads: never what a click on words means. */
@@ -110,3 +112,26 @@ export function matchBlocks(blocks, drawn) {
110
112
  }
111
113
  return out;
112
114
  }
115
+ /**
116
+ * Whether an element is one a person sees, from it and each element it's in (itself first): not in
117
+ * something hidden from them (a carousel's other slides, stacked in the same place and faded out,
118
+ * aria-hidden or inert) and not faded out to nothing. A click is matched only against what's seen,
119
+ * so the slide showing is the one a click on it opens, not one faded out on top of it.
120
+ */
121
+ export function isShown(chain) {
122
+ return chain.every((c) => c.ariaHidden !== "true" && !c.inert && c.visibility !== "hidden" && c.display !== "none" && !(Number(c.opacity) < 0.05));
123
+ }
124
+ /**
125
+ * A built section's class (as drawn, prefixed) without the state variants given: `kit:md:hover:bg-ink`
126
+ * without hover is `kit:md:bg-ink`, which the preview adds to draw an element as hovered while its
127
+ * hover classes are edited. Null for a class without any of them.
128
+ */
129
+ export function withoutVariant(cls, variants) {
130
+ if (!cls.startsWith(`${PREFIX}:`))
131
+ return null;
132
+ const { variants: vs, utility } = splitVariants(cls.slice(PREFIX.length + 1));
133
+ const kept = vs.filter((v) => !variants.includes(v));
134
+ if (kept.length === vs.length)
135
+ return null;
136
+ return [PREFIX, ...kept, utility].join(":");
137
+ }