@caltho/site-kit 0.2.10 → 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 (140) hide show
  1. package/README.md +146 -16
  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/cli/check.js +31 -1
  23. package/dist/consent.d.ts +33 -0
  24. package/dist/consent.js +36 -0
  25. package/dist/consent.test.d.ts +1 -0
  26. package/dist/consent.test.js +61 -0
  27. package/dist/content/index.d.ts +450 -33
  28. package/dist/content/index.js +297 -15
  29. package/dist/content/lists.d.ts +69 -0
  30. package/dist/content/lists.js +77 -0
  31. package/dist/content/lists.test.d.ts +1 -0
  32. package/dist/content/lists.test.js +257 -0
  33. package/dist/content/look.d.ts +260 -9
  34. package/dist/content/look.js +386 -22
  35. package/dist/content-migrate.d.ts +51 -0
  36. package/dist/content-migrate.js +69 -0
  37. package/dist/finish.d.ts +1 -1
  38. package/dist/finish.js +27 -6
  39. package/dist/fonts/families/dm-sans/LICENSE +93 -0
  40. package/dist/fonts/families/dm-sans/dm-sans-latin-ext-wght-italic.woff2 +0 -0
  41. package/dist/fonts/families/dm-sans/dm-sans-latin-ext-wght-normal.woff2 +0 -0
  42. package/dist/fonts/families/dm-sans/dm-sans-latin-wght-italic.woff2 +0 -0
  43. package/dist/fonts/families/dm-sans/dm-sans-latin-wght-normal.woff2 +0 -0
  44. package/dist/fonts/families/fraunces/LICENSE +93 -0
  45. package/dist/fonts/families/fraunces/fraunces-latin-ext-wght-italic.woff2 +0 -0
  46. package/dist/fonts/families/fraunces/fraunces-latin-ext-wght-normal.woff2 +0 -0
  47. package/dist/fonts/families/fraunces/fraunces-latin-wght-italic.woff2 +0 -0
  48. package/dist/fonts/families/fraunces/fraunces-latin-wght-normal.woff2 +0 -0
  49. package/dist/fonts/families/inter/LICENSE +93 -0
  50. package/dist/fonts/families/inter/inter-latin-ext-wght-italic.woff2 +0 -0
  51. package/dist/fonts/families/inter/inter-latin-ext-wght-normal.woff2 +0 -0
  52. package/dist/fonts/families/inter/inter-latin-wght-italic.woff2 +0 -0
  53. package/dist/fonts/families/inter/inter-latin-wght-normal.woff2 +0 -0
  54. package/dist/fonts/families/lora/LICENSE +93 -0
  55. package/dist/fonts/families/lora/lora-latin-ext-wght-italic.woff2 +0 -0
  56. package/dist/fonts/families/lora/lora-latin-ext-wght-normal.woff2 +0 -0
  57. package/dist/fonts/families/lora/lora-latin-wght-italic.woff2 +0 -0
  58. package/dist/fonts/families/lora/lora-latin-wght-normal.woff2 +0 -0
  59. package/dist/fonts/families/manrope/LICENSE +93 -0
  60. package/dist/fonts/families/manrope/manrope-latin-ext-wght-normal.woff2 +0 -0
  61. package/dist/fonts/families/manrope/manrope-latin-wght-normal.woff2 +0 -0
  62. package/dist/fonts/families/newsreader/LICENSE +93 -0
  63. package/dist/fonts/families/newsreader/newsreader-latin-ext-wght-italic.woff2 +0 -0
  64. package/dist/fonts/families/newsreader/newsreader-latin-ext-wght-normal.woff2 +0 -0
  65. package/dist/fonts/families/newsreader/newsreader-latin-wght-italic.woff2 +0 -0
  66. package/dist/fonts/families/newsreader/newsreader-latin-wght-normal.woff2 +0 -0
  67. package/dist/fonts/families/outfit/LICENSE +93 -0
  68. package/dist/fonts/families/outfit/outfit-latin-ext-wght-normal.woff2 +0 -0
  69. package/dist/fonts/families/outfit/outfit-latin-wght-normal.woff2 +0 -0
  70. package/dist/fonts/families/playfair-display/LICENSE +93 -0
  71. package/dist/fonts/families/playfair-display/playfair-display-latin-ext-wght-italic.woff2 +0 -0
  72. package/dist/fonts/families/playfair-display/playfair-display-latin-ext-wght-normal.woff2 +0 -0
  73. package/dist/fonts/families/playfair-display/playfair-display-latin-wght-italic.woff2 +0 -0
  74. package/dist/fonts/families/playfair-display/playfair-display-latin-wght-normal.woff2 +0 -0
  75. package/dist/fonts/families/source-serif-4/LICENSE +93 -0
  76. package/dist/fonts/families/source-serif-4/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
  77. package/dist/fonts/families/source-serif-4/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
  78. package/dist/fonts/families/source-serif-4/source-serif-4-latin-wght-italic.woff2 +0 -0
  79. package/dist/fonts/families/source-serif-4/source-serif-4-latin-wght-normal.woff2 +0 -0
  80. package/dist/fonts/families/work-sans/LICENSE +93 -0
  81. package/dist/fonts/families/work-sans/work-sans-latin-ext-wght-italic.woff2 +0 -0
  82. package/dist/fonts/families/work-sans/work-sans-latin-ext-wght-normal.woff2 +0 -0
  83. package/dist/fonts/families/work-sans/work-sans-latin-wght-italic.woff2 +0 -0
  84. package/dist/fonts/families/work-sans/work-sans-latin-wght-normal.woff2 +0 -0
  85. package/dist/fonts/google.json +1 -0
  86. package/dist/fonts-google.d.ts +35 -0
  87. package/dist/fonts-google.js +70 -0
  88. package/dist/fonts.css +365 -0
  89. package/dist/frame.test.d.ts +1 -0
  90. package/dist/frame.test.js +58 -0
  91. package/dist/migrate.d.ts +45 -0
  92. package/dist/migrate.js +284 -0
  93. package/dist/next/block-mark.d.ts +7 -0
  94. package/dist/next/block-mark.js +16 -0
  95. package/dist/next/blocks.d.ts +11 -0
  96. package/dist/next/blocks.js +35 -0
  97. package/dist/next/built.d.ts +40 -0
  98. package/dist/next/built.js +318 -0
  99. package/dist/next/consent.d.ts +4 -0
  100. package/dist/next/consent.js +13 -0
  101. package/dist/next/data.d.ts +6 -2
  102. package/dist/next/data.js +10 -4
  103. package/dist/next/frame.d.ts +42 -0
  104. package/dist/next/frame.js +73 -0
  105. package/dist/next/index.d.ts +8 -2
  106. package/dist/next/index.js +9 -3
  107. package/dist/next/menu-group.d.ts +11 -0
  108. package/dist/next/menu-group.js +33 -0
  109. package/dist/next/metadata.js +3 -2
  110. package/dist/next/parts/Img.d.ts +6 -1
  111. package/dist/next/parts/Img.js +11 -3
  112. package/dist/next/parts/LookStyle.js +3 -1
  113. package/dist/next/preview.js +511 -27
  114. package/dist/next/sections.d.ts +19 -10
  115. package/dist/next/sections.js +134 -17
  116. package/dist/policies.js +22 -4
  117. package/dist/prepare.d.ts +15 -1
  118. package/dist/prepare.js +125 -3
  119. package/dist/preview-match.d.ts +29 -0
  120. package/dist/preview-match.js +58 -1
  121. package/dist/schema.d.ts +10 -0
  122. package/dist/schema.js +29 -1
  123. package/dist/sections/icons.d.ts +49 -0
  124. package/dist/sections/icons.js +55 -0
  125. package/dist/sections/index.d.ts +188 -12
  126. package/dist/sections/index.js +136 -10
  127. package/dist/sections/presets.d.ts +13 -0
  128. package/dist/sections/presets.js +300 -0
  129. package/dist/sections.css +1584 -41
  130. package/dist/sections.test.js +276 -14
  131. package/dist/seo.js +2 -1
  132. package/dist/tree/behaviors.d.ts +50 -0
  133. package/dist/tree/behaviors.js +67 -0
  134. package/dist/tree/classes.d.ts +24 -0
  135. package/dist/tree/classes.js +94 -0
  136. package/dist/tree/index.d.ts +145 -0
  137. package/dist/tree/index.js +196 -0
  138. package/dist/tree/messages.d.ts +45 -0
  139. package/dist/tree/messages.js +7 -0
  140. package/package.json +22 -3
@@ -1,11 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { describe, expect, it } from "bun:test";
3
3
  import { renderToStaticMarkup } from "react-dom/server";
4
- import { defineSite, z } from "./content/index.js";
4
+ import { defineSite, lookVars, z } from "./content/index.js";
5
+ import { KitBlocks } from "./next/blocks.js";
6
+ import { InPreview } from "./next/block-mark.js";
5
7
  import { setPrepared } from "./next/data.js";
6
8
  import { KitSection } from "./next/sections.js";
7
9
  import { Slideshow } from "./next/slideshow.js";
8
- import { gallery, isKitSection, sections, slideshow } from "./sections/index.js";
10
+ import { gallery, ICON_NAMES, isKitSection, own as ownSection, presets, sections, slideshow, videoEmbed } from "./sections/index.js";
11
+ /** A site's own styles for each thing kit sections draw, given in place of the kit's. */
12
+ const theme = { section: "s", wrap: "w", headingLarge: "h-l", headingMedium: "h-m", headingSmall: "h-s", text: "t", muted: "m", button: "b", buttonQuiet: "bq" };
9
13
  // Sections any site can use: their fields, as a site takes them into its own, and drawn.
10
14
  const own = { hero: z.object({ heading: z.string() }) };
11
15
  describe("the kit's sections", () => {
@@ -36,9 +40,9 @@ describe("a gallery, drawn", () => {
36
40
  csp: {},
37
41
  });
38
42
  it("is a grid of its photos, each opening its largest copy, with its heading, intro and captions", () => {
39
- const html = renderToStaticMarkup(_jsx(KitSection, { s: { type: "gallery", id: "work", heading: "Our decks", intro: "Built this **summer**.", columns: "4", shape: "landscape", images: [{ src: "a.jpg", alt: "A cedar deck", caption: "Cedar, 2026" }, { src: "b.jpg", alt: "A composite deck" }] }, level: 2 }));
40
- expect(html).toContain('<section class="kit-section kit-gallery" id="work" aria-labelledby="work-heading">');
41
- expect(html).toContain('<h2 id="work-heading" class="kit-heading">Our decks</h2>');
43
+ const html = renderToStaticMarkup(_jsx(KitSection, { s: { type: "gallery", id: "work", heading: "Our decks", intro: "Built this **summer**.", columns: "4", shape: "landscape", images: [{ src: "a.jpg", alt: "A cedar deck", caption: "Cedar, 2026" }, { src: "b.jpg", alt: "A composite deck" }] }, level: 2, classes: theme }));
44
+ expect(html).toContain('<section class="s kit-gallery" id="work" aria-labelledby="work-heading">');
45
+ expect(html).toContain('<h2 id="work-heading" class="h-l">Our decks</h2>');
42
46
  expect(html).toContain("<strong>summer</strong>");
43
47
  expect(html).toContain('class="kit-gallery-grid kit-cols-4"');
44
48
  expect(html).toContain('<a href="/_img/a.1600.webp" class="kit-gallery-open">');
@@ -51,14 +55,14 @@ describe("a gallery, drawn", () => {
51
55
  expect(html).not.toMatch(/\sstyle=/);
52
56
  });
53
57
  it("sits in a site with a design of its own: its section, container and heading classes in place of the kit's", () => {
54
- const html = renderToStaticMarkup(_jsx(KitSection, { s: { type: "gallery", heading: "Inside", images: [{ src: "a.jpg", alt: "A cabin" }] }, classes: { section: "", wrap: "mx-auto max-w-5xl px-4", heading: "font-display text-3xl" } }));
58
+ const html = renderToStaticMarkup(_jsx(KitSection, { s: { type: "gallery", heading: "Inside", images: [{ src: "a.jpg", alt: "A cabin" }] }, classes: { ...theme, section: "", wrap: "mx-auto max-w-5xl px-4", headingLarge: "font-display text-3xl" } }));
55
59
  expect(html).toContain('<section class="kit-gallery"');
56
60
  expect(html).toContain('<div class="mx-auto max-w-5xl px-4">');
57
61
  expect(html).toContain('class="font-display text-3xl">Inside</h2>');
58
62
  expect(html).not.toContain("kit-wrap");
59
63
  });
60
64
  it("draws nothing for a type that isn't the kit's: the site's own to draw", () => {
61
- expect(renderToStaticMarkup(_jsx(KitSection, { s: { type: "hero", heading: "Hi" } }))).toBe("");
65
+ expect(renderToStaticMarkup(_jsx(KitSection, { s: { type: "hero", heading: "Hi" }, classes: theme }))).toBe("");
62
66
  });
63
67
  });
64
68
  describe("a slideshow", () => {
@@ -86,7 +90,7 @@ describe("a slideshow", () => {
86
90
  expect(ok.site.pages[0].blocks[0]).toMatchObject({ seconds: 5, shape: "wide" });
87
91
  expect(site.parse(page([{ type: "slideshow", images: [{ src: "a.jpg", alt: "A" }] }])).problems?.join(" ")).toMatch(/images/);
88
92
  expect(slideshow.meta()?.title).toBe("Slideshow");
89
- const html = renderToStaticMarkup(_jsx(KitSection, { s: { type: "slideshow", heading: "On board", shape: "landscape", seconds: 4, images: [{ src: "a.jpg", alt: "A cabin", caption: "Cabin" }, { src: "b.jpg", alt: "The deck" }] } }));
93
+ const html = renderToStaticMarkup(_jsx(KitSection, { s: { type: "slideshow", heading: "On board", shape: "landscape", seconds: 4, images: [{ src: "a.jpg", alt: "A cabin", caption: "Cabin" }, { src: "b.jpg", alt: "The deck" }] }, classes: theme }));
90
94
  expect(html).toContain('class="kit-slides kit-shape-landscape"');
91
95
  expect(html).toContain("<figcaption>Cabin</figcaption>");
92
96
  expect(html).toContain('aria-label="On board"');
@@ -114,21 +118,279 @@ describe("a section of the business's own", () => {
114
118
  // Saved sections are optional: a site without any is as it was.
115
119
  expect(site.parse(page([block])).site.saved).toEqual([]);
116
120
  });
121
+ it("draws lines across a section's full width where its Layout says, strong in the text colour", async () => {
122
+ const { KitBlocks } = await import("./next/blocks.js");
123
+ const strip = { type: "own", columns: [{ parts: [{ type: "text", text: "Where we work" }] }], placement: { lines: "both", lineColour: "strong" } };
124
+ const html = renderToStaticMarkup(_jsx(KitBlocks, { blocks: [strip, { ...strip, placement: {} }], render: () => null }));
125
+ expect(html).toContain('<div class="kit-lines kit-lines-both kit-lines-strong">');
126
+ expect(html.match(/class="kit-lines /g)?.length).toBe(1);
127
+ });
128
+ it("draws a heading at the Look's largest sizes, a part in the other font, and a column's parts at its foot", () => {
129
+ const own = { type: "own", layout: "two", columns: [{ place: "bottom", parts: [{ type: "heading", text: "The good outside.", size: "display" }, { type: "text", text: "Yard care.", font: "heading" }] }, { parts: [{ type: "items", font: "text", items: [{ title: "Yard care" }] }] }] };
130
+ const html = renderToStaticMarkup(_jsx(KitSection, { s: own, level: 2, classes: theme }));
131
+ expect(html).toContain('<h2 class="h-l kit-type-display">The good outside.</h2>');
132
+ expect(html).toContain('class="kit-own-column kit-own-column-bottom"');
133
+ expect(html).toContain("kit-own-text t kit-font-heading");
134
+ expect(html).toContain('class="kit-own-items-title h-m kit-font-text"');
135
+ });
117
136
  it("is drawn in the site's own classes: its heading, words and buttons", () => {
118
- const html = renderToStaticMarkup(_jsx(KitSection, { s: block, level: 2, classes: { section: "", wrap: "site-wrap", heading: "site-h", text: "site-text", button: "site-button" } }));
137
+ const html = renderToStaticMarkup(_jsx(KitSection, { s: block, level: 2, classes: { ...theme, section: "", wrap: "site-wrap", headingLarge: "site-h", text: "site-text", button: "site-button", muted: "site-muted" } }));
119
138
  expect(html).toContain('class="kit-own-grid kit-own-two kit-own-align-left"');
120
- expect(html).toContain('<h2 class="site-h kit-own-h-large">Our packages</h2>');
139
+ expect(html).toContain('<h2 class="site-h">Our packages</h2>');
121
140
  expect(html).toContain("<strong>the one</strong>");
122
141
  expect(html).toContain('class="kit-own-list kit-own-list-checks site-text"');
123
142
  expect(html).toContain('<a href="/" class="site-button">Book</a>');
124
- expect(html).toContain('<span class="kit-own-amount">$120</span>');
125
- expect(html).toContain("<dd>8</dd>");
143
+ expect(html).toContain('<span class="kit-own-amount h-m">$120</span>');
144
+ expect(html).toContain('<dd class="h-m">8</dd>');
126
145
  expect(html).toContain("<blockquote class=\"site-text\">Best week ever.</blockquote>");
127
146
  expect(html).not.toMatch(/\sstyle=/);
128
147
  });
129
- it("keeps to its layout's columns, and draws the kit's own button where the site gave none", () => {
130
- const html = renderToStaticMarkup(_jsx(KitSection, { s: { ...block, layout: "one" } }));
148
+ it("keeps to its layout's columns, every button in the site's own button style", () => {
149
+ const html = renderToStaticMarkup(_jsx(KitSection, { s: { ...block, layout: "one" }, classes: theme }));
131
150
  expect(html.match(/kit-own-column/g)).toHaveLength(1);
151
+ expect(html).toContain('class="b"');
152
+ expect(html).not.toMatch(/kit-button|kit-heading/);
153
+ });
154
+ });
155
+ describe("a section of the business's own, its background and spacing", () => {
156
+ const one = (extra, parts = [{ type: "heading", text: "Hello" }, { type: "button", label: "Book", href: "/" }]) => ({ type: "own", columns: [{ parts }], ...extra });
157
+ it("is plain with the site's own spacing unless it says, and content from before still reads", () => {
158
+ const parsed = ownSection.parse({ columns: [{ parts: [{ type: "text", text: "Hi" }] }], tone: "brand" });
159
+ expect(parsed).toMatchObject({ layout: "one", align: "left", tone: "brand", spacing: "normal" });
160
+ expect(parsed.background).toBeUndefined();
161
+ expect(ownSection.safeParse({ columns: [{ parts: [] }], tone: "purple" }).success).toBe(false);
162
+ expect(ownSection.safeParse({ columns: [{ parts: [] }], spacing: "huge" }).success).toBe(false);
163
+ });
164
+ it("draws its tone and spacing as classes, words and buttons kept readable on brand, dark or a photo", () => {
165
+ const plain = renderToStaticMarkup(_jsx(KitSection, { s: one({}), classes: theme }));
166
+ expect(plain).toContain('class="s kit-own kit-own-plain kit-own-pad-normal"');
167
+ const soft = renderToStaticMarkup(_jsx(KitSection, { s: one({ tone: "soft", spacing: "tight" }), classes: theme }));
168
+ expect(soft).toContain('class="s kit-own kit-own-soft kit-own-pad-tight kit-own-shape-card"');
169
+ for (const tone of ["brand", "dark"]) {
170
+ const html = renderToStaticMarkup(_jsx(KitSection, { s: one({ tone, spacing: "roomy" }), classes: { ...theme, button: "site-button" } }));
171
+ expect(html).toContain(`class="s kit-own kit-own-${tone} kit-own-pad-roomy kit-own-shape-card kit-own-inverse"`);
172
+ expect(html).toContain('<p class="kit-own-action"><a href="/" class="site-button">Book</a></p>');
173
+ }
174
+ expect(plain).not.toMatch(/\sstyle=/);
175
+ });
176
+ it("draws a photo behind it, under a scrim, its words light over it", () => {
177
+ const html = renderToStaticMarkup(_jsx(KitSection, { s: one({ background: { src: "a.jpg", alt: "" } }), classes: theme }));
178
+ expect(html).toContain("kit-own-behind kit-own-inverse");
179
+ expect(html).toMatch(/<div class="kit-own-bg" aria-hidden="true"><img [^>]*alt=""/);
180
+ expect(html).not.toMatch(/\sstyle=/);
181
+ });
182
+ it("keeps buttons one after another together, side by side", () => {
183
+ const html = renderToStaticMarkup(_jsx(KitSection, { s: one({}, [{ type: "button", label: "Call", href: "tel:+15550100000" }, { type: "button", label: "Write", href: "mailto:a@b.co", style: "quiet" }, { type: "text", text: "Or visit." }]), classes: theme }));
184
+ expect(html).toContain('<div class="kit-own-actions"><p class="kit-own-action"><a href="tel:+15550100000" class="b">Call</a></p><p class="kit-own-action kit-own-action-quiet"><a href="mailto:a@b.co" class="bq">Write</a></p></div>');
185
+ });
186
+ it("shows where a photo still to choose will go, in the preview", () => {
187
+ expect(ownSection.safeParse({ columns: [{ parts: [{ type: "photo" }] }] }).success).toBe(true);
188
+ const html = renderToStaticMarkup(_jsx(KitSection, { s: one({}, [{ type: "photo", shape: "square" }]), classes: theme }));
189
+ expect(html).toContain('<div class="kit-own-photo-empty kit-own-shape-square" aria-hidden="true"></div>');
190
+ });
191
+ });
192
+ describe("ready-made sections", () => {
193
+ it("are each a valid section of the business's own, with a key, a name and a description", () => {
194
+ expect(presets.length).toBeGreaterThanOrEqual(9);
195
+ expect(new Set(presets.map((p) => p.key)).size).toBe(presets.length);
196
+ const site = defineSite({ ...sections });
197
+ for (const p of presets) {
198
+ expect(p.block.type).toBe("own");
199
+ const r = ownSection.safeParse(p.block);
200
+ if (!r.success)
201
+ throw new Error(`${p.key}: ${r.error.message}`);
202
+ const parsed = site.parse({ url: "https://acme.example", business: { name: "Acme" }, pages: [{ path: "/", title: "Acme", description: "Acme does things.", blocks: [p.block] }] });
203
+ expect(parsed.problems).toBeUndefined();
204
+ expect(p.name.length && p.description.length).toBeTruthy();
205
+ }
206
+ });
207
+ it("read as plain business words: sentence case, no exclamation marks, no placeholder Latin", () => {
208
+ const words = JSON.stringify(presets);
209
+ expect(words).not.toMatch(/!/);
210
+ expect(words.toLowerCase()).not.toMatch(/lorem|ipsum/);
211
+ for (const p of presets)
212
+ expect(p.name[0]).toBe(p.name[0].toUpperCase());
213
+ });
214
+ it("draw", () => {
215
+ for (const p of presets)
216
+ expect(renderToStaticMarkup(_jsx(KitSection, { s: p.block, classes: theme }))).toContain("kit-own");
217
+ });
218
+ });
219
+ describe("kit sections with no classes of the site's", () => {
220
+ const block = { type: "own", columns: [{ parts: [{ type: "heading", text: "Hello", size: "large" }, { type: "text", text: "Words." }, { type: "price", amount: "Price", per: "a visit" }, { type: "button", label: "Book", href: "/" }, { type: "button", label: "Call", href: "/", style: "quiet" }] }] };
221
+ it("are drawn in the kit's classes, which read the Look", () => {
222
+ const html = renderToStaticMarkup(_jsx(KitSection, { s: block }));
223
+ expect(html).toContain('<section class="kit-section kit-own kit-own-plain kit-own-pad-normal"><div class="kit-wrap">');
224
+ expect(html).toContain('<h2 class="kit-h kit-h-large">Hello</h2>');
225
+ expect(html).toContain('kit-own-text kit-text"');
226
+ expect(html).toContain('<span class="kit-own-per kit-muted"> a visit</span>');
227
+ expect(html).toContain('<a href="/" class="kit-button">Book</a>');
228
+ expect(html).toContain('<a href="/" class="kit-button kit-button-quiet">Call</a>');
229
+ });
230
+ it("take any of the site's own in place of the kit's", () => {
231
+ const html = renderToStaticMarkup(_jsx(KitSection, { s: block, classes: { headingLarge: "display", section: "" } }));
232
+ expect(html).toContain('<h2 class="display">Hello</h2>');
233
+ expect(html).toContain('<section class="kit-own');
132
234
  expect(html).toContain('class="kit-button"');
133
235
  });
134
236
  });
237
+ describe("a page's blocks, drawn by KitBlocks", () => {
238
+ const look = { brand: "#2b5f8a", palette: [{ name: "Sand", color: "#e8dcc4" }] };
239
+ const prepare = () => setPrepared({
240
+ site: { url: "https://acme.example", lang: "en", business: { name: "Acme" }, redirects: [], pages: [] },
241
+ images: { "a.jpg": { width: 1600, height: 1000, src: "/_img/a.1600.jpg", srcset: [] } },
242
+ csp: {},
243
+ look: lookVars(undefined, look),
244
+ });
245
+ const blocks = [
246
+ { type: "hero", heading: "Hi" },
247
+ { type: "own", columns: [{ parts: [{ type: "heading", text: "Ours" }] }], placement: { background: { color: "sand" } } },
248
+ { type: "hero", heading: "Over a photo", placement: { background: { photo: { src: "a.jpg", alt: "A deck" }, overlay: "dark" } } },
249
+ { type: "hero", heading: "Plain", placement: { align: "center" } },
250
+ ];
251
+ const render = (b) => (b.type === "hero" ? _jsx("section", { className: "hero", children: String(b.heading) }) : null);
252
+ it("draws the site's own blocks, the kit's for the rest, and a frame only where there's a background", () => {
253
+ prepare();
254
+ const html = renderToStaticMarkup(_jsx(KitBlocks, { blocks: blocks, render: render }));
255
+ expect(html).toContain('<section class="hero">Hi</section>');
256
+ expect(html).toContain('<div class="kit-frame kit-bg-swatch-1"><section class="kit-section kit-own');
257
+ expect(html).toMatch(/<div class="kit-frame kit-frame-photo kit-overlay-dark kit-frame-dark"><div class="kit-frame-bg" aria-hidden="true"><img [^>]*alt=""[^>]*><\/div><section class="hero">Over a photo<\/section><\/div>/);
258
+ expect(html.match(/kit-frame"|kit-frame /g)).toHaveLength(2);
259
+ expect(html).toContain('<section class="hero">Plain</section>');
260
+ // No marks on the live site, and no style attribute anywhere (the content security policy blocks them).
261
+ expect(html).not.toContain("<template");
262
+ expect(html).not.toMatch(/\sstyle=/);
263
+ });
264
+ it("marks where each block starts in the preview, and gives each its heading level", () => {
265
+ prepare();
266
+ const html = renderToStaticMarkup(_jsx(InPreview.Provider, { value: true, children: _jsx(KitBlocks, { blocks: blocks.slice(0, 2), render: render, level: (i) => (i === 1 ? 1 : 2) }) }));
267
+ expect(html).toMatch(/^<template data-calos-block="0"><\/template><section class="hero">Hi<\/section><template data-calos-block="1"><\/template><div class="kit-frame/);
268
+ expect(html).toContain('<h2 class="kit-h kit-h-medium">Ours</h2>');
269
+ });
270
+ it("knows a background is dark from the Look as it is, so a light brand keeps dark words", () => {
271
+ setPrepared({ site: { pages: [] }, images: {}, csp: {}, look: lookVars(undefined, { brand: "#ffd400" }) });
272
+ const html = renderToStaticMarkup(_jsx(KitBlocks, { blocks: [{ type: "own", columns: [{ parts: [] }], placement: { background: { color: "brand" } } }], render: () => null }));
273
+ expect(html).toContain('<div class="kit-frame kit-bg-brand">');
274
+ });
275
+ });
276
+ describe("the schema a site writes", () => {
277
+ it("carries the ready-made sections the site's own kind takes, at its root", () => {
278
+ const withOwn = defineSite({ ...sections, ...own }).jsonSchema();
279
+ expect(withOwn["x-presets"].map((p) => p.key)).toEqual(presets.map((p) => p.key));
280
+ expect(withOwn["x-presets"][0]).toMatchObject({ key: "hero", name: "Opening", block: { type: "own" } });
281
+ expect(defineSite(own).jsonSchema()["x-presets"]).toBeUndefined();
282
+ // Prices and numbers are never made up.
283
+ const words = JSON.stringify(withOwn["x-presets"]);
284
+ expect(words).not.toMatch(/"amount":"[^"]*\d/);
285
+ expect(words).not.toMatch(/"value":"[^"]*\d/);
286
+ });
287
+ });
288
+ describe("a section of the business's own: tables, icons, logos and videos", () => {
289
+ const one = (parts, extra = {}) => ({ type: "own", columns: [{ parts }], ...extra });
290
+ const ok = (parts, extra = {}) => ownSection.safeParse(one(parts, extra));
291
+ const problems = (parts) => {
292
+ const r = ok(parts);
293
+ return r.success ? "" : r.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" | ");
294
+ };
295
+ const table = { type: "table", columns: ["", "Basic", "Plus"], rows: [{ label: "Price", cells: ["Price", "Price"] }, { label: "Visits", cells: ["yes", "No"] }], highlight: 2 };
296
+ it("a table: two to five columns, a cell for each after the first, a column it picks out that's there", () => {
297
+ expect(ok([table]).success).toBe(true);
298
+ expect(ok([{ ...table, highlight: undefined }]).success).toBe(true);
299
+ expect(problems([{ ...table, columns: ["Only"] }])).toMatch(/columns/);
300
+ expect(problems([{ ...table, columns: ["", "a", "b", "c", "d", "e"] }])).toMatch(/columns/);
301
+ expect(problems([{ ...table, rows: [] }])).toMatch(/rows/);
302
+ expect(problems([{ ...table, rows: Array.from({ length: 21 }, () => ({ label: "Row", cells: ["a", "b"] })) }])).toMatch(/rows/);
303
+ expect(problems([{ ...table, rows: [{ label: "Price", cells: ["Price"] }] }])).toMatch(/rows.0.cells: One cell for each of the 2 columns/);
304
+ expect(problems([{ ...table, highlight: 3 }])).toMatch(/highlight/);
305
+ expect(problems([{ ...table, highlight: 0 }])).toMatch(/highlight/);
306
+ expect(problems([{ ...table, columns: ["", "Basic", ""] }])).toMatch(/columns.2: A column needs a heading/);
307
+ });
308
+ it("a table, drawn: a real table on a wider screen and a card for each row on a phone, ticks and dashes read as yes and no", () => {
309
+ const html = renderToStaticMarkup(_jsx(KitSection, { s: one([table], { heading: "Compare" }) }));
310
+ expect(html).toContain('<div class="kit-table-wide"><table class="kit-table kit-text"><thead><tr><td></td><th scope="col">Basic</th><th scope="col" class="kit-table-highlight">Plus</th></tr></thead>');
311
+ expect(html).toContain('<tr><th scope="row">Price</th><td>Price</td><td class="kit-table-highlight">Price</td></tr>');
312
+ expect(html).toMatch(/<td><span class="kit-table-yes"><svg class="kit-icon"[^>]*aria-hidden="true"[^>]*>.*?<\/svg><span class="kit-sr">Yes<\/span><\/span><\/td>/);
313
+ expect(html).toContain('<span class="kit-sr">No</span>');
314
+ // On a phone: each row a card, its label its heading (under the section's), each column's heading beside its cell.
315
+ expect(html).toContain('<ul class="kit-table-cards"><li class="kit-table-card"><h3 class="kit-h kit-h-small">Price</h3><dl class="kit-text"><div><dt>Basic</dt><dd>Price</dd></div><div class="kit-table-highlight"><dt>Plus</dt><dd>Price</dd></div></dl></li>');
316
+ expect(html).not.toMatch(/\sstyle=/);
317
+ });
318
+ it("an icon: one of the kit's, drawn inline in the theme's colour, never fetched", () => {
319
+ expect(ICON_NAMES.length).toBeGreaterThanOrEqual(40);
320
+ for (const name of ["check", "calendar", "map-pin", "arrow-right", "invoice", "warning", "grid"])
321
+ expect(ICON_NAMES).toContain(name);
322
+ expect(ICON_NAMES.some((n) => /sparkle/.test(n))).toBe(false);
323
+ expect(ok([{ type: "icon", name: "calendar" }]).success).toBe(true);
324
+ expect(problems([{ type: "icon", name: "sparkle" }])).toMatch(/name/);
325
+ expect(ownSection.parse(one([{ type: "icon", name: "clock" }])).columns[0].parts[0]).toMatchObject({ size: "medium" });
326
+ const html = renderToStaticMarkup(_jsx(KitSection, { s: one([{ type: "icon", name: "clock", size: "large" }]) }));
327
+ expect(html).toMatch(/<svg data-icon="clock" class="kit-icon kit-own-icon kit-own-icon-large" viewBox="0 0 24 24" fill="none" stroke="currentColor"[^>]*aria-hidden="true"[^>]*><path d="M12 22a10/);
328
+ expect(html).not.toMatch(/<img|<use|href=/);
329
+ });
330
+ it("logos: two to twelve, in a row with a line above if it has one", () => {
331
+ const logos = { type: "logos", title: "Who we work with", images: [{ src: "a.png", alt: "Acme" }, { src: "b.png", alt: "Bolt" }] };
332
+ expect(ok([logos]).success).toBe(true);
333
+ expect(problems([{ ...logos, images: [logos.images[0]] }])).toMatch(/images/);
334
+ expect(problems([{ ...logos, images: Array.from({ length: 13 }, () => logos.images[0]) }])).toMatch(/images/);
335
+ setPrepared({ site: { pages: [] }, images: { "a.png": { width: 200, height: 80, src: "/_img/a.png", srcset: [] }, "b.png": { width: 200, height: 80, src: "/_img/b.png", srcset: [] } }, csp: {} });
336
+ const html = renderToStaticMarkup(_jsx(KitSection, { s: one([logos]) }));
337
+ expect(html).toContain('<div class="kit-own-logos"><p class="kit-own-logos-title kit-muted">Who we work with</p><ul><li><img src="/_img/a.png"');
338
+ expect(html).toContain('alt="Bolt"');
339
+ });
340
+ it("a video: YouTube or Vimeo only, shown in the privacy-friendly player with its title", () => {
341
+ expect(videoEmbed("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=3")).toEqual({ src: "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ", origin: "https://www.youtube-nocookie.com" });
342
+ expect(videoEmbed("https://youtu.be/dQw4w9WgXcQ?si=x").src).toBe("https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ");
343
+ expect(videoEmbed("https://www.youtube.com/shorts/dQw4w9WgXcQ").src).toBe("https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ");
344
+ expect(videoEmbed("https://vimeo.com/76979871")).toEqual({ src: "https://player.vimeo.com/video/76979871?dnt=1", origin: "https://player.vimeo.com" });
345
+ expect(videoEmbed("https://vimeo.com/76979871/abc123").src).toBe("https://player.vimeo.com/video/76979871?h=abc123&dnt=1");
346
+ for (const bad of ["https://example.com/watch?v=dQw4w9WgXcQ", "http://youtu.be/dQw4w9WgXcQ", "https://youtu.be/short", "https://vimeo.com/channels/staff"]) {
347
+ expect(videoEmbed(bad)).toBeNull();
348
+ expect(problems([{ type: "video", url: bad, title: "A tour" }])).toMatch(/url: A YouTube or Vimeo address/);
349
+ }
350
+ expect(ok([{ type: "video", url: "https://youtu.be/dQw4w9WgXcQ", title: "A tour" }]).success).toBe(true);
351
+ expect(problems([{ type: "video", url: "https://youtu.be/dQw4w9WgXcQ" }])).toMatch(/title/);
352
+ const html = renderToStaticMarkup(_jsx(KitSection, { s: one([{ type: "video", url: "https://youtu.be/dQw4w9WgXcQ", title: "A tour of the workshop" }]) }));
353
+ expect(html).toContain('<div class="kit-own-video"><iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ" title="A tour of the workshop"');
354
+ expect(html).toContain('loading="lazy"');
355
+ // No address yet: a quiet box where it will go in the preview (the store holds a draft here), nothing on the site.
356
+ expect(renderToStaticMarkup(_jsx(KitSection, { s: one([{ type: "video", title: "A tour" }]) }))).toContain('<div class="kit-own-video kit-own-video-empty" aria-hidden="true"></div>');
357
+ });
358
+ it("columns drawn as cards, a heading and intro over them, and a toned section a card or a band", () => {
359
+ const block = {
360
+ type: "own",
361
+ id: "why",
362
+ layout: "two",
363
+ align: "center",
364
+ heading: "Why us",
365
+ intro: "Two **good** reasons.",
366
+ columns: [
367
+ { card: true, parts: [{ type: "heading", text: "One", size: "large" }] },
368
+ { parts: [{ type: "text", text: "Two" }] },
369
+ ],
370
+ };
371
+ expect(ownSection.safeParse(block).success).toBe(true);
372
+ expect(ownSection.parse(block).shape).toBe("card");
373
+ expect(ownSection.safeParse({ ...block, shape: "circle" }).success).toBe(false);
374
+ const html = renderToStaticMarkup(_jsx(KitSection, { s: block, level: 2 }));
375
+ expect(html).toContain('<section class="kit-section kit-own kit-own-plain kit-own-pad-normal" id="why" aria-labelledby="why-heading"><div class="kit-wrap"><div class="kit-own-panel"><div class="kit-own-head kit-own-align-center"><h2 id="why-heading" class="kit-h kit-h-large">Why us</h2><div class="prose kit-own-intro kit-text"><p>Two <strong>good</strong> reasons.</p>');
376
+ // Under the section's heading, a part's large heading is a level down.
377
+ expect(html).toContain('<div class="kit-own-column kit-own-card"><h3 class="kit-h kit-h-large">One</h3>');
378
+ expect(html).toContain('<div class="kit-own-column"><div class="prose kit-own-text kit-text">');
379
+ // Plain has no shape; a toned one is a card unless it's a band; a photo behind is always across the page.
380
+ expect(html).not.toContain("kit-own-shape");
381
+ expect(renderToStaticMarkup(_jsx(KitSection, { s: { ...block, tone: "brand" } }))).toContain("kit-own-brand kit-own-pad-normal kit-own-shape-card kit-own-inverse");
382
+ expect(renderToStaticMarkup(_jsx(KitSection, { s: { ...block, tone: "soft", shape: "band" } }))).toContain("kit-own-soft kit-own-pad-normal kit-own-shape-band");
383
+ setPrepared({ site: { pages: [] }, images: { "a.jpg": { width: 1600, height: 1000, src: "/_img/a.jpg", srcset: [] } }, csp: {} });
384
+ expect(renderToStaticMarkup(_jsx(KitSection, { s: { ...block, tone: "dark", background: { src: "a.jpg", alt: "" } } }))).not.toContain("kit-own-shape");
385
+ });
386
+ it("the ready-made comparison, features with icons, logo strip and video", () => {
387
+ const keys = presets.map((p) => p.key);
388
+ for (const k of ["comparison", "features-icons", "logo-strip", "video"])
389
+ expect(keys).toContain(k);
390
+ const comparison = presets.find((p) => p.key === "comparison").block;
391
+ expect(comparison.heading).toBeTruthy();
392
+ expect(comparison.columns[0].parts[0]).toMatchObject({ type: "table", highlight: 2 });
393
+ const features = presets.find((p) => p.key === "features-icons").block;
394
+ expect(features.columns.map((c) => [c.card, c.parts.map((p) => p.type)])).toEqual(Array(3).fill([true, ["icon", "heading", "text"]]));
395
+ });
396
+ });
package/dist/seo.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // What search engines and browsers read about a site besides its words: the structured data
2
2
  // (schema.org) describing the business, robots.txt, and the files Cloudflare Pages reads for
3
3
  // response headers and redirects. Everything here is worked out from the site's content.
4
+ import { allPages } from "./content/lists.js";
4
5
  import { clock } from "./content/index.js";
5
6
  /** The page's address as search engines should know it. */
6
7
  export function canonical(site, path) {
@@ -96,7 +97,7 @@ export function robotsTxt(site) {
96
97
  }
97
98
  /** Every page search engines should list, at its canonical address. */
98
99
  export function sitemapXml(site) {
99
- const urls = site.pages.filter((p) => !p.noindex).map((p) => ` <url><loc>${canonical(site, p.path)}</loc></url>`);
100
+ const urls = allPages(site).filter((p) => !p.noindex).map((p) => ` <url><loc>${canonical(site, p.path)}</loc></url>`);
100
101
  return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join("\n")}\n</urlset>\n`;
101
102
  }
102
103
  /** Where CalOS's preview draws a draft (./next/preview.tsx): framed by CalOS, never indexed. */
@@ -0,0 +1,50 @@
1
+ import { z } from "zod";
2
+ export declare const BEHAVIORS: readonly ["carousel", "tabs", "accordion", "marquee", "reveal", "count-up", "sticky", "filter"];
3
+ export type BehaviorName = (typeof BEHAVIORS)[number];
4
+ /**
5
+ * The settings of each. `slides` and the like name which children are the moving parts: by default a
6
+ * carousel's slides are its node's children; `controls` lets a carousel's dots and arrows be drawn
7
+ * from its own design (nodes marked with a `slot`: "prev", "next", "dots").
8
+ */
9
+ export declare const behavior: z.ZodDiscriminatedUnion<[z.ZodObject<{
10
+ name: z.ZodLiteral<"carousel">;
11
+ seconds: z.ZodOptional<z.ZodNumber>;
12
+ transition: z.ZodDefault<z.ZodEnum<{
13
+ fade: "fade";
14
+ slide: "slide";
15
+ }>>;
16
+ pauseOnHover: z.ZodDefault<z.ZodBoolean>;
17
+ dots: z.ZodDefault<z.ZodBoolean>;
18
+ arrows: z.ZodDefault<z.ZodBoolean>;
19
+ }, z.core.$strip>, z.ZodObject<{
20
+ name: z.ZodLiteral<"tabs">;
21
+ first: z.ZodDefault<z.ZodNumber>;
22
+ }, z.core.$strip>, z.ZodObject<{
23
+ name: z.ZodLiteral<"accordion">;
24
+ single: z.ZodDefault<z.ZodBoolean>;
25
+ open: z.ZodOptional<z.ZodNumber>;
26
+ }, z.core.$strip>, z.ZodObject<{
27
+ name: z.ZodLiteral<"marquee">;
28
+ seconds: z.ZodDefault<z.ZodNumber>;
29
+ reverse: z.ZodDefault<z.ZodBoolean>;
30
+ }, z.core.$strip>, z.ZodObject<{
31
+ name: z.ZodLiteral<"reveal">;
32
+ effect: z.ZodDefault<z.ZodEnum<{
33
+ fade: "fade";
34
+ up: "up";
35
+ left: "left";
36
+ right: "right";
37
+ zoom: "zoom";
38
+ }>>;
39
+ stagger: z.ZodDefault<z.ZodNumber>;
40
+ }, z.core.$strip>, z.ZodObject<{
41
+ name: z.ZodLiteral<"count-up">;
42
+ seconds: z.ZodDefault<z.ZodNumber>;
43
+ }, z.core.$strip>, z.ZodObject<{
44
+ name: z.ZodLiteral<"filter">;
45
+ field: z.ZodString;
46
+ }, z.core.$strip>, z.ZodObject<{
47
+ name: z.ZodLiteral<"sticky">;
48
+ top: z.ZodDefault<z.ZodNumber>;
49
+ }, z.core.$strip>], "name">;
50
+ export type Behavior = z.infer<typeof behavior>;
@@ -0,0 +1,67 @@
1
+ // What a built section's elements can do beyond being drawn: the kit's own small scripts, chosen by
2
+ // name with settings, never written by a business. Visitors get static HTML; a behavior adds its
3
+ // script only on a page that uses it, and does nothing harmful where scripting is off (the first
4
+ // slide shown, every accordion item readable).
5
+ import { z } from "zod";
6
+ export const BEHAVIORS = ["carousel", "tabs", "accordion", "marquee", "reveal", "count-up", "sticky", "filter"];
7
+ /**
8
+ * The settings of each. `slides` and the like name which children are the moving parts: by default a
9
+ * carousel's slides are its node's children; `controls` lets a carousel's dots and arrows be drawn
10
+ * from its own design (nodes marked with a `slot`: "prev", "next", "dots").
11
+ */
12
+ export const behavior = z.discriminatedUnion("name", [
13
+ z.object({
14
+ name: z.literal("carousel"),
15
+ /** Seconds on each slide; none: only on a click. Never moves for visitors who asked for less motion. */
16
+ seconds: z.number().int().min(2).max(30).optional(),
17
+ /** Fade between slides, or slide sideways. */
18
+ transition: z.enum(["fade", "slide"]).default("fade"),
19
+ /** Pauses while the pointer is over it. */
20
+ pauseOnHover: z.boolean().default(true),
21
+ /** Draws its own dots and arrows (the kit's), where the design has none marked as slots. */
22
+ dots: z.boolean().default(true),
23
+ arrows: z.boolean().default(false),
24
+ }),
25
+ z.object({
26
+ name: z.literal("tabs"),
27
+ /** The tab shown first, by its place. */
28
+ first: z.number().int().min(0).max(20).default(0),
29
+ }),
30
+ z.object({
31
+ name: z.literal("accordion"),
32
+ /** Only one item open at a time. */
33
+ single: z.boolean().default(false),
34
+ /** The item open at first, by its place; none: all closed. */
35
+ open: z.number().int().min(0).max(50).optional(),
36
+ }),
37
+ z.object({
38
+ name: z.literal("marquee"),
39
+ seconds: z.number().int().min(5).max(120).default(30),
40
+ reverse: z.boolean().default(false),
41
+ }),
42
+ z.object({
43
+ name: z.literal("reveal"),
44
+ /** How it comes in as it's scrolled to; nothing for visitors who asked for less motion. */
45
+ effect: z.enum(["fade", "up", "left", "right", "zoom"]).default("up"),
46
+ /** Its children one after another, this many milliseconds apart. */
47
+ stagger: z.number().int().min(0).max(1000).default(0),
48
+ }),
49
+ z.object({
50
+ name: z.literal("count-up"),
51
+ /** Counts up to its words' number when scrolled to. */
52
+ seconds: z.number().min(0.3).max(5).default(1.2),
53
+ }),
54
+ z.object({
55
+ name: z.literal("filter"),
56
+ /**
57
+ * The field of the list repeated inside it that its buttons choose by: each button inside it (not in
58
+ * an item) shows the items whose field matches its words ("Real estate" is real-estate), "All" every one.
59
+ */
60
+ field: z.string().regex(/^[\w-]{1,60}$/),
61
+ }),
62
+ z.object({
63
+ name: z.literal("sticky"),
64
+ /** Stays this far from the top while its section scrolls past. */
65
+ top: z.number().int().min(0).max(400).default(0),
66
+ }),
67
+ ]);
@@ -0,0 +1,24 @@
1
+ /** Variants a class may carry: screen sizes, states, and a few structural ones. */
2
+ export declare const VARIANTS: Set<string>;
3
+ export type ClassCheck = {
4
+ ok: boolean;
5
+ bad: {
6
+ token: string;
7
+ why: string;
8
+ }[];
9
+ };
10
+ /** The classes of a class string: split on whitespace, empties dropped. */
11
+ export declare const classTokens: (c: string) => string[];
12
+ /** A class's variants and its utility, split on colons outside brackets. */
13
+ export declare function splitVariants(token: string): {
14
+ variants: string[];
15
+ utility: string;
16
+ };
17
+ /** Why one class isn't allowed, or null when it is. */
18
+ export declare function classProblem(token: string, known?: (utility: string) => boolean): string | null;
19
+ /** A class string checked whole: ok, or each class that isn't and why. */
20
+ export declare function checkClasses(c: string, o?: {
21
+ known?: (utility: string) => boolean;
22
+ }): ClassCheck;
23
+ /** The allowed classes of a string (what the kit draws: a bad one is dropped, the rest kept). */
24
+ export declare const safeClasses: (c: string | undefined) => string;
@@ -0,0 +1,94 @@
1
+ // Which Tailwind classes a built section may carry (cal-os docs/design/site-builder.md, "Tailwind"):
2
+ // any utility, with the variants a site needs, and arbitrary values that are only a number, length,
3
+ // colour, percentage or the site's own variables. Never a class that loads another site (url()), writes
4
+ // words into the page (content-[…]), or sets an arbitrary property ([mask:…]). Pure: the kit runs it
5
+ // to drop a bad class from a tree it draws, CalOS to refuse one, Cal to be told why.
6
+ //
7
+ // It checks a class's shape, not that Tailwind knows the utility: `known` (from Tailwind's own design
8
+ // system, where it's loaded, in CalOS) adds that. A shape-valid class Tailwind doesn't know just draws nothing.
9
+ /** Variants a class may carry: screen sizes, states, and a few structural ones. */
10
+ export const VARIANTS = new Set([
11
+ "sm", "md", "lg", "xl", "2xl",
12
+ "max-sm", "max-md", "max-lg", "max-xl", "max-2xl",
13
+ "hover", "focus", "focus-visible", "focus-within", "active", "disabled", "visited",
14
+ "group-hover", "group-focus", "group-focus-within", "peer-hover", "peer-focus", "peer-checked",
15
+ // A menu button drawn open (its lines turned to a cross) while what it opens is: the accordion says aria-expanded on it.
16
+ "group-aria-expanded", "peer-aria-expanded",
17
+ "first", "last", "odd", "even", "only", "empty",
18
+ "motion-safe", "motion-reduce", "print", "dark",
19
+ "before", "after", "placeholder", "marker", "selection", "file",
20
+ "open", "aria-expanded", "aria-selected", "aria-current", "aria-disabled", "aria-pressed",
21
+ ]);
22
+ /** An arbitrary value that's only data: a number, a length, a colour, a percentage, a calc of those, or a var(--…). */
23
+ const SAFE_ARBITRARY = /^(?:-?[\d.]+(?:px|rem|em|%|vh|vw|svh|dvh|lvh|ch|ex|fr|deg|turn|s|ms)?|#[0-9a-fA-F]{3,8}|(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|color-mix|calc|min|max|clamp|var|repeat|minmax|fit-content)\([\w\s.,%#+\-*/()]*\)|[\d.]+\/[\d.]+|auto|none|[a-z-]+)(?:_[\w.#%(),+\-*/]+)*$/;
24
+ /** Utilities never allowed, whatever their value: they write words, or run the browser's code. */
25
+ const NEVER = [/^content-\[/, /^will-change-/];
26
+ /** The classes of a class string: split on whitespace, empties dropped. */
27
+ export const classTokens = (c) => c.split(/\s+/).filter(Boolean);
28
+ /** A class's variants and its utility, split on colons outside brackets. */
29
+ export function splitVariants(token) {
30
+ const parts = [];
31
+ let depth = 0;
32
+ let cur = "";
33
+ for (const ch of token) {
34
+ if (ch === "[" || ch === "(")
35
+ depth++;
36
+ if (ch === "]" || ch === ")")
37
+ depth--;
38
+ if (ch === ":" && depth === 0) {
39
+ parts.push(cur);
40
+ cur = "";
41
+ }
42
+ else
43
+ cur += ch;
44
+ }
45
+ parts.push(cur);
46
+ return { variants: parts.slice(0, -1), utility: parts.at(-1) };
47
+ }
48
+ /** Why one class isn't allowed, or null when it is. */
49
+ export function classProblem(token, known) {
50
+ if (token.length > 120)
51
+ return "too long";
52
+ if (!/^[\w\-:/.[\]()%#,!*+@&>~=]+$/.test(token))
53
+ return "characters a class can't have";
54
+ const { variants, utility } = splitVariants(token);
55
+ for (const v of variants)
56
+ if (!VARIANTS.has(v))
57
+ return `the variant ${v}: isn't offered`;
58
+ const u = utility.replace(/^!/, "").replace(/!$/, "").replace(/^-/, "");
59
+ if (!u)
60
+ return "no utility";
61
+ if (u.startsWith("["))
62
+ return "an arbitrary property ([prop:value]) isn't allowed";
63
+ if (NEVER.some((re) => re.test(u)))
64
+ return "this utility isn't allowed";
65
+ const arb = /\[(.*)\]/.exec(u);
66
+ if (arb) {
67
+ const v = arb[1];
68
+ if (/url\(|['"`\\;{}@]|javascript:|expression\(/i.test(v))
69
+ return "a value that loads or runs something";
70
+ if (!SAFE_ARBITRARY.test(v))
71
+ return "an arbitrary value that isn't a number, length, colour or the site's variable";
72
+ }
73
+ // An opacity after a slash (bg-ink/80, bg-ink/[.5]) is taken off before the shape is checked; a slash
74
+ // inside an arbitrary value (aspect-[4/3], w-[calc(100%/3)]) is the value's own.
75
+ const base = /\]$/.test(u) && !/\/\[[^\]]*\]$/.test(u) ? u : u.replace(/\/(?:\[[^\]]*\]|[\w.]+)$/, "");
76
+ // A half step (p-0.5, gap-1.5) and a number with a % (a gradient's stop: via-32%) are a utility's own values too.
77
+ if (!/^[a-z@][\w-]*(?:\.\d+)?(?:\d%)?(?:-\[.*\])?$/.test(base))
78
+ return "not a Tailwind utility's shape";
79
+ if (known && !known(utility))
80
+ return "Tailwind has no such class";
81
+ return null;
82
+ }
83
+ /** A class string checked whole: ok, or each class that isn't and why. */
84
+ export function checkClasses(c, o = {}) {
85
+ const bad = [];
86
+ for (const token of classTokens(c)) {
87
+ const why = classProblem(token, o.known);
88
+ if (why)
89
+ bad.push({ token, why });
90
+ }
91
+ return { ok: bad.length === 0, bad };
92
+ }
93
+ /** The allowed classes of a string (what the kit draws: a bad one is dropped, the rest kept). */
94
+ export const safeClasses = (c) => (c ? classTokens(c).filter((t) => !classProblem(t)).join(" ") : "");