@caltho/site-kit 0.2.10 → 0.2.11

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 (92) hide show
  1. package/README.md +61 -12
  2. package/dist/cli/check.js +31 -1
  3. package/dist/content/index.d.ts +310 -23
  4. package/dist/content/index.js +142 -15
  5. package/dist/content/look.d.ts +260 -9
  6. package/dist/content/look.js +386 -22
  7. package/dist/finish.js +9 -1
  8. package/dist/fonts/families/dm-sans/LICENSE +93 -0
  9. package/dist/fonts/families/dm-sans/dm-sans-latin-ext-wght-italic.woff2 +0 -0
  10. package/dist/fonts/families/dm-sans/dm-sans-latin-ext-wght-normal.woff2 +0 -0
  11. package/dist/fonts/families/dm-sans/dm-sans-latin-wght-italic.woff2 +0 -0
  12. package/dist/fonts/families/dm-sans/dm-sans-latin-wght-normal.woff2 +0 -0
  13. package/dist/fonts/families/fraunces/LICENSE +93 -0
  14. package/dist/fonts/families/fraunces/fraunces-latin-ext-wght-italic.woff2 +0 -0
  15. package/dist/fonts/families/fraunces/fraunces-latin-ext-wght-normal.woff2 +0 -0
  16. package/dist/fonts/families/fraunces/fraunces-latin-wght-italic.woff2 +0 -0
  17. package/dist/fonts/families/fraunces/fraunces-latin-wght-normal.woff2 +0 -0
  18. package/dist/fonts/families/inter/LICENSE +93 -0
  19. package/dist/fonts/families/inter/inter-latin-ext-wght-italic.woff2 +0 -0
  20. package/dist/fonts/families/inter/inter-latin-ext-wght-normal.woff2 +0 -0
  21. package/dist/fonts/families/inter/inter-latin-wght-italic.woff2 +0 -0
  22. package/dist/fonts/families/inter/inter-latin-wght-normal.woff2 +0 -0
  23. package/dist/fonts/families/lora/LICENSE +93 -0
  24. package/dist/fonts/families/lora/lora-latin-ext-wght-italic.woff2 +0 -0
  25. package/dist/fonts/families/lora/lora-latin-ext-wght-normal.woff2 +0 -0
  26. package/dist/fonts/families/lora/lora-latin-wght-italic.woff2 +0 -0
  27. package/dist/fonts/families/lora/lora-latin-wght-normal.woff2 +0 -0
  28. package/dist/fonts/families/manrope/LICENSE +93 -0
  29. package/dist/fonts/families/manrope/manrope-latin-ext-wght-normal.woff2 +0 -0
  30. package/dist/fonts/families/manrope/manrope-latin-wght-normal.woff2 +0 -0
  31. package/dist/fonts/families/newsreader/LICENSE +93 -0
  32. package/dist/fonts/families/newsreader/newsreader-latin-ext-wght-italic.woff2 +0 -0
  33. package/dist/fonts/families/newsreader/newsreader-latin-ext-wght-normal.woff2 +0 -0
  34. package/dist/fonts/families/newsreader/newsreader-latin-wght-italic.woff2 +0 -0
  35. package/dist/fonts/families/newsreader/newsreader-latin-wght-normal.woff2 +0 -0
  36. package/dist/fonts/families/outfit/LICENSE +93 -0
  37. package/dist/fonts/families/outfit/outfit-latin-ext-wght-normal.woff2 +0 -0
  38. package/dist/fonts/families/outfit/outfit-latin-wght-normal.woff2 +0 -0
  39. package/dist/fonts/families/playfair-display/LICENSE +93 -0
  40. package/dist/fonts/families/playfair-display/playfair-display-latin-ext-wght-italic.woff2 +0 -0
  41. package/dist/fonts/families/playfair-display/playfair-display-latin-ext-wght-normal.woff2 +0 -0
  42. package/dist/fonts/families/playfair-display/playfair-display-latin-wght-italic.woff2 +0 -0
  43. package/dist/fonts/families/playfair-display/playfair-display-latin-wght-normal.woff2 +0 -0
  44. package/dist/fonts/families/source-serif-4/LICENSE +93 -0
  45. package/dist/fonts/families/source-serif-4/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
  46. package/dist/fonts/families/source-serif-4/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
  47. package/dist/fonts/families/source-serif-4/source-serif-4-latin-wght-italic.woff2 +0 -0
  48. package/dist/fonts/families/source-serif-4/source-serif-4-latin-wght-normal.woff2 +0 -0
  49. package/dist/fonts/families/work-sans/LICENSE +93 -0
  50. package/dist/fonts/families/work-sans/work-sans-latin-ext-wght-italic.woff2 +0 -0
  51. package/dist/fonts/families/work-sans/work-sans-latin-ext-wght-normal.woff2 +0 -0
  52. package/dist/fonts/families/work-sans/work-sans-latin-wght-italic.woff2 +0 -0
  53. package/dist/fonts/families/work-sans/work-sans-latin-wght-normal.woff2 +0 -0
  54. package/dist/fonts/google.json +1 -0
  55. package/dist/fonts-google.d.ts +35 -0
  56. package/dist/fonts-google.js +70 -0
  57. package/dist/fonts.css +365 -0
  58. package/dist/frame.test.d.ts +1 -0
  59. package/dist/frame.test.js +33 -0
  60. package/dist/migrate.d.ts +45 -0
  61. package/dist/migrate.js +284 -0
  62. package/dist/next/block-mark.d.ts +7 -0
  63. package/dist/next/block-mark.js +16 -0
  64. package/dist/next/blocks.d.ts +11 -0
  65. package/dist/next/blocks.js +35 -0
  66. package/dist/next/data.d.ts +4 -0
  67. package/dist/next/data.js +4 -0
  68. package/dist/next/frame.d.ts +34 -0
  69. package/dist/next/frame.js +70 -0
  70. package/dist/next/index.d.ts +6 -2
  71. package/dist/next/index.js +7 -3
  72. package/dist/next/menu-group.d.ts +11 -0
  73. package/dist/next/menu-group.js +33 -0
  74. package/dist/next/parts/LookStyle.js +3 -1
  75. package/dist/next/preview.js +282 -25
  76. package/dist/next/sections.d.ts +19 -10
  77. package/dist/next/sections.js +129 -17
  78. package/dist/prepare.d.ts +5 -1
  79. package/dist/prepare.js +59 -1
  80. package/dist/preview-match.d.ts +8 -0
  81. package/dist/preview-match.js +33 -1
  82. package/dist/schema.d.ts +10 -0
  83. package/dist/schema.js +26 -0
  84. package/dist/sections/icons.d.ts +47 -0
  85. package/dist/sections/icons.js +51 -0
  86. package/dist/sections/index.d.ts +165 -11
  87. package/dist/sections/index.js +129 -8
  88. package/dist/sections/presets.d.ts +13 -0
  89. package/dist/sections/presets.js +300 -0
  90. package/dist/sections.css +1260 -41
  91. package/dist/sections.test.js +276 -14
  92. package/package.json +8 -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caltho/site-kit",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
4
4
  "description": "What every Caltho site is built with: the contract with CalOS (content, and how a site declares what is editable), images, search and security, and the checks",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,13 +49,18 @@
49
49
  "types": "./dist/sections/index.d.ts",
50
50
  "default": "./dist/sections/index.js"
51
51
  },
52
- "./sections.css": "./dist/sections.css"
52
+ "./migrate": {
53
+ "types": "./dist/migrate.d.ts",
54
+ "default": "./dist/migrate.js"
55
+ },
56
+ "./sections.css": "./dist/sections.css",
57
+ "./fonts.css": "./dist/fonts.css"
53
58
  },
54
59
  "engines": {
55
60
  "node": ">=22.18"
56
61
  },
57
62
  "scripts": {
58
- "build": "rm -rf dist && tsc -p tsconfig.build.json && cp -R src/fonts dist/fonts && cp src/sections.css dist/sections.css && chmod +x dist/cli/index.js",
63
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && mkdir -p dist/fonts && cp -R src/fonts/. dist/fonts/ && cp src/sections.css dist/sections.css && cp src/fonts.css dist/fonts.css && chmod +x dist/cli/index.js",
59
64
  "typecheck": "tsc -p tsconfig.json --noEmit",
60
65
  "test": "bun test src",
61
66
  "prepack": "bun run build"