@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
@@ -0,0 +1,86 @@
1
+ // How a built section's Tailwind classes reach a page, on any site, whatever its own CSS
2
+ // (cal-os docs/design/site-builder.md, "Tailwind"). Pure: the build (./css.ts) and the preview
3
+ // (../next/preview.tsx, with Tailwind's browser build) compile the same input, so what's typed in
4
+ // CalOS's preview is what's published.
5
+ //
6
+ // - Prefixed: a tree's `p-4` is drawn as `kit:p-4`, so only elements the kit drew from a tree ever
7
+ // carry one of these classes. A site's own markup with a class called `flex` or `hidden` (its own
8
+ // CSS's) is never restyled by them, and the kit's never by its.
9
+ // - No preflight (Tailwind's reset): the rest of the site looks as it did.
10
+ // - Not in a cascade layer: a site's own plain CSS (unlayered) would otherwise beat every utility,
11
+ // whatever its specificity.
12
+ // - The site's Look as the theme: `bg-brand`, `text-ink`, `font-display`, `rounded-card`, read from
13
+ // the Look's CSS variables (--look-*, content/look.ts), so a change of Look in CalOS reaches built
14
+ // sections with nothing compiled again.
15
+ import { KIT_TYPE, TYPE_ROLES } from "../content/look.js";
16
+ /** What every built section's class carries on the page (the tree keeps it without). */
17
+ export const PREFIX = "kit";
18
+ /** A class of a tree as it's drawn: `md:p-4` → `kit:md:p-4`. */
19
+ export const prefixed = (token) => `${PREFIX}:${token}`;
20
+ /** The Look's colours by role, as Tailwind colours (`bg-brand`, `text-on-brand`, `border-line`), with what they fall back to on a site without a Look. */
21
+ export const THEME_COLOURS = {
22
+ brand: "var(--look-brand, #111)",
23
+ "on-brand": "var(--look-on-brand, #fff)",
24
+ accent: "var(--look-accent, currentColor)",
25
+ "on-accent": "var(--look-on-accent, #111)",
26
+ page: "var(--look-page, #fff)",
27
+ ink: "var(--look-ink, #111)",
28
+ muted: "var(--look-muted, #666)",
29
+ line: "var(--look-line, #ddd)",
30
+ soft: "var(--look-soft, #f3f3f3)",
31
+ light: "var(--look-light, #fff)",
32
+ dark: "var(--look-dark, #111)",
33
+ };
34
+ /** The Look's fonts: `font-display` and `font-heading` its headings', `font-body` and `font-text` its text's. */
35
+ export const THEME_FONTS = {
36
+ display: "var(--look-heading-font, inherit)",
37
+ heading: "var(--look-heading-font, inherit)",
38
+ body: "var(--look-body-font, inherit)",
39
+ text: "var(--look-body-font, inherit)",
40
+ };
41
+ /** The Look's type scale as text sizes (`text-h1`, `text-body`), each with its line height, tracking and weight. */
42
+ export const THEME_TYPE = TYPE_ROLES;
43
+ /** Its corners, width and spacing: `rounded-card`, `rounded-button`, `max-w-content`, `px-gutter`, `py-section`. */
44
+ export const THEME_OTHER = {
45
+ "--radius-card": "var(--look-radius, 8px)",
46
+ "--radius-button": "var(--look-button-radius, 6px)",
47
+ "--container-content": "var(--look-width, 1200px)",
48
+ "--spacing-gutter": "var(--look-gutter, 20px)",
49
+ "--spacing-section": "var(--look-section-pad, clamp(56px, 8vw, 96px))",
50
+ };
51
+ /** Names a site's own colour can't take as a Tailwind colour: they're Tailwind's own words, or the roles. */
52
+ const RESERVED = new Set(["inherit", "current", "transparent", ...Object.keys(THEME_COLOURS)]);
53
+ /**
54
+ * The site's own colours' slugs, from the Look's values (lookVars: `--look-swatch-dark-sand`), each
55
+ * one a Tailwind colour by its slug (`bg-dark-sand`).
56
+ */
57
+ export function swatchSlugs(look) {
58
+ return Object.keys(look ?? {})
59
+ .map((k) => /^--look-swatch-([a-z][a-z0-9-]*)$/.exec(k)?.[1])
60
+ .filter((s) => !!s && !RESERVED.has(s));
61
+ }
62
+ /** The Tailwind input for built sections: the theme and utilities only, prefixed, and the site's Look as tokens. */
63
+ export function tailwindInput(swatches = []) {
64
+ const colours = [...Object.entries(THEME_COLOURS).map(([k, v]) => ` --color-${k}: ${v};`), ...swatches.map((s) => ` --color-${s}: var(--look-swatch-${s});`)];
65
+ const fonts = Object.entries(THEME_FONTS).map(([k, v]) => ` --font-${k}: ${v};`);
66
+ const type = THEME_TYPE.flatMap((r) => [
67
+ ` --text-${r}: var(--look-type-${r}-size, ${KIT_TYPE[r].size}px);`,
68
+ ` --text-${r}--line-height: var(--look-type-${r}-leading, ${KIT_TYPE[r].leading});`,
69
+ ` --text-${r}--letter-spacing: var(--look-type-${r}-tracking, ${KIT_TYPE[r].tracking}em);`,
70
+ ` --text-${r}--font-weight: var(--look-type-${r}-weight, ${KIT_TYPE[r].weight});`,
71
+ ]);
72
+ const other = Object.entries(THEME_OTHER).map(([k, v]) => ` ${k}: ${v};`);
73
+ return [
74
+ `@import "tailwindcss/theme.css" prefix(${PREFIX});`,
75
+ `@import "tailwindcss/utilities.css";`,
76
+ // A link to the page it's on (the kit marks it: next/built.tsx): aria-current:underline. Tailwind has no such variant of its own.
77
+ `@custom-variant aria-current (&[aria-current="page"]);`,
78
+ // inline: a utility reads the Look's variable itself, so nothing new is set on :root.
79
+ `@theme inline {`,
80
+ ...colours,
81
+ ...fonts,
82
+ ...type,
83
+ ...other,
84
+ `}`,
85
+ ].join("\n");
86
+ }
@@ -0,0 +1,24 @@
1
+ import { type BehaviorName, type Node } from "../tree/index.ts";
2
+ /** A node's classes as drawn: only the allowed ones (a bad one dropped, never drawn), and its behavior's, each prefixed. */
3
+ export declare function nodeClasses(n: Node): string[];
4
+ /** A block's or component's tree, wherever the content keeps one: built sections, saved sections, the site's components. */
5
+ export declare function treesIn(site: {
6
+ pages?: {
7
+ blocks?: unknown[];
8
+ }[];
9
+ saved?: unknown;
10
+ components?: unknown;
11
+ }): Generator<Node>;
12
+ /** Every class the site's built sections are drawn with (prefixed): all the build compiles. Hidden nodes too, so showing one again needs no build. */
13
+ export declare function classesIn(site: Parameters<typeof treesIn>[0]): Set<string>;
14
+ /** The behaviors the site's built sections use, whose scripts the build makes. */
15
+ export declare function behaviorsIn(site: Parameters<typeof treesIn>[0]): Set<BehaviorName>;
16
+ /** Whether a page has any built section on it (a `built` or `use` block). */
17
+ export declare const hasBuilt: (blocks: readonly {
18
+ type?: unknown;
19
+ }[]) => boolean;
20
+ /**
21
+ * The value at a dotted path: "company.email" in the site's content, "item.name" in the item a
22
+ * `repeat` is drawing. Only the content's own fields are read, never anything an object inherits.
23
+ */
24
+ export declare function readPath(path: string, site: unknown, item?: unknown, self?: unknown): unknown;
@@ -0,0 +1,96 @@
1
+ // What drawing a built section needs to know of its tree beyond each node: the classes it's drawn
2
+ // with (its own, checked, and those its behavior adds), what a `bind` or `repeat` reads from the
3
+ // site's content, and every class and behavior a site's built sections use (for the build to
4
+ // compile only those classes and send only those scripts). Pure, for the server, the preview and the
5
+ // build alike.
6
+ import { safeClasses } from "../tree/index.js";
7
+ import { prefixed } from "./theme.js";
8
+ /** A node's classes as drawn: only the allowed ones (a bad one dropped, never drawn), and its behavior's, each prefixed. */
9
+ export function nodeClasses(n) {
10
+ const own = safeClasses(n.class).split(" ").filter(Boolean);
11
+ // Sticky is CSS alone: no script for it.
12
+ const extra = n.behavior?.name === "sticky" ? ["sticky", `top-[${n.behavior.top ?? 0}px]`] : [];
13
+ return [...own, ...extra].map(prefixed);
14
+ }
15
+ /** A block's or component's tree, wherever the content keeps one: built sections, saved sections, the site's components. */
16
+ export function* treesIn(site) {
17
+ const fromBlock = (b) => {
18
+ const o = b;
19
+ return o && o.type === "built" && o.root && typeof o.root === "object" ? o.root : null;
20
+ };
21
+ for (const p of site.pages ?? [])
22
+ for (const b of p.blocks ?? []) {
23
+ const r = fromBlock(b);
24
+ if (r)
25
+ yield r;
26
+ }
27
+ if (Array.isArray(site.saved))
28
+ for (const s of site.saved) {
29
+ const r = fromBlock(s?.block);
30
+ if (r)
31
+ yield r;
32
+ }
33
+ if (Array.isArray(site.components))
34
+ for (const c of site.components)
35
+ if (c?.root && typeof c.root === "object")
36
+ yield c.root;
37
+ // A header or footer built as a tree (the site's own, in place of the kit's).
38
+ for (const k of ["ownHeader", "ownFooter"]) {
39
+ const o = site[k];
40
+ if (o?.root && typeof o.root === "object")
41
+ yield o.root;
42
+ }
43
+ // A list's page design: drawn for each of its items.
44
+ const lists = site.lists;
45
+ if (lists && typeof lists === "object")
46
+ for (const l of Object.values(lists))
47
+ for (const b of l?.page?.blocks ?? []) {
48
+ const r = fromBlock(b);
49
+ if (r)
50
+ yield r;
51
+ }
52
+ }
53
+ function* nodes(root) {
54
+ yield root;
55
+ for (const c of root.children ?? [])
56
+ yield* nodes(c);
57
+ }
58
+ /** Every class the site's built sections are drawn with (prefixed): all the build compiles. Hidden nodes too, so showing one again needs no build. */
59
+ export function classesIn(site) {
60
+ const out = new Set();
61
+ for (const root of treesIn(site))
62
+ for (const n of nodes(root))
63
+ for (const c of nodeClasses(n))
64
+ out.add(c);
65
+ return out;
66
+ }
67
+ /** The behaviors the site's built sections use, whose scripts the build makes. */
68
+ export function behaviorsIn(site) {
69
+ const out = new Set();
70
+ for (const root of treesIn(site))
71
+ for (const n of nodes(root))
72
+ if (n.behavior && !n.hidden)
73
+ out.add(n.behavior.name);
74
+ return out;
75
+ }
76
+ /** Whether a page has any built section on it (a `built` or `use` block). */
77
+ export const hasBuilt = (blocks) => blocks.some((b) => b?.type === "built" || b?.type === "use");
78
+ /**
79
+ * The value at a dotted path: "company.email" in the site's content, "item.name" in the item a
80
+ * `repeat` is drawing. Only the content's own fields are read, never anything an object inherits.
81
+ */
82
+ export function readPath(path, site, item, self) {
83
+ const parts = path.split(".");
84
+ let at = site;
85
+ // item.: the item a repeat is drawing; this.: the item an item page is drawn for (a list's page).
86
+ if (parts[0] === "item" || parts[0] === "this") {
87
+ at = parts[0] === "item" ? item : self;
88
+ parts.shift();
89
+ }
90
+ for (const k of parts) {
91
+ if (at === null || typeof at !== "object" || !Object.prototype.hasOwnProperty.call(at, k))
92
+ return undefined;
93
+ at = at[k];
94
+ }
95
+ return at;
96
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,257 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { describe, expect, it } from "bun:test";
3
+ import { existsSync } from "node:fs";
4
+ import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+ import { renderToStaticMarkup } from "react-dom/server";
9
+ import { behaviorScript, BEHAVIOR_RUNS } from "./behaviors/index.js";
10
+ import { builtCss } from "./built/css.js";
11
+ import { swatchSlugs, tailwindInput } from "./built/theme.js";
12
+ import { behaviorsIn, classesIn, nodeClasses, readPath } from "./built/tree.js";
13
+ import { defineSite } from "./content/index.js";
14
+ import { finish } from "./finish.js";
15
+ import { setPrepared } from "./next/data.js";
16
+ import { KitSection } from "./next/sections.js";
17
+ import { prepare } from "./prepare.js";
18
+ import { isShown, withoutVariant } from "./preview-match.js";
19
+ import { isKitSection, sections } from "./sections/index.js";
20
+ // Built sections: a tree of elements styled with Tailwind, drawn by the kit on any site, with the CSS
21
+ // for exactly its classes and the scripts of exactly its behaviors.
22
+ const content = {
23
+ url: "https://acme.example",
24
+ business: { name: "Acme" },
25
+ company: { email: "hello@acme.example", phone: "555 0100" },
26
+ caseStudies: [
27
+ { name: "Maple & Oak", summary: "Tax season, **calm**.", photo: { src: "a.jpg", alt: "The office" } },
28
+ { name: "Harbor Dental", summary: "Booked out.", photo: { src: "b.jpg", alt: "The chair" } },
29
+ ],
30
+ };
31
+ const images = {
32
+ "a.jpg": { width: 1600, height: 1000, src: "/_img/a.1600.webp", srcset: [{ url: "/_img/a.640.webp", w: 640 }, { url: "/_img/a.1600.webp", w: 1600 }] },
33
+ "b.jpg": { width: 800, height: 600, src: "/_img/b.800.webp", srcset: [] },
34
+ };
35
+ /** Draws a block as the published site does (draft: as CalOS's preview does). */
36
+ function draw(block, o = {}) {
37
+ setPrepared({ site: { ...content, lang: "en", redirects: [], pages: [], ...o.site }, images, csp: {}, ...(o.built ? { built: o.built } : {}) }, o.draft ?? false);
38
+ return renderToStaticMarkup(_jsx(KitSection, { s: block }));
39
+ }
40
+ const tree = (root) => ({ type: "built", id: "hero", root });
41
+ describe("a built section's tree, drawn", () => {
42
+ it("draws each node as its tag with its classes prefixed, a class that isn't allowed dropped", () => {
43
+ const html = draw(tree({ id: "root000001", tag: "section", class: "grid md:grid-cols-2 gap-6 bg-[url(https://evil.example/x.png)]", children: [{ id: "head000001", tag: "h1", class: "font-display text-h1 text-ink", text: "One place to run the **business**." }] }));
44
+ expect(html).toContain('<section class="kit:grid kit:md:grid-cols-2 kit:gap-6" id="hero" data-kit-built="">');
45
+ expect(html).toContain('<h1 class="kit:font-display kit:text-h1 kit:text-ink">One place to run the <strong>business</strong>.</h1>');
46
+ expect(html).not.toContain("evil");
47
+ });
48
+ it("leaves out a tag that isn't allowed, hidden nodes, and HTML in words; marks nodes only in the preview", () => {
49
+ const root = { id: "root000002", tag: "div", children: [{ id: "bad0000001", tag: "script", text: "alert(1)" }, { id: "hid0000001", tag: "p", text: "Draft", hidden: true }, { id: "txt0000001", tag: "p", text: "<img src=x onerror=alert(1)> hi" }] };
50
+ const html = draw(tree(root));
51
+ expect(html).not.toContain("<script");
52
+ expect(html).not.toContain("Draft");
53
+ expect(html).not.toContain("<img");
54
+ expect(html).toContain("&lt;img src=x onerror=alert(1)&gt; hi");
55
+ expect(html).not.toContain("data-node");
56
+ expect(draw(tree(root), { draft: true })).toContain('data-node="txt0000001"');
57
+ });
58
+ it("checks links again, opens outside ones in a new tab safely, and labels what has no words", () => {
59
+ const html = draw(tree({
60
+ id: "root000003",
61
+ tag: "nav",
62
+ children: [
63
+ { id: "lnk0000001", tag: "a", text: "Work", href: "/work/" },
64
+ { id: "lnk0000002", tag: "a", text: "Them", href: "https://them.example", external: true },
65
+ { id: "lnk0000003", tag: "a", text: "Bad", href: "javascript:alert(1)" },
66
+ { id: "btn0000001", tag: "button", label: "Next", children: [{ id: "ico0000001", tag: "icon", icon: "arrow-right" }] },
67
+ { id: "btn0000002", tag: "button", text: "Call", href: "tel:+15550100" },
68
+ ],
69
+ }));
70
+ expect(html).toContain('<a href="/work/">Work</a>');
71
+ expect(html).toContain('<a href="https://them.example" target="_blank" rel="noopener noreferrer">Them</a>');
72
+ expect(html).toContain("<a>Bad</a>");
73
+ expect(html).not.toContain("javascript:");
74
+ expect(html).toContain('<button aria-label="Next" type="button"><svg class="kit-built-icon" viewBox="0 0 24 24"');
75
+ expect(html).toContain('<a href="tel:+15550100">Call</a>');
76
+ });
77
+ it("draws a picture through the kit's images and a video as the kit's own player", () => {
78
+ const html = draw(tree({ id: "root000004", tag: "figure", children: [{ id: "pic0000001", tag: "img", class: "rounded-card", image: { src: "a.jpg", alt: "The office" } }, { id: "vid0000001", tag: "video", label: "Our story", video: { provider: "youtube", id: "dQw4w9WgXcQ" } }, { id: "vid0000002", tag: "video", video: { provider: "vimeo", id: "123456789" } }] }));
79
+ expect(html).toContain('srcSet="/_img/a.640.webp 640w, /_img/a.1600.webp 1600w"');
80
+ expect(html).toContain('alt="The office"');
81
+ expect(html).toContain('class="kit:rounded-card"');
82
+ expect(html).toContain('src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ" title="Our story"');
83
+ expect(html).toContain('src="https://player.vimeo.com/video/123456789?dnt=1"');
84
+ });
85
+ it("shows shared values (bind) and one copy for each item of a list (repeat)", () => {
86
+ const html = draw(tree({
87
+ id: "root000005",
88
+ tag: "section",
89
+ children: [
90
+ { id: "mail000001", tag: "a", bind: { path: "company.email" }, href: "mailto:hello@acme.example" },
91
+ { id: "card000001", tag: "article", repeat: { path: "caseStudies" }, children: [{ id: "name000001", tag: "h3", bind: { path: "item.name" } }, { id: "sum0000001", tag: "p", bind: { path: "item.summary" } }, { id: "pic0000002", tag: "img", bind: { path: "item.photo" } }] },
92
+ { id: "card000002", tag: "p", repeat: { path: "caseStudies", limit: 1 }, bind: { path: "item.name" } },
93
+ { id: "none000001", tag: "p", bind: { path: "company.__proto__" }, text: "Kept" },
94
+ ],
95
+ }));
96
+ expect(html).toContain('<a href="mailto:hello@acme.example">hello@acme.example</a>');
97
+ expect(html).toContain("<h3>Maple &amp; Oak</h3>");
98
+ expect(html).toContain("<h3>Harbor Dental</h3>");
99
+ expect(html).toContain("Tax season, <strong>calm</strong>.");
100
+ expect(html).toContain('alt="The chair"');
101
+ expect(html.match(/<p>Maple &amp; Oak<\/p>/g)).toHaveLength(1);
102
+ expect(html).not.toContain("<p>Harbor Dental</p>");
103
+ expect(html).toContain("<p>Kept</p>");
104
+ expect(readPath("item.name", content, { name: "x" })).toBe("x");
105
+ expect(readPath("company.toString", content)).toBeUndefined();
106
+ });
107
+ it("draws a component used by name with its slots filled, and nothing for one the site hasn't", () => {
108
+ const components = [{ key: "case-card", name: "Case card", root: { id: "comp000001", tag: "article", class: "p-6 rounded-card", children: [{ id: "tit0000001", tag: "h3", slot: "title", text: "Title" }, { id: "go00000001", tag: "a", slot: "link", text: "View project", href: "/" }] } }];
109
+ const html = draw({ type: "use", id: "case", component: "case-card", slots: { title: { text: "Maple & Oak" }, link: { href: "/work/maple/" } } }, { site: { components } });
110
+ expect(html).toContain('<article class="kit:p-6 kit:rounded-card" id="case" data-kit-built=""><h3>Maple &amp; Oak</h3><a href="/work/maple/">View project</a></article>');
111
+ expect(draw({ type: "use", component: "nope", slots: {} }, { site: { components } })).toBe("");
112
+ expect(draw({ type: "use", component: "nope", slots: {} }, { site: { components }, draft: true })).toContain("kit-built-missing");
113
+ });
114
+ it("links the built sections' CSS and the scripts of the behaviors it uses, on the published page only", () => {
115
+ const built = { css: "/_kit/built.abc.css", scripts: { carousel: "/_kit/carousel.def.js", tabs: "/_kit/tabs.ghi.js" } };
116
+ const block = tree({ id: "root000006", tag: "div", behavior: { name: "carousel", transition: "fade", pauseOnHover: true, dots: true, arrows: false }, children: [{ id: "s000000001", tag: "p", text: "One" }, { id: "s000000002", tag: "p", text: "Two" }] });
117
+ const html = draw(block, { built });
118
+ expect(html).toContain('href="/_kit/built.abc.css"');
119
+ expect(html).toContain('src="/_kit/carousel.def.js"');
120
+ expect(html).not.toContain("tabs.ghi.js");
121
+ const preview = draw(block, { built, draft: true });
122
+ expect(preview).not.toContain("/_kit/");
123
+ });
124
+ });
125
+ describe("behaviors, drawn", () => {
126
+ it("a carousel: the first slide shown, the rest hidden until its script starts, dots as labelled buttons, and a stop button where it moves by itself", () => {
127
+ const html = draw(tree({ id: "root000007", tag: "div", class: "relative", label: "Case studies", behavior: { name: "carousel", seconds: 6, transition: "fade", pauseOnHover: true, dots: true, arrows: true }, children: [{ id: "s000000003", tag: "article", text: "One" }, { id: "s000000004", tag: "article", text: "Two" }] }));
128
+ expect(html).toContain('data-kit-behavior="carousel" data-kit-settings="{&quot;seconds&quot;:6,&quot;transition&quot;:&quot;fade&quot;,&quot;pauseOnHover&quot;:true,&quot;dots&quot;:true,&quot;arrows&quot;:true}" role="region" aria-roledescription="carousel"');
129
+ expect(html).toContain('<div class="kit-carousel-track" data-kit-track="" data-kit-transition="fade"><article data-kit-slide="" data-active="" role="group" aria-roledescription="slide" aria-label="1 of 2">One</article><article data-kit-slide="" role="group" aria-roledescription="slide" aria-label="2 of 2">Two</article></div>');
130
+ expect(html).toContain('data-kit-to="0" aria-label="Show slide 1 of 2" aria-current="true"');
131
+ expect(html).toContain('data-kit-pause="" aria-pressed="false" aria-label="Pause the slides"');
132
+ expect(html).toContain('data-kit-prev="" aria-label="Previous slide"');
133
+ });
134
+ it("a carousel's own designed controls are used in place of the kit's", () => {
135
+ const html = draw(tree({ id: "root000008", tag: "div", behavior: { name: "carousel", transition: "slide", pauseOnHover: true, dots: true, arrows: false }, children: [{ id: "s000000005", tag: "p", text: "One" }, { id: "s000000006", tag: "p", text: "Two" }, { id: "nxt0000001", tag: "button", slot: "next", children: [{ id: "ico0000002", tag: "icon", icon: "arrow-right" }] }, { id: "dts0000001", tag: "div", slot: "dots", class: "flex gap-2" }] }));
136
+ expect(html).toContain('<button data-kit-next="" aria-label="Next slide" type="button">');
137
+ expect(html).toContain('<div class="kit:flex kit:gap-2"><button type="button" class="kit-carousel-dot" data-kit-to="0"');
138
+ expect(html).not.toContain("kit-carousel-controls");
139
+ });
140
+ it("tabs, accordion, marquee, reveal and sticky mark what their scripts and CSS work on", () => {
141
+ const tabs = draw(tree({ id: "root000009", tag: "div", behavior: { name: "tabs", first: 0 }, children: [{ id: "lst0000001", tag: "div", children: [{ id: "tab0000001", tag: "button", text: "Returns" }, { id: "tab0000002", tag: "button", text: "Payroll" }] }, { id: "pan0000001", tag: "div", text: "We file them." }, { id: "pan0000002", tag: "div", text: "We run it." }] }));
142
+ expect(tabs).toContain('<div data-kit-tablist=""><button type="button">Returns</button>');
143
+ expect(tabs).toContain('<div data-kit-panel="">We file them.</div>');
144
+ const acc = draw(tree({ id: "root000010", tag: "div", behavior: { name: "accordion", single: true }, children: [{ id: "itm0000001", tag: "div", children: [{ id: "q000000001", tag: "h3", text: "How long?" }, { id: "a000000001", tag: "p", text: "Three weeks." }] }] }));
145
+ expect(acc).toContain('<div data-kit-item=""><h3 data-kit-head="">How long?</h3><p data-kit-body="">Three weeks.</p></div>');
146
+ const mq = draw(tree({ id: "root000011", tag: "ul", class: "flex gap-8", behavior: { name: "marquee", seconds: 20, reverse: false }, children: [{ id: "li00000001", tag: "li", text: "Acme" }] }));
147
+ expect(mq).toContain('<div class="kit-marquee-track" data-kit-track=""><div class="kit-marquee-group"><li>Acme</li></div><div class="kit-marquee-group" data-kit-copy="" aria-hidden="true" inert=""><li>Acme</li></div></div>');
148
+ const rv = draw(tree({ id: "root000012", tag: "div", behavior: { name: "reveal", effect: "left", stagger: 80 } }));
149
+ expect(rv).toContain('data-kit-behavior="reveal" data-kit-settings="{&quot;effect&quot;:&quot;left&quot;,&quot;stagger&quot;:80}" data-kit-effect="left"');
150
+ expect(nodeClasses({ id: "stk0000001", tag: "aside", class: "p-4", behavior: { name: "sticky", top: 24 } })).toEqual(["kit:p-4", "kit:sticky", "kit:top-[24px]"]);
151
+ });
152
+ it("each behavior with a script is one that can be sent alone, its code and its starter", () => {
153
+ for (const name of Object.keys(BEHAVIOR_RUNS)) {
154
+ const js = behaviorScript(name);
155
+ expect(() => new Function(js)).not.toThrow();
156
+ expect(js).toContain(`"${name}"`);
157
+ }
158
+ expect(behaviorScript("sticky")).toBeNull();
159
+ });
160
+ });
161
+ describe("the kit's sections, with built ones", () => {
162
+ const site = defineSite({ ...sections });
163
+ const page = (blocks, extra = {}) => ({ url: "https://acme.example", business: { name: "Acme" }, ...extra, pages: [{ path: "/", title: "Acme", description: "Acme does things.", blocks }] });
164
+ it("takes built and use blocks, components, and refuses a tree with an id twice or a component it hasn't", () => {
165
+ expect(isKitSection("built") && isKitSection("use")).toBe(true);
166
+ const root = { id: "root000013", tag: "section", children: [{ id: "head000002", tag: "h2", text: "Hi" }] };
167
+ expect(site.parse(page([{ type: "built", root }])).problems).toBeUndefined();
168
+ const twice = { ...root, children: [root.children[0], root.children[0]] };
169
+ expect(site.parse(page([{ type: "built", root: twice }])).problems?.join(" ")).toMatch(/head000002 is used twice/);
170
+ expect(site.parse(page([{ type: "use", component: "case-card" }])).problems?.join(" ")).toMatch(/no component called case-card/);
171
+ const withComponent = page([{ type: "use", component: "case-card", slots: { title: { text: "Hi" } } }], { components: [{ key: "case-card", name: "Case card", root }] });
172
+ expect(site.parse(withComponent).problems).toBeUndefined();
173
+ expect(site.parse(page([{ type: "built", root: { ...root, class: "bg-[url(x)]" } }])).problems?.join(" ")).toMatch(/Not allowed: bg-\[url\(x\)\]/);
174
+ });
175
+ it("says its schema, trees and all, for CalOS", () => {
176
+ const json = JSON.stringify(site.jsonSchema());
177
+ expect(json).toContain("Built section");
178
+ expect(json).toContain('"components"');
179
+ });
180
+ it("collects every class and behavior a site's trees use: built sections, saved ones and components", () => {
181
+ const s = {
182
+ pages: [{ blocks: [{ type: "built", root: { id: "a000000001", tag: "div", class: "p-4 bg-[url(x)]", behavior: { name: "reveal" }, children: [{ id: "b000000001", tag: "p", class: "md:text-h2", hidden: true }] } }, { type: "hero" }] }],
183
+ saved: [{ name: "x", block: { type: "built", root: { id: "c000000001", tag: "div", class: "flex" } } }],
184
+ components: [{ key: "k", name: "K", root: { id: "d000000001", tag: "div", class: "grid", behavior: { name: "carousel" } } }],
185
+ };
186
+ expect([...classesIn(s)].sort()).toEqual(["kit:flex", "kit:grid", "kit:md:text-h2", "kit:p-4"]);
187
+ expect([...behaviorsIn(s)].sort()).toEqual(["carousel", "reveal"]);
188
+ });
189
+ });
190
+ describe("built sections' CSS", () => {
191
+ it("is Tailwind's, for those classes only, with no reset, unlayered, and the Look as its theme", async () => {
192
+ const css = await builtCss(["kit:p-4", "kit:md:hover:bg-brand/90", "kit:text-h1", "kit:bg-dark-sand", "kit:font-display", "kit:rounded-card", "kit:max-w-content"], ["dark-sand"]);
193
+ expect(css).toContain(".kit\\:p-4 {");
194
+ expect(css).toContain("var(--look-brand, #111)");
195
+ expect(css).toContain("font-size: var(--look-type-h1-size, 56px)");
196
+ expect(css).toContain("background-color: var(--look-swatch-dark-sand)");
197
+ expect(css).toContain("font-family: var(--look-heading-font, inherit)");
198
+ expect(css).toContain("border-radius: var(--look-radius, 8px)");
199
+ expect(css).toContain("max-width: var(--look-width, 1200px)");
200
+ // Nothing for a class of the site's own, no reset of its elements, no layer the site's CSS would beat.
201
+ expect(css).not.toMatch(/\n\.flex|box-sizing|@layer (theme|base|utilities)|^\/\*!/);
202
+ expect(await builtCss([])).toBe("");
203
+ });
204
+ it("names the site's own colours by their slugs, never over Tailwind's own words or the roles", () => {
205
+ expect(swatchSlugs({ "--look-swatch-dark-sand": "#c2a", "--look-swatch-1": "#c2a", "--look-swatch-current": "#000", "--look-swatch-brand": "#111", "--look-brand": "#111" })).toEqual(["dark-sand"]);
206
+ expect(tailwindInput(["dark-sand"])).toContain("--color-dark-sand: var(--look-swatch-dark-sand);");
207
+ expect(tailwindInput()).toStartWith('@import "tailwindcss/theme.css" prefix(kit);\n@import "tailwindcss/utilities.css";');
208
+ });
209
+ it("is made by prepare into the site's public files, with each behavior's script, and cleared when there are none", async () => {
210
+ const kit = pathToFileURL(join(import.meta.dir, "content", "index.ts")).href;
211
+ const kitSections = pathToFileURL(join(import.meta.dir, "sections", "index.ts")).href;
212
+ const root = await mkdtemp(join(tmpdir(), "site-"));
213
+ await mkdir(join(root, "content"), { recursive: true });
214
+ await writeFile(join(root, "content", "schema.ts"), `import { defineSite } from ${JSON.stringify(kit)};\nimport { sections } from ${JSON.stringify(kitSections)};\nexport default defineSite({ ...sections }, { look: { brand: "#2b5f8a", palette: [{ name: "Dark sand", color: "#8a6d3b" }] } });\n`);
215
+ const site = (blocks) => writeFile(join(root, "content", "site.json"), JSON.stringify({ url: "https://acme.example", business: { name: "Acme" }, pages: [{ path: "/", title: "Acme", description: "Acme does things.", blocks }] }));
216
+ await site([{ type: "built", root: { id: "root000014", tag: "div", class: "p-4 bg-dark-sand", behavior: { name: "carousel" }, children: [{ id: "s000000007", tag: "p", text: "One" }] } }]);
217
+ const p = await prepare(root, {}, () => { });
218
+ expect(p.built?.css).toMatch(/^\/_kit\/built\.[0-9a-f]{10}\.css$/);
219
+ expect(p.built?.scripts?.carousel).toMatch(/^\/_kit\/carousel\.[0-9a-f]{10}\.js$/);
220
+ const css = await readFile(join(root, "public", p.built.css), "utf8");
221
+ expect(css).toContain("background-color: var(--look-swatch-dark-sand)");
222
+ expect(await readFile(join(root, "public", p.built.scripts.carousel), "utf8")).toContain('"carousel"');
223
+ await site([{ type: "own", columns: [{ parts: [] }] }]);
224
+ const again = await prepare(root, {}, () => { });
225
+ expect(again.built).toBeUndefined();
226
+ expect(existsSync(join(root, "public", "_kit"))).toBe(false);
227
+ });
228
+ });
229
+ describe("the preview", () => {
230
+ it("lets Tailwind's browser build write its styles on the preview page only", async () => {
231
+ const root = await mkdtemp(join(tmpdir(), "site-"));
232
+ await mkdir(join(root, ".site"), { recursive: true });
233
+ await mkdir(join(root, "out"), { recursive: true });
234
+ await writeFile(join(root, ".site", "prepared.json"), JSON.stringify({ site: { url: "https://acme.example", business: { name: "Acme" }, redirects: [], pages: [{ path: "/", blocks: [] }] }, images: {}, csp: {} }));
235
+ await writeFile(join(root, "out", "index.html"), "<html><head><style>a{}</style></head><body></body></html>");
236
+ await writeFile(join(root, "out", "calos-preview.html"), "<html><head><style>a{}</style></head><body></body></html>");
237
+ await finish(root, join(root, "out"), {}, () => { });
238
+ expect(await readFile(join(root, "out", "calos-preview.html"), "utf8")).toContain("style-src 'self' 'unsafe-inline';");
239
+ const page = await readFile(join(root, "out", "index.html"), "utf8");
240
+ expect(page).toMatch(/style-src 'self' 'sha256-[^']+';/);
241
+ expect(page).not.toContain("unsafe-inline");
242
+ });
243
+ it("matches a click only against what's seen: not a slide faded out over the one showing", () => {
244
+ const shown = { ariaHidden: null, visibility: "visible", display: "block", opacity: "1" };
245
+ expect(isShown([shown, shown])).toBe(true);
246
+ expect(isShown([shown, { ...shown, opacity: "0" }])).toBe(false);
247
+ expect(isShown([{ ...shown, ariaHidden: "true" }])).toBe(false);
248
+ expect(isShown([shown, { ...shown, inert: true }])).toBe(false);
249
+ expect(isShown([{ ...shown, visibility: "hidden" }])).toBe(false);
250
+ });
251
+ it("draws a node as hovered by its hover classes without the state", () => {
252
+ expect(withoutVariant("kit:md:hover:bg-ink", ["hover"])).toBe("kit:md:bg-ink");
253
+ expect(withoutVariant("kit:group-hover:underline", ["group-hover"])).toBe("kit:underline");
254
+ expect(withoutVariant("kit:bg-ink", ["hover"])).toBeNull();
255
+ expect(withoutVariant("hover:bg-ink", ["hover"])).toBeNull();
256
+ });
257
+ });
@@ -0,0 +1,33 @@
1
+ import { z } from "zod";
2
+ /** Where the visitor's choice is kept, in their own browser: "yes" or "no". */
3
+ export declare const CONSENT_KEY = "kit-consent";
4
+ /** The setting: whether the site asks, the banner's words and buttons, where it sits, and its design. */
5
+ export declare const consent: z.ZodObject<{
6
+ on: z.ZodDefault<z.ZodBoolean>;
7
+ text: z.ZodDefault<z.ZodString>;
8
+ accept: z.ZodDefault<z.ZodString>;
9
+ decline: z.ZodDefault<z.ZodString>;
10
+ policy: z.ZodOptional<z.ZodObject<{
11
+ label: z.ZodString;
12
+ href: z.ZodString;
13
+ }, z.core.$strip>>;
14
+ place: z.ZodDefault<z.ZodEnum<{
15
+ top: "top";
16
+ bottom: "bottom";
17
+ "bottom-left": "bottom-left";
18
+ "bottom-right": "bottom-right";
19
+ }>>;
20
+ design: z.ZodOptional<z.ZodString>;
21
+ }, z.core.$strip>;
22
+ export type Consent = z.output<typeof consent>;
23
+ /** Whether the site asks before counting. */
24
+ export declare const asksConsent: (site: {
25
+ consent?: unknown;
26
+ }) => boolean;
27
+ /**
28
+ * The banner's script: shows the banner where no choice has been made, keeps the choice when a
29
+ * button is pressed, tells the page (a `kit:consent` event, "yes" or "no"), and shows the banner
30
+ * again from anything marked data-kit-consent="change". Without scripting, the banner stays hidden
31
+ * and nothing is counted.
32
+ */
33
+ export declare function consentScript(): string;
@@ -0,0 +1,36 @@
1
+ // A cookie banner as a site's configuration, not its code (the site's `consent` setting). Off by
2
+ // default: a site that counts its visits does it without cookies (the kit's counter, ./finish.ts),
3
+ // which needs no banner. On, the site asks first: a banner in its words and place, in the kit's
4
+ // design or one of the site's components (a built tree with the slots text, accept, decline and
5
+ // policy), and its visits are counted only once a visitor accepts. The choice is kept in the
6
+ // visitor's browser (localStorage, not a cookie), and any element marked data-kit-consent="change"
7
+ // (a footer link) brings the banner back. The privacy policy says which (./policies.ts).
8
+ //
9
+ // Pure: the schema, the scripts finish puts in each page, and whether a site asks.
10
+ import { z } from "zod";
11
+ /** Where the visitor's choice is kept, in their own browser: "yes" or "no". */
12
+ export const CONSENT_KEY = "kit-consent";
13
+ /** The setting: whether the site asks, the banner's words and buttons, where it sits, and its design. */
14
+ export const consent = z
15
+ .object({
16
+ on: z.boolean().default(false).meta({ title: "Ask before counting visits" }),
17
+ text: z.string().trim().min(1).max(600).default("We'd like to count visits to this site, to see what's useful. Is that all right?").meta({ title: "What it says", "x-ui": "markdown" }),
18
+ accept: z.string().trim().min(1).max(40).default("Accept").meta({ title: "Accept button" }),
19
+ decline: z.string().trim().min(1).max(40).default("Decline").meta({ title: "Decline button" }),
20
+ policy: z.object({ label: z.string().trim().min(1).max(60), href: z.string().regex(/^\/[\w\-./#]*$/) }).optional().meta({ title: "Link to the privacy policy" }),
21
+ place: z.enum(["bottom", "bottom-left", "bottom-right", "top"]).default("bottom").meta({ title: "Where it sits", "x-labels": { bottom: "Along the bottom", "bottom-left": "Bottom left corner", "bottom-right": "Bottom right corner", top: "Along the top" } }),
22
+ /** One of the site's components drawn as the banner, its slots filled (text, accept, decline, policy); the kit's own design without one. */
23
+ design: z.string().regex(/^[a-z][a-z0-9-]{0,39}$/).optional().meta({ title: "Design (a component)" }),
24
+ })
25
+ .meta({ title: "Cookie banner" });
26
+ /** Whether the site asks before counting. */
27
+ export const asksConsent = (site) => !!site.consent?.on;
28
+ /**
29
+ * The banner's script: shows the banner where no choice has been made, keeps the choice when a
30
+ * button is pressed, tells the page (a `kit:consent` event, "yes" or "no"), and shows the banner
31
+ * again from anything marked data-kit-consent="change". Without scripting, the banner stays hidden
32
+ * and nothing is counted.
33
+ */
34
+ export function consentScript() {
35
+ return `(()=>{const K=${JSON.stringify(CONSENT_KEY)};const get=()=>{try{return localStorage.getItem(K)}catch{return null}};const banner=()=>document.querySelector("[data-kit-consent-banner]");const go=()=>{const b=banner();if(b&&!get())b.hidden=false;document.addEventListener("click",(e)=>{const t=e.target.closest&&e.target.closest("[data-kit-consent]");if(!t)return;const v=t.getAttribute("data-kit-consent");if(v==="change"){e.preventDefault();const b=banner();if(b){b.hidden=false;b.querySelector("[data-kit-consent=accept]")?.focus()}return}if(v!=="accept"&&v!=="decline")return;e.preventDefault();const yes=v==="accept"?"yes":"no";try{localStorage.setItem(K,yes)}catch{}const b=banner();if(b)b.hidden=true;dispatchEvent(new CustomEvent("kit:consent",{detail:yes}))})};if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",go,{once:true});else go();})();`;
36
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,61 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { describe, expect, it } from "bun:test";
3
+ import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { renderToStaticMarkup } from "react-dom/server";
7
+ import { consentScript } from "./consent.js";
8
+ import { defineSite } from "./content/index.js";
9
+ import { counted, finish } from "./finish.js";
10
+ import { KitConsent } from "./next/consent.js";
11
+ import { setPrepared } from "./next/data.js";
12
+ import { policy } from "./policies.js";
13
+ import { sections } from "./sections/index.js";
14
+ // A cookie banner as configuration: off unless the site turns it on; on, visits are counted only once accepted.
15
+ const base = { url: "https://acme.example", lang: "en", business: { name: "Acme" }, redirects: [], pages: [{ path: "/", title: "Acme", description: "Acme does things.", blocks: [{ type: "gallery", images: [{ src: "a.jpg", alt: "A" }] }] }] };
16
+ const analytics = { client: "5c2bbecb-9956-4f13-a458-9528dd049210", api: "https://analytics-api.caltho.com", script: "https://analytics.caltho.com/op1.js" };
17
+ describe("consent", () => {
18
+ it("is off unless turned on, with its words and place defaulted, and its design one of the site's components", () => {
19
+ const site = defineSite({ ...sections });
20
+ const off = site.parse(base);
21
+ expect(off.site?.consent).toBeUndefined();
22
+ const on = site.parse({ ...base, consent: { on: true } });
23
+ expect(on.site?.consent).toMatchObject({ on: true, accept: "Accept", decline: "Decline", place: "bottom" });
24
+ expect(site.parse({ ...base, consent: { on: true, design: "banner" } }).problems?.join(" ")).toMatch(/no component called banner/);
25
+ });
26
+ it("starts the counter only once accepted, and puts the banner's script in each page", async () => {
27
+ const gated = counted("<html><head></head><body></body></html>", analytics, true);
28
+ expect(gated).not.toContain('<script src=');
29
+ expect(gated).toContain('localStorage.getItem("kit-consent")==="yes"');
30
+ expect(gated).toContain('addEventListener("kit:consent"');
31
+ expect(() => new Function(/<script>([\s\S]*?)<\/script>/.exec(gated)[1])).not.toThrow();
32
+ expect(() => new Function(consentScript())).not.toThrow();
33
+ const root = await mkdtemp(join(tmpdir(), "finish-"));
34
+ await mkdir(join(root, ".site"), { recursive: true });
35
+ await mkdir(join(root, "out"), { recursive: true });
36
+ await writeFile(join(root, ".site", "prepared.json"), JSON.stringify({ site: { ...base, consent: { on: true } }, images: {}, csp: {}, analytics }));
37
+ await writeFile(join(root, "out", "index.html"), "<html><head></head><body></body></html>");
38
+ await finish(root, join(root, "out"), {}, () => { });
39
+ const html = await readFile(join(root, "out", "index.html"), "utf8");
40
+ expect(html).toContain("data-kit-consent-banner");
41
+ expect(html).toContain("script-src 'self' 'sha256-");
42
+ });
43
+ it("draws the banner hidden for the script to show, in the kit's design or the site's component, its buttons marked", () => {
44
+ const components = [{ key: "banner", name: "Banner", root: { id: "ban0000001", tag: "div", class: "p-4 bg-ink", children: [{ id: "txt0000001", tag: "p", slot: "text", text: "x" }, { id: "yes0000001", tag: "button", slot: "accept", text: "x" }, { id: "no00000001", tag: "button", slot: "decline", text: "x" }] } }];
45
+ setPrepared({ site: { ...base, components }, images: {}, csp: {} }, false);
46
+ const kit = renderToStaticMarkup(_jsx(KitConsent, { site: { consent: { on: true, text: "Count **visits**?", accept: "OK", decline: "No thanks", place: "bottom-right", policy: { label: "Privacy", href: "/privacy/" } } } }));
47
+ expect(kit).toContain('class="kit-consent kit-consent-bottom-right" data-kit-consent-banner="" role="region" aria-label="Cookie choices" hidden=""');
48
+ expect(kit).toContain("Count <strong>visits</strong>?");
49
+ expect(kit).toContain('data-kit-consent="accept">OK</button>');
50
+ expect(kit).toContain('<a class="kit-consent-policy" href="/privacy/">Privacy</a>');
51
+ const own = renderToStaticMarkup(_jsx(KitConsent, { site: { consent: { on: true, text: "Cookies?", accept: "Yes", decline: "No", place: "bottom", design: "banner" } } }));
52
+ expect(own).toContain('<p>Cookies?</p><button data-kit-consent="accept" type="button">Yes</button><button data-kit-consent="decline" type="button">No</button>');
53
+ expect(renderToStaticMarkup(_jsx(KitConsent, { site: {} }))).toBe("");
54
+ });
55
+ it("says in the privacy policy that visits are counted only with agreement", () => {
56
+ const asks = policy({ ...base, countsVisits: true, consent: { on: true } }, "privacy").body;
57
+ expect(asks).toContain("only if you agree when it asks");
58
+ expect(asks).not.toContain("doesn't use cookies");
59
+ expect(policy({ ...base, countsVisits: true }, "privacy").body).toContain("This site doesn't use cookies or advertising trackers.");
60
+ });
61
+ });