@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
@@ -0,0 +1,257 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { describe, expect, it } from "bun:test";
3
+ import { renderToStaticMarkup } from "react-dom/server";
4
+ import { behaviorsIn, classesIn } from "../built/tree.js";
5
+ import { built, node, treeProblems } from "../tree/index.js";
6
+ import { BuiltSection } from "../next/built.js";
7
+ import { pageAt, setPrepared } from "../next/data.js";
8
+ import { sitemap, staticParams } from "../next/metadata.js";
9
+ import { sitemapXml } from "../seo.js";
10
+ import { allPages, defineSite, fromList, itemOf, text, withListItems, z } from "./index.js";
11
+ // Lists (cal-os docs/design/site-lists.md): items kept once in the content, repeated by built
12
+ // sections, and each item of a list with a page at its own address, drawn from one design.
13
+ const content = defineSite({ hero: z.object({ heading: text }), built, list: z.object({ heading: text }) });
14
+ const productPage = {
15
+ path: "/products/",
16
+ title: "name",
17
+ description: "summary",
18
+ blocks: [
19
+ {
20
+ type: "built",
21
+ root: {
22
+ id: "prodroot01",
23
+ tag: "section",
24
+ class: "p-6",
25
+ children: [
26
+ { id: "prodname01", tag: "h1", class: "text-4xl", bind: { path: "this.name" } },
27
+ { id: "prodpric01", tag: "p", bind: { path: "this.price" } },
28
+ { id: "prodphot01", tag: "img", bind: { path: "this.photo" } },
29
+ { id: "prodrela01", tag: "ul", children: [{ id: "prodrelb01", tag: "li", repeat: { path: "lists.products.items" }, bind: { path: "item.name" } }] },
30
+ ],
31
+ },
32
+ },
33
+ ],
34
+ };
35
+ const raw = (over = {}) => ({
36
+ url: "https://acme.example",
37
+ business: { name: "Acme" },
38
+ pages: [
39
+ { path: "/", title: "Acme", description: "Acme does things.", blocks: [{ type: "hero", heading: "Hello" }] },
40
+ { path: "/products/", title: "Products", description: "What we make.", blocks: [{ type: "hero", heading: "Products" }] },
41
+ ],
42
+ lists: {
43
+ products: {
44
+ name: "Products",
45
+ fields: [
46
+ { key: "name", label: "Name", kind: "text" },
47
+ { key: "summary", label: "Summary", kind: "longText" },
48
+ { key: "price", label: "Price", kind: "number" },
49
+ { key: "photo", label: "Photo", kind: "image" },
50
+ ],
51
+ items: [
52
+ { id: "rec-1", slug: "walnut-desk", name: "Walnut desk", summary: "Solid walnut.", price: 1200, photo: { src: "desk.jpg", alt: "The desk" } },
53
+ { id: "rec-2", slug: "oak-chair", name: "Oak chair", summary: "Sits well.", price: 300 },
54
+ ],
55
+ page: structuredClone(productPage),
56
+ },
57
+ },
58
+ ...over,
59
+ });
60
+ describe("a site's lists", () => {
61
+ it("takes lists, and a site without any has none", () => {
62
+ expect(content.parse(raw()).problems).toBeUndefined();
63
+ const none = raw();
64
+ delete none.lists;
65
+ expect(content.parse(none).site.lists).toEqual({});
66
+ });
67
+ it("refuses a value of the wrong kind, a field the list hasn't, an address used twice or a page's address, and a title that isn't words", () => {
68
+ const r = raw();
69
+ const items = r.lists.products.items;
70
+ items[0].price = "a lot";
71
+ items[0].colour = "red";
72
+ items[1].slug = "walnut-desk";
73
+ r.pages.push({ path: "/products/oak-table/", title: "Oak table", description: "A page of its own.", blocks: [{ type: "hero", heading: "Oak" }] });
74
+ items.push({ id: "rec-3", slug: "oak-table", name: "Oak table" });
75
+ r.lists.products.page.title = "price";
76
+ const problems = content.parse(r).problems.join("\n");
77
+ expect(problems).toMatch(/price: Not a number value/);
78
+ expect(problems).toMatch(/colour isn't one of Products's fields/);
79
+ expect(problems).toMatch(/Two items have the address walnut-desk/);
80
+ expect(problems).toMatch(/There's a page at \/products\/oak-table\/ already/);
81
+ expect(problems).toMatch(/The title is one of the list's fields of words/);
82
+ });
83
+ it("refuses an address that isn't lowercase words and dashes, and a field called id or slug", () => {
84
+ const r = raw();
85
+ r.lists.products.items[0].slug = "Walnut Desk";
86
+ r.lists.products.fields.push({ key: "slug", label: "Slug", kind: "text" });
87
+ const problems = content.parse(r).problems.join("\n");
88
+ expect(problems).toMatch(/Lowercase letters, numbers and dashes/);
89
+ expect(problems).toMatch(/id and slug are an item's own/);
90
+ });
91
+ it("gives each item a page at its list's address, titled, described and shared by its own values", () => {
92
+ const site = content.parse(raw()).site;
93
+ const items = allPages(site).filter((p) => itemOf(p));
94
+ expect(items.map((p) => [p.path, p.title, p.description])).toEqual([
95
+ ["/products/walnut-desk/", "Walnut desk", "Solid walnut."],
96
+ ["/products/oak-chair/", "Oak chair", "Sits well."],
97
+ ]);
98
+ expect(items[0].share).toEqual({ src: "desk.jpg", alt: "The desk" });
99
+ });
100
+ it("finds an item's page at its address, lists it for the build and the sitemap, and checks links to it", () => {
101
+ const site = content.parse(raw()).site;
102
+ setPrepared({ site, images: {}, csp: {} }, false);
103
+ expect(pageAt(["products", "oak-chair"])?.title).toBe("Oak chair");
104
+ expect(pageAt(["products"])?.title).toBe("Products");
105
+ expect(pageAt(["products", "nothing"])).toBeNull();
106
+ expect(staticParams()).toContainEqual({ path: ["products", "walnut-desk"] });
107
+ expect(sitemap().map((u) => u.url)).toContain("https://acme.example/products/oak-chair/");
108
+ expect(sitemapXml(site)).toContain("/products/walnut-desk/");
109
+ // A link to an item's page lands; one to an item there isn't doesn't.
110
+ const linked = raw();
111
+ linked.pages[0].blocks = [{ type: "built", root: { id: "linkroot01", tag: "a", text: "Desk", href: "/products/walnut-desk/" } }];
112
+ expect(content.parse(linked).problems).toBeUndefined();
113
+ linked.pages[0].blocks[0].root.href = "/products/pine-bed/";
114
+ expect(content.parse(linked).problems.join("\n")).toMatch(/No page at \/products\/pine-bed\//);
115
+ });
116
+ it("draws an item's page with this. for the item and item. for a repeat's", () => {
117
+ const site = content.parse(raw()).site;
118
+ setPrepared({ site, images: {}, csp: {} }, true);
119
+ const page = pageAt(["products", "walnut-desk"]);
120
+ const html = renderToStaticMarkup(_jsx(BuiltSection, { s: page.blocks[0] }));
121
+ expect(html).toContain(">Walnut desk</h1>");
122
+ expect(html).toContain(">1200</p>");
123
+ expect(html).toContain('alt="The desk"');
124
+ expect(html).toContain("<li");
125
+ expect(html).toContain(">Oak chair</li>");
126
+ });
127
+ it("builds CSS and behaviors for a list's page design, which only its items draw", () => {
128
+ const site = content.parse(raw()).site;
129
+ expect([...classesIn(site)]).toEqual(["kit:p-6", "kit:text-4xl"]);
130
+ expect([...behaviorsIn(site)]).toEqual([]);
131
+ });
132
+ });
133
+ describe("a section's items from a list", () => {
134
+ const site = {
135
+ trailingSlash: true,
136
+ lists: {
137
+ services: {
138
+ name: "Services",
139
+ fields: [
140
+ { key: "name", label: "Name", kind: "text" },
141
+ { key: "summary", label: "Summary", kind: "longText" },
142
+ { key: "photo", label: "Photo", kind: "image" },
143
+ ],
144
+ items: [
145
+ { id: "a", slug: "bookkeeping", name: "Bookkeeping", summary: "Monthly books.", photo: { src: "uploads/b.jpg", alt: "A ledger" } },
146
+ { id: "b", slug: "payroll", name: "Payroll" },
147
+ ],
148
+ page: { path: "/services/", title: "name", blocks: [] },
149
+ },
150
+ },
151
+ };
152
+ const block = { type: "services", heading: "What we do", items: [{ title: "Typed", text: "Typed" }], from: { list: "services", into: "items", fields: { title: "name", text: "summary", image: "photo", href: "@page" } } };
153
+ it("fills the section's items from the list, each linked to its page", () => {
154
+ const out = withListItems(block, site);
155
+ expect(out.items).toEqual([
156
+ { title: "Bookkeeping", text: "Monthly books.", image: { src: "uploads/b.jpg", alt: "A ledger" }, href: "/services/bookkeeping/" },
157
+ // Words it hasn't are empty words; a picture it hasn't is left out.
158
+ { title: "Payroll", text: "", href: "/services/payroll/" },
159
+ ]);
160
+ });
161
+ it("keeps the typed items where the list isn't there, and when none is picked", () => {
162
+ expect(withListItems({ ...block, from: { ...block.from, list: "gone" } }, site)).toEqual({ ...block, from: { ...block.from, list: "gone" } });
163
+ const { from: _, ...typed } = block;
164
+ expect(withListItems(typed, site)).toBe(typed);
165
+ });
166
+ it("is what a page draws", () => {
167
+ const pages = allPages({ ...site, pages: [{ path: "/services/", title: "Services", description: "", blocks: [block] }] });
168
+ expect(pages[0].blocks[0].items).toHaveLength(2);
169
+ });
170
+ it("is a field a section adds, naming the array it fills", () => {
171
+ const schema = z.object({ items: z.array(z.object({ title: text })), from: fromList("items") });
172
+ expect(schema.parse({ items: [], from: { list: "services", fields: { title: "name" } } }).from).toEqual({ list: "services", into: "items", fields: { title: "name" } });
173
+ expect(() => schema.parse({ items: [], from: { list: "services", into: "people" } })).toThrow();
174
+ });
175
+ });
176
+ describe("a link bound to a list's value", () => {
177
+ it("goes to an email address as mailto: and a phone number as tel:, anything else as it is", async () => {
178
+ const { linkFor } = await import("../next/built.js");
179
+ expect(linkFor("team@caltho.com")).toBe("mailto:team@caltho.com");
180
+ expect(linkFor("(215) 555-0100")).toBe("tel:2155550100");
181
+ expect(linkFor("+1 215 555 0100")).toBe("tel:+12155550100");
182
+ expect(linkFor("/services/")).toBe("/services/");
183
+ expect(linkFor("https://caltho.com")).toBe("https://caltho.com");
184
+ });
185
+ });
186
+ describe("a filter over a list", () => {
187
+ it("marks each item with its value of the filter's field, and leaves its buttons as buttons", () => {
188
+ const site = { lists: { work: { name: "Work", fields: [{ key: "name", label: "Name", kind: "text" }, { key: "industry", label: "Industry", kind: "text" }], items: [{ id: "a", slug: "boat", name: "Boat", industry: "Hospitality" }, { id: "b", slug: "agency", name: "Agency", industry: "Real Estate" }] } } };
189
+ setPrepared({ site, images: {}, csp: {} }, false);
190
+ const block = {
191
+ type: "built",
192
+ root: {
193
+ id: "filtroot01", tag: "section", behavior: { name: "filter", field: "industry" }, children: [
194
+ { id: "filtbtns01", tag: "div", children: [{ id: "filtall001", tag: "button", text: "All", class: "aria-pressed:bg-ink" }, { id: "filtre0001", tag: "button", text: "Real estate" }] },
195
+ { id: "filtgrid01", tag: "ul", children: [{ id: "filtitem01", tag: "li", repeat: { path: "lists.work.items" }, bind: { path: "item.name" } }] },
196
+ ],
197
+ },
198
+ };
199
+ const html = renderToStaticMarkup(_jsx(BuiltSection, { s: block }));
200
+ expect(html).toContain('data-kit-behavior="filter"');
201
+ expect(html).toMatch(/<li[^>]*data-kit-filter-value="hospitality"[^>]*>Boat<\/li>/);
202
+ expect(html).toMatch(/<li[^>]*data-kit-filter-value="real-estate"[^>]*>Agency<\/li>/);
203
+ expect(html).toMatch(/<button[^>]*type="button"[^>]*>All<\/button>/);
204
+ });
205
+ });
206
+ describe("a form in a built section", () => {
207
+ const form = {
208
+ id: "formroot01", tag: "form", class: "grid gap-4", children: [
209
+ { id: "formlab001", tag: "label", for: "formname01", text: "Your name" },
210
+ { id: "formname01", tag: "input", field: { name: "name", required: true, placeholder: "Jane" } },
211
+ { id: "formmsg001", tag: "textarea", field: { name: "message", rows: 4 } },
212
+ { id: "formhow001", tag: "select", field: { name: "how", placeholder: "Pick one" }, children: [{ id: "formopt001", tag: "option", text: "Email" }] },
213
+ { id: "formsend01", tag: "button", text: "Send message" },
214
+ ],
215
+ };
216
+ it("posts to the site's own form address with each field by its name, a label naming its field, the spam trap, and a button that sends", () => {
217
+ setPrepared({ site: { form: "https://forms.caltho.com/f/abc" }, images: {}, csp: {} }, false);
218
+ const html = renderToStaticMarkup(_jsx(BuiltSection, { s: { type: "built", root: form } }));
219
+ const tag = (name) => html.match(new RegExp(`<${name}\\b[^>]*>`))?.[0] ?? "";
220
+ expect(tag("form")).toContain('method="post"');
221
+ expect(tag("form")).toContain('action="https://forms.caltho.com/f/abc"');
222
+ expect(html).toMatch(/<label[^>]*for="kit-f-formname01"[^>]*>Your name<\/label>/);
223
+ const name = html.match(/<input[^>]*id="kit-f-formname01"[^>]*>/)?.[0] ?? "";
224
+ for (const a of ['name="name"', 'type="text"', 'required=""', 'placeholder="Jane"'])
225
+ expect(name).toContain(a);
226
+ expect(html).toMatch(/<textarea[^>]*name="message"[^>]*rows="4"/);
227
+ expect(html).toMatch(/<select[^>]*name="how"[^>]*>.*<option value="Email">Email<\/option>/);
228
+ expect(html).toContain('name="_website"');
229
+ expect(html).toMatch(/<button[^>]*type="submit"[^>]*>Send message<\/button>/);
230
+ });
231
+ it("can't be sent where the site has no form address yet, and a field outside a form is refused", () => {
232
+ setPrepared({ site: {}, images: {}, csp: {} }, false);
233
+ const html = renderToStaticMarkup(_jsx(BuiltSection, { s: { type: "built", root: form } }));
234
+ expect(html).not.toContain("<form");
235
+ expect(html).toContain("data-kit-form-unset");
236
+ expect(treeProblems({ id: "rootrootxx", tag: "div", children: [{ id: "lonefield1", tag: "input", field: { name: "email" } }] }).join(" ")).toMatch(/belongs inside a form/);
237
+ });
238
+ it("sends to the business's form it names, by key; one the site wasn't given isn't sent anywhere", () => {
239
+ const quote = { ...form, sendTo: "quote" };
240
+ setPrepared({ site: { form: "https://forms.caltho.com/f/abc", forms: { quote: "https://acme.caltho.app/api/forms/quote/k1" } }, images: {}, csp: {} }, false);
241
+ expect(renderToStaticMarkup(_jsx(BuiltSection, { s: { type: "built", root: quote } }))).toContain('action="https://acme.caltho.app/api/forms/quote/k1"');
242
+ const html = renderToStaticMarkup(_jsx(BuiltSection, { s: { type: "built", root: { ...form, sendTo: "gone" } } }));
243
+ expect(html).not.toContain("<form");
244
+ expect(node.safeParse({ id: "abcdefgh12", tag: "div", sendTo: "quote" }).success).toBe(false);
245
+ expect(node.safeParse({ ...form, sendTo: "https://evil.example" }).success).toBe(false);
246
+ const picked = renderToStaticMarkup(_jsx(BuiltSection, { s: { type: "built", root: { ...form, children: [{ id: "byemail001", tag: "input", field: { name: "reach", type: "radio", value: "email", checked: true } }] } } }));
247
+ expect(picked).toMatch(/<input[^>]*checked=""[^>]*value="email"/);
248
+ });
249
+ it("shows a picture's phone version in its place on a small screen", () => {
250
+ const prepared = (src, w, h) => ({ src: `/_img/${src}`, width: w, height: h, srcset: [{ url: `/_img/${src}.800.webp`, w: 800 }] });
251
+ setPrepared({ site: {}, images: { "uploads/today.png": prepared("today.png", 2880, 1400), "uploads/today-phone.png": prepared("today-phone.png", 1170, 1992) }, csp: {} }, false);
252
+ const shot = { id: "shotshot01", tag: "img", class: "w-full", image: { src: "uploads/today.png", alt: "Today", phoneSrc: "uploads/today-phone.png" } };
253
+ const html = renderToStaticMarkup(_jsx(BuiltSection, { s: { type: "built", root: { id: "rootroot01", tag: "div", children: [shot] } } }));
254
+ expect(html).toMatch(/<picture><source media="\(max-width: 700px\)"[^>]*today-phone[^>]*\/><img[^>]*today\.png[^>]*alt="Today"/);
255
+ expect(node.safeParse({ ...shot, image: { ...shot.image, phoneSrc: "https://evil.example/x.png" } }).success).toBe(false);
256
+ });
257
+ });
@@ -1,4 +1,18 @@
1
1
  import { z } from "zod";
2
+ export declare const TYPE_ROLES: readonly ["display", "h1", "h2", "h3", "body", "small"];
3
+ export type TypeRole = (typeof TYPE_ROLES)[number];
4
+ export type TypeStyle = {
5
+ size?: number;
6
+ weight?: 300 | 400 | 500 | 600 | 700;
7
+ tracking?: number;
8
+ leading?: number;
9
+ };
10
+ export type TypeScale = Partial<Record<TypeRole, TypeStyle>>;
11
+ export type Swatch = {
12
+ name: string;
13
+ color: string;
14
+ };
15
+ export type Space = "compact" | "normal" | "roomy";
2
16
  export type LookOptions = {
3
17
  /** The fonts the site carries, by the name CalOS shows → the font-family it's drawn with. */
4
18
  fonts?: Record<string, string>;
@@ -8,19 +22,183 @@ export type LookOptions = {
8
22
  /** The site's own colours, used until others are chosen. */
9
23
  brand: string;
10
24
  accent?: string;
11
- /** The page's background, which a brand colour must read against. */
25
+ /** The page's background, which a brand colour must read against (`background` is the older name). */
26
+ page?: string;
12
27
  background?: string;
28
+ /** The site's main text colour (a dark section's background). */
29
+ ink?: string;
30
+ /** Quieter text, lines and borders, a quiet section's background: worked out from page and ink when not given. */
31
+ muted?: string;
32
+ line?: string;
33
+ soft?: string;
34
+ /** Colours of the site's own, by name. */
35
+ palette?: Swatch[];
36
+ /** The site's type scale: px sizes, weights, tracking (em) and line height, by role. */
37
+ type?: TypeScale;
38
+ /** The content's width in px (800–1600). */
39
+ width?: number;
40
+ /** The space each side of the content, in px, on a wide screen (8–96; 40 unless said): half of it on a phone, growing with the screen between. A site's own sections and the kit's line up when its CSS reads --look-gutter. */
41
+ gutter?: number;
42
+ /** The space above and below each section. */
43
+ space?: Space;
13
44
  corners?: Corners;
14
45
  buttons?: Buttons;
46
+ /** The site's header, until the business chooses otherwise (headerOf). */
47
+ header?: HeaderLook;
48
+ };
49
+ /**
50
+ * The header: whether it stays at the top as the page scrolls, how it's set apart from the page
51
+ * (a line under it, nothing, the brand colour, or a bar floating over the page), and where the
52
+ * name and menu sit (name left and menu right, all centred, or name and menu together on the left).
53
+ */
54
+ export type HeaderLook = {
55
+ sticky?: boolean;
56
+ style?: HeaderStyle;
57
+ layout?: HeaderLayout;
58
+ menuShows?: MenuShows;
59
+ menuStyle?: string;
60
+ };
61
+ /** The phone's menu button: what it shows (its icon, the word, both); its look is a button style (menuStyle). */
62
+ export declare const MENU_SHOWS: readonly ["both", "icon", "word"];
63
+ export type MenuShows = (typeof MENU_SHOWS)[number];
64
+ /** A button style of the business's own: its colours by the theme's names, border, corners, size and lettering. */
65
+ export type ButtonStyle = {
66
+ name: string;
67
+ /** Its fill: a role, a palette colour's slug, or "none" (an outline). */
68
+ fill?: string;
69
+ /** Its words' colour: a role or palette slug; else whichever of the page and text reads on the fill. */
70
+ text?: string;
71
+ border?: {
72
+ width?: number;
73
+ color?: string;
74
+ };
75
+ /** Its corners in px, or 999 for round ends. */
76
+ radius?: number;
77
+ size?: "small" | "medium" | "large";
78
+ weight?: number;
79
+ caps?: boolean;
80
+ };
81
+ export declare const BUTTON_SIZES: readonly ["small", "medium", "large"];
82
+ /** How many styles of its own a site can have (the classes sections.css draws, kit-btn-1 to -8). */
83
+ export declare const MAX_BUTTON_STYLES = 8;
84
+ /** The theme's own, always there: a style of the business's can't take their names. */
85
+ export declare const THEME_BUTTONS: readonly ["main", "accent", "quiet"];
86
+ export declare const HEADER_STYLES: readonly ["line", "plain", "brand", "floating"];
87
+ export declare const HEADER_LAYOUTS: readonly ["spread", "centered", "left"];
88
+ export type HeaderStyle = (typeof HEADER_STYLES)[number];
89
+ export type HeaderLayout = (typeof HEADER_LAYOUTS)[number];
90
+ /** The header as it's drawn: what the business chose, else the site's own, else the kit's. */
91
+ export declare function headerOf(look: Look | undefined, o: Pick<LookOptions, "header">): Required<Omit<HeaderLook, "menuStyle">> & {
92
+ menuStyle?: string;
15
93
  };
16
94
  declare const CORNERS: readonly ["square", "soft", "round"];
17
95
  declare const BUTTONS: readonly ["solid", "outline"];
18
96
  type Corners = (typeof CORNERS)[number];
19
97
  type Buttons = (typeof BUTTONS)[number];
98
+ /** The colours of the page any section's background may be, beside the site's own (by their slug). */
99
+ /**
100
+ * The kit's fonts (fonts.css, imported once in the site's layout): variable, every weight, by the
101
+ * name CalOS shows and the family it's drawn with. A site's look.ts lists them as its fonts (with
102
+ * any of its own), so the business picks any of them for its headings and text.
103
+ */
104
+ export declare const KIT_FONTS: Record<string, string>;
105
+ export declare const COLOUR_ROLES: readonly ["page", "soft", "brand", "accent", "ink"];
106
+ export type ColourRole = (typeof COLOUR_ROLES)[number];
107
+ /** The kit's type scale, for what a site doesn't say. */
108
+ export declare const KIT_TYPE: Record<TypeRole, Required<TypeStyle>>;
109
+ /** A colour of the site's own as a CSS name: "Dark sand" → "dark-sand" (runs of anything else one dash, none at the ends). */
110
+ export declare function swatchSlug(name: string): string;
20
111
  /** The Look's fields, for the site's content. */
21
112
  export declare function lookSchema(o: LookOptions): z.ZodOptional<z.ZodObject<{
113
+ uploadedFonts?: z.ZodOptional<z.ZodArray<z.ZodObject<{
114
+ family: z.ZodString;
115
+ files: z.ZodArray<z.ZodObject<{
116
+ src: z.ZodString;
117
+ weight: z.ZodString;
118
+ style: z.ZodEnum<{
119
+ normal: "normal";
120
+ italic: "italic";
121
+ }>;
122
+ }, z.core.$strip>>;
123
+ }, z.core.$strip>>> | undefined;
124
+ footer?: z.ZodDefault<z.ZodObject<{
125
+ background: z.ZodDefault<z.ZodString>;
126
+ }, z.core.$strip>> | undefined;
127
+ header?: z.ZodDefault<z.ZodObject<{
128
+ sticky: z.ZodDefault<z.ZodBoolean>;
129
+ style: z.ZodDefault<z.ZodEnum<{
130
+ line: "line";
131
+ plain: "plain";
132
+ brand: "brand";
133
+ floating: "floating";
134
+ }>>;
135
+ layout: z.ZodDefault<z.ZodEnum<{
136
+ left: "left";
137
+ spread: "spread";
138
+ centered: "centered";
139
+ }>>;
140
+ menuShows: z.ZodDefault<z.ZodEnum<{
141
+ icon: "icon";
142
+ both: "both";
143
+ word: "word";
144
+ }>>;
145
+ menuStyle: z.ZodOptional<z.ZodString>;
146
+ }, z.core.$strip>> | undefined;
22
147
  brand: z.ZodDefault<z.ZodString>;
23
148
  accent: z.ZodDefault<z.ZodString>;
149
+ page: z.ZodDefault<z.ZodString>;
150
+ ink: z.ZodDefault<z.ZodString>;
151
+ muted: z.ZodDefault<z.ZodString>;
152
+ line: z.ZodDefault<z.ZodString>;
153
+ soft: z.ZodDefault<z.ZodString>;
154
+ palette: z.ZodDefault<z.ZodArray<z.ZodObject<{
155
+ name: z.ZodString;
156
+ color: z.ZodString;
157
+ }, z.core.$strip>>>;
158
+ type: z.ZodDefault<z.ZodObject<{
159
+ h1: z.ZodOptional<z.ZodObject<{
160
+ size: z.ZodOptional<z.ZodNumber>;
161
+ weight: z.ZodOptional<z.ZodLiteral<400 | 600 | 500 | 300 | 700>>;
162
+ tracking: z.ZodOptional<z.ZodNumber>;
163
+ leading: z.ZodOptional<z.ZodNumber>;
164
+ }, z.core.$strip>>;
165
+ h2: z.ZodOptional<z.ZodObject<{
166
+ size: z.ZodOptional<z.ZodNumber>;
167
+ weight: z.ZodOptional<z.ZodLiteral<400 | 600 | 500 | 300 | 700>>;
168
+ tracking: z.ZodOptional<z.ZodNumber>;
169
+ leading: z.ZodOptional<z.ZodNumber>;
170
+ }, z.core.$strip>>;
171
+ h3: z.ZodOptional<z.ZodObject<{
172
+ size: z.ZodOptional<z.ZodNumber>;
173
+ weight: z.ZodOptional<z.ZodLiteral<400 | 600 | 500 | 300 | 700>>;
174
+ tracking: z.ZodOptional<z.ZodNumber>;
175
+ leading: z.ZodOptional<z.ZodNumber>;
176
+ }, z.core.$strip>>;
177
+ small: z.ZodOptional<z.ZodObject<{
178
+ size: z.ZodOptional<z.ZodNumber>;
179
+ weight: z.ZodOptional<z.ZodLiteral<400 | 600 | 500 | 300 | 700>>;
180
+ tracking: z.ZodOptional<z.ZodNumber>;
181
+ leading: z.ZodOptional<z.ZodNumber>;
182
+ }, z.core.$strip>>;
183
+ body: z.ZodOptional<z.ZodObject<{
184
+ size: z.ZodOptional<z.ZodNumber>;
185
+ weight: z.ZodOptional<z.ZodLiteral<400 | 600 | 500 | 300 | 700>>;
186
+ tracking: z.ZodOptional<z.ZodNumber>;
187
+ leading: z.ZodOptional<z.ZodNumber>;
188
+ }, z.core.$strip>>;
189
+ display: z.ZodOptional<z.ZodObject<{
190
+ size: z.ZodOptional<z.ZodNumber>;
191
+ weight: z.ZodOptional<z.ZodLiteral<400 | 600 | 500 | 300 | 700>>;
192
+ tracking: z.ZodOptional<z.ZodNumber>;
193
+ leading: z.ZodOptional<z.ZodNumber>;
194
+ }, z.core.$strip>>;
195
+ }, z.core.$strip>>;
196
+ width: z.ZodDefault<z.ZodNumber>;
197
+ space: z.ZodDefault<z.ZodEnum<{
198
+ normal: "normal";
199
+ compact: "compact";
200
+ roomy: "roomy";
201
+ }>>;
24
202
  corners: z.ZodDefault<z.ZodEnum<{
25
203
  square: "square";
26
204
  soft: "soft";
@@ -30,30 +208,103 @@ export declare function lookSchema(o: LookOptions): z.ZodOptional<z.ZodObject<{
30
208
  solid: "solid";
31
209
  outline: "outline";
32
210
  }>>;
33
- headingFont?: z.ZodDefault<z.ZodEnum<{
34
- [x: string]: string;
35
- }>> | undefined;
36
- bodyFont?: z.ZodDefault<z.ZodEnum<{
37
- [x: string]: string;
38
- }>> | undefined;
211
+ radius: z.ZodOptional<z.ZodNumber>;
212
+ buttonRadius: z.ZodOptional<z.ZodNumber>;
213
+ buttonStyles: z.ZodOptional<z.ZodArray<z.ZodObject<{
214
+ name: z.ZodString;
215
+ fill: z.ZodOptional<z.ZodString>;
216
+ text: z.ZodOptional<z.ZodString>;
217
+ border: z.ZodOptional<z.ZodObject<{
218
+ width: z.ZodOptional<z.ZodNumber>;
219
+ color: z.ZodOptional<z.ZodString>;
220
+ }, z.core.$strip>>;
221
+ radius: z.ZodOptional<z.ZodNumber>;
222
+ size: z.ZodOptional<z.ZodEnum<{
223
+ small: "small";
224
+ medium: "medium";
225
+ large: "large";
226
+ }>>;
227
+ weight: z.ZodOptional<z.ZodNumber>;
228
+ caps: z.ZodOptional<z.ZodBoolean>;
229
+ }, z.core.$strip>>>;
230
+ buttonBorder: z.ZodOptional<z.ZodObject<{
231
+ width: z.ZodNumber;
232
+ color: z.ZodOptional<z.ZodString>;
233
+ }, z.core.$strip>>;
234
+ headingFont?: z.ZodDefault<z.ZodString> | undefined;
235
+ bodyFont?: z.ZodDefault<z.ZodString> | undefined;
236
+ }, z.core.$strip>>;
237
+ /** A button style's name, by its slug where a button names it ("main", "accent", "quiet", or one of the business's). */
238
+ export declare const buttonStyleName: z.ZodString;
239
+ declare const uploadedFonts: z.ZodArray<z.ZodObject<{
240
+ family: z.ZodString;
241
+ files: z.ZodArray<z.ZodObject<{
242
+ src: z.ZodString;
243
+ weight: z.ZodString;
244
+ style: z.ZodEnum<{
245
+ normal: "normal";
246
+ italic: "italic";
247
+ }>;
248
+ }, z.core.$strip>>;
39
249
  }, z.core.$strip>>;
250
+ export type UploadedFont = z.infer<typeof uploadedFonts>[number];
251
+ /** The @font-face rules for an uploaded font, each file at the address `at` gives its path. */
252
+ export declare function uploadedFontCss(f: UploadedFont, at: (src: string) => string): string;
40
253
  export type Look = {
41
254
  headingFont?: string;
42
255
  bodyFont?: string;
43
256
  brand?: string;
44
257
  accent?: string;
258
+ page?: string;
259
+ ink?: string;
260
+ muted?: string;
261
+ line?: string;
262
+ soft?: string;
263
+ palette?: Swatch[];
264
+ type?: TypeScale;
265
+ width?: number;
266
+ space?: Space;
45
267
  corners?: Corners;
46
268
  buttons?: Buttons;
269
+ /** Exact corners in px, past the presets: cards and pictures, and buttons (999: a pill). */
270
+ radius?: number;
271
+ buttonRadius?: number;
272
+ /** A border round buttons: px wide, in a role's or a palette colour's colour (else the button's own). */
273
+ buttonBorder?: {
274
+ width?: number;
275
+ color?: string;
276
+ };
277
+ /** Button styles of the business's own, past the theme's Main, Accent and Quiet: each button picks one by its name's slug. */
278
+ buttonStyles?: ButtonStyle[];
279
+ header?: HeaderLook;
280
+ /** The kit footer's background: "dark" (the site's own), a colour role, or a palette colour's slug. */
281
+ footer?: {
282
+ background?: string;
283
+ };
284
+ /** Fonts the business uploaded, each family's files (under fonts/ among the site's pictures). */
285
+ uploadedFonts?: UploadedFont[];
47
286
  };
48
287
  /** How readable one colour is on another (WCAG): 1 to 21. */
49
288
  export declare function contrast(a: string, b: string): number;
50
- /** The colours chosen that won't read: the brand colour is used for text and buttons on the page, so it must stand out from it. */
289
+ /** Whether a colour is dark: white words read better on it than near-black ones. */
290
+ export declare function isDark(color: string): boolean;
291
+ /** The colours chosen that won't read: the brand colour is used for text and buttons on the page, so it must stand out from it; text must read on it; the site's own colours need names of their own. */
51
292
  export declare function lookProblems(look: Look | undefined, o: LookOptions): {
52
- path: string[];
293
+ path: (string | number)[];
53
294
  message: string;
54
295
  }[];
55
296
  /** The Look as CSS variables: what's chosen, else the site's own. */
56
297
  export declare function lookVars(look: Look | undefined, o: LookOptions): Record<string, string>;
57
298
  /** The Look as a stylesheet for the page's head: `:root { … }`. */
58
299
  export declare function lookCss(look: Look | undefined, o: LookOptions): string;
300
+ /**
301
+ * The colour a section's background names (a role, or one of the site's own by its slug), from the
302
+ * Look's variables; undefined when it names none.
303
+ */
304
+ export declare function colourOf(name: string, vars: Record<string, string>): string | undefined;
305
+ /**
306
+ * The class that draws a button in one of the business's own styles (kit-btn-3), from the Look's
307
+ * values (lookVars): undefined for the theme's own (main, accent, quiet) or a style it doesn't have.
308
+ */
309
+ export declare function buttonStyleClass(style: string | undefined, vars: Record<string, string>): string | undefined;
59
310
  export {};