@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
@@ -10,10 +10,17 @@
10
10
  // them with no further work.
11
11
  import { z } from "zod";
12
12
  import { checkStructure, describePath } from "../schema.js";
13
- import { lookProblems, lookSchema } from "./look.js";
14
- export { contrast, lookCss, lookProblems, lookVars } from "./look.js";
15
- export { checkStructure, describePath } from "../schema.js";
13
+ import { presets } from "../sections/presets.js";
14
+ import { built, component, treeProblems } from "../tree/index.js";
15
+ import { CONTENT_FORMAT } from "../content-migrate.js";
16
+ import { consent } from "../consent.js";
17
+ import { FIELD_KINDS, itemPath } from "./lists.js";
18
+ import { buttonStyleName, COLOUR_ROLES, colourOf, isDark, lookProblems, lookSchema, swatchSlug } from "./look.js";
19
+ export { COLOUR_ROLES, colourOf, contrast, HEADER_LAYOUTS, HEADER_STYLES, headerOf, isDark, KIT_FONTS, KIT_TYPE, lookCss, lookProblems, lookVars, swatchSlug, TYPE_ROLES, uploadedFontCss, buttonStyleClass, BUTTON_SIZES, MAX_BUTTON_STYLES, THEME_BUTTONS, } from "./look.js";
20
+ export { fontFaceCss, fontFiles, googleFamily, googleFont, googleFonts, isFontName, servedAt } from "../fonts-google.js";
21
+ export { blockLabel, checkStructure, describePath } from "../schema.js";
16
22
  export { z };
23
+ export { allPages, FIELD_KINDS, fromList, itemOf, itemPages, itemPath, PAGE_LINK, THIS_ITEM, withListItems } from "./lists.js";
17
24
  // Fields carry an "x-ui" hint in the JSON Schema, so CalOS draws the right control: a text area, a
18
25
  // Markdown box, a picture picker.
19
26
  export const text = z.string().trim().min(1);
@@ -33,16 +40,26 @@ export const href = z
33
40
  .string()
34
41
  .regex(/^(\/[\w\-./#?=&%]*|#[\w-]+|https?:\/\/\S+|tel:\+?[\d\s()-]+|mailto:\S+@\S+)$/, { error: HREF_HELP })
35
42
  .meta({ "x-error": HREF_HELP });
36
- export const link = z.object({ label: text, href, style: z.enum(["primary", "secondary"]).optional() });
43
+ export const link = z.object({
44
+ label: text,
45
+ href,
46
+ // Main (the brand colour), quiet (an outline), or the accent colour: a button that stands out on a photo or a dark band.
47
+ style: z.enum(["primary", "secondary", "accent"]).optional().meta({ title: "Style", "x-labels": { primary: "Main", secondary: "Quiet", accent: "Accent colour" } }),
48
+ });
37
49
  export const time = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, { error: "A time like 08:00 or 17:30" }).meta({ "x-error": "A time like 08:00 or 17:30" });
38
50
  export const DAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
39
51
  /** What every block has: a place on the page, for links like /#contact. */
40
52
  const blockBase = { id: z.string().regex(/^[a-z][\w-]*$/).optional() };
41
53
  /**
42
- * How a section sits on the page, for a site that offers it (defineSite's `placement`): its alignment,
43
- * width, spacing and the screens it shows on. The site's design says what each means at every width,
44
- * so a choice here stays sensible on a phone. CalOS shows it as the section's Layout. (A block's own
45
- * `layout`, a site's choice of how it's drawn, is a different thing and keeps its name.)
54
+ * How a section sits on the page, offered on every section (defineSite's `placement`, on unless a site
55
+ * turns it off): its alignment, width, spacing, the screens it shows on, and what's behind it. The
56
+ * site's design says what each means at every width, so a choice here stays sensible on a phone.
57
+ * CalOS shows it as the section's Layout. (A block's own `layout`, a site's choice of how it's drawn,
58
+ * is a different thing and keeps its name.)
59
+ *
60
+ * Its background is drawn by KitBlocks (../next/blocks.tsx) as a frame around the section: a colour
61
+ * (one of the page's, by its role, or one of the site's own, by its slug) and a photo, shaded so the
62
+ * words over it read.
46
63
  */
47
64
  export const placement = z
48
65
  .object({
@@ -50,9 +67,24 @@ export const placement = z
50
67
  width: z.enum(["narrow", "normal", "wide"]).default("normal").meta({ title: "Width" }),
51
68
  spacing: z.enum(["compact", "normal", "roomy"]).default("normal").meta({ title: "Spacing" }),
52
69
  show: z.enum(["all", "desktop", "phone"]).default("all").meta({ title: "Show on" }),
70
+ // A line across the page's full width above it, below it or both (a strip between two rules), quiet (the Look's line colour) or strong (its text colour).
71
+ lines: z.enum(["none", "top", "bottom", "both"]).optional().meta({ title: "Lines", "x-labels": { none: "None", top: "Above", bottom: "Below", both: "Above and below" } }),
72
+ lineColour: z.enum(["quiet", "strong"]).optional().meta({ title: "Line colour", "x-labels": { quiet: "Quiet", strong: "Strong" } }),
73
+ background: z
74
+ .object({
75
+ color: z
76
+ .string()
77
+ .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, { error: "One of the site's colours" })
78
+ .optional()
79
+ .meta({ title: "Colour", "x-ui": "swatch", "x-error": "One of the site's colours" }),
80
+ photo: image.optional().meta({ title: "Photo" }),
81
+ overlay: z.enum(["none", "light", "dark"]).default("dark").meta({ title: "Shade over the photo" }),
82
+ })
83
+ .optional()
84
+ .meta({ title: "Background" }),
53
85
  })
54
86
  .meta({ title: "Layout" });
55
- /** A section's placement as class names, only what differs from the usual: "place-align-center place-width-narrow", or "". */
87
+ /** A section's placement as class names, only what differs from the usual: "place-align-center place-width-narrow", or "". Its background is the frame's (frameClass). */
56
88
  export function placementClass(block) {
57
89
  const l = block.placement ?? {};
58
90
  const usual = { align: "left", width: "normal", spacing: "normal", show: "all" };
@@ -61,6 +93,63 @@ export function placementClass(block) {
61
93
  .map((k) => `place-${k}-${l[k]}`)
62
94
  .join(" ");
63
95
  }
96
+ /** The classes of the lines across a section's full width (KitBlocks wraps it in them), or "" for none. */
97
+ export function linesClass(block) {
98
+ const l = block.placement?.lines;
99
+ if (!l || l === "none")
100
+ return "";
101
+ return ["kit-lines", `kit-lines-${l}`, block.placement?.lineColour === "strong" ? "kit-lines-strong" : ""].filter(Boolean).join(" ");
102
+ }
103
+ /** Whether a section has something behind it (a colour or a photo), and so a frame. */
104
+ export function hasBackground(block) {
105
+ const b = block.placement?.background;
106
+ return Boolean(b && (b.color || b.photo?.src));
107
+ }
108
+ /** What a page's colours are when a site has no Look: a light page, its other colours dark. */
109
+ const NO_LOOK = { "--look-page": "#ffffff", "--look-soft": "#f3f3f3", "--look-brand": "#111111", "--look-accent": "#111111", "--look-ink": "#111111" };
110
+ /**
111
+ * Whether a section's background is dark, so its words go light: under a photo, by its shade (dark
112
+ * or none: light words; light: dark words); else by its colour's value in the Look (`vars`, from
113
+ * lookVars, at build and in the preview alike). A colour it can't find counts as light.
114
+ */
115
+ export function backgroundIsDark(block, vars = {}) {
116
+ const b = block.placement?.background;
117
+ if (!b)
118
+ return false;
119
+ if (b.photo?.src)
120
+ return (b.overlay ?? "dark") !== "light";
121
+ if (!b.color)
122
+ return false;
123
+ const value = colourOf(b.color, { ...NO_LOOK, ...vars });
124
+ return value ? isDark(value) : false;
125
+ }
126
+ /**
127
+ * The classes of the frame KitBlocks draws around a section with a background, or "" for one
128
+ * without: `kit-frame`, its colour (`kit-bg-soft`, or `kit-bg-swatch-2` for the site's second own
129
+ * colour, found in `vars`), `kit-frame-photo kit-overlay-dark` under a photo, and `kit-frame-dark`
130
+ * when its words go light (backgroundIsDark). sections.css draws them from the Look's variables.
131
+ */
132
+ export function frameClass(block, vars = {}) {
133
+ if (!hasBackground(block))
134
+ return "";
135
+ const b = block.placement.background;
136
+ const out = ["kit-frame"];
137
+ if (b.color) {
138
+ if (COLOUR_ROLES.includes(b.color))
139
+ out.push(`kit-bg-${b.color}`);
140
+ else {
141
+ const value = vars[`--look-swatch-${b.color}`];
142
+ const n = value ? Array.from({ length: 12 }, (_, i) => i + 1).find((i) => vars[`--look-swatch-${i}`] === value) : undefined;
143
+ if (n)
144
+ out.push(`kit-bg-swatch-${n}`);
145
+ }
146
+ }
147
+ if (b.photo?.src)
148
+ out.push("kit-frame-photo", `kit-overlay-${b.overlay ?? "dark"}`);
149
+ if (backgroundIsDark(block, vars))
150
+ out.push("kit-frame-dark");
151
+ return out.join(" ");
152
+ }
64
153
  const address = z.object({
65
154
  street: text,
66
155
  locality: text,
@@ -111,7 +200,7 @@ const pageBase = (trailingSlash) => ({
111
200
  });
112
201
  /**
113
202
  * The site's menu, as its header draws it: the one edited in CalOS (its links in order, one picked out,
114
- * and the header's button), else the pages that have a menu label, in page order (and no button: the
203
+ * each with the links under it if it has any, and the header's button), else the pages that have a menu label, in page order (and no button: the
115
204
  * site's own default).
116
205
  */
117
206
  export function menuOf(site) {
@@ -129,14 +218,85 @@ export function footerOf(site) {
129
218
  * A site's content shape from its blocks: each a name and the fields it has. The site's own block
130
219
  * type is `z.output<typeof content.schema>["pages"][number]["blocks"][number]`.
131
220
  */
221
+ /** A list's key and a field's: what Shows and Repeats for name them by (`lists.products.items`, `this.price`). */
222
+ const listKey = z.string().regex(/^[a-z][a-zA-Z0-9]{0,39}$/, { error: "Letters and numbers, starting with a lowercase letter, like products or teamMembers" });
223
+ const fieldKey = z
224
+ .string()
225
+ .regex(/^[a-z][a-zA-Z0-9]{0,39}$/, { error: "Letters and numbers, starting with a lowercase letter, like name or price" })
226
+ .refine((k) => k !== "id" && k !== "slug", { error: "id and slug are an item's own" });
227
+ /** An item: its id, its address part, and its values (each checked against its field's kind: listProblems). */
228
+ const listItem = z
229
+ .object({
230
+ id: z.string().regex(/^[\w-]{1,64}$/),
231
+ slug: z.string().max(80).regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, { error: "Lowercase letters, numbers and dashes, like walnut-desk" }),
232
+ })
233
+ .catchall(z.unknown());
234
+ /** Each kind of a list's field, as a value is checked. */
235
+ const VALUE = {
236
+ text: z.string().trim().max(300),
237
+ longText: z.string().max(5000),
238
+ image,
239
+ link: href,
240
+ number: z.number().finite(),
241
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, { error: "A date like 2026-11-09" }),
242
+ };
243
+ /**
244
+ * What a list's shape can't say alone: fields named once, each item's values of their field's kind
245
+ * and no others, ids and addresses used once, a page titled and described by a field of words, and no
246
+ * item at the address of one of the site's pages.
247
+ */
248
+ function listProblems(site) {
249
+ const out = [];
250
+ const lists = (site.lists ?? {});
251
+ const pagePaths = new Set(site.pages.map((p) => p.path));
252
+ for (const [key, list] of Object.entries(lists)) {
253
+ const at = ["lists", key];
254
+ const fields = new Map();
255
+ list.fields.forEach((f, i) => {
256
+ if (fields.has(f.key))
257
+ out.push({ path: [...at, "fields", i, "key"], message: `Two fields are called ${f.key}` });
258
+ fields.set(f.key, f.kind);
259
+ });
260
+ const ids = new Set();
261
+ const slugs = new Set();
262
+ list.items.forEach((item, i) => {
263
+ if (ids.has(item.id))
264
+ out.push({ path: [...at, "items", i, "id"], message: `Two items have the id ${item.id}` });
265
+ ids.add(item.id);
266
+ if (slugs.has(item.slug))
267
+ out.push({ path: [...at, "items", i, "slug"], message: `Two items have the address ${item.slug}` });
268
+ slugs.add(item.slug);
269
+ for (const [k, v] of Object.entries(item)) {
270
+ if (k === "id" || k === "slug" || v === undefined || v === null || v === "")
271
+ continue;
272
+ const kind = fields.get(k);
273
+ if (!kind)
274
+ out.push({ path: [...at, "items", i, k], message: `${k} isn't one of ${list.name}'s fields` });
275
+ else if (!VALUE[kind].safeParse(v).success)
276
+ out.push({ path: [...at, "items", i, k], message: `Not a ${kind === "longText" ? "text" : kind} value` });
277
+ }
278
+ if (list.page && pagePaths.has(itemPath(list.page, item.slug, site.trailingSlash !== false)))
279
+ out.push({ path: [...at, "items", i, "slug"], message: `There's a page at ${itemPath(list.page, item.slug, site.trailingSlash !== false)} already` });
280
+ });
281
+ if (list.page) {
282
+ const words = (k) => !k || fields.get(k) === "text" || fields.get(k) === "longText";
283
+ if (!fields.has(list.page.title) || fields.get(list.page.title) !== "text")
284
+ out.push({ path: [...at, "page", "title"], message: "The title is one of the list's fields of words" });
285
+ if (list.page.description && (!fields.has(list.page.description) || !words(list.page.description)))
286
+ out.push({ path: [...at, "page", "description"], message: "The description is one of the list's fields of words" });
287
+ }
288
+ }
289
+ return out;
290
+ }
132
291
  export function defineSite(blocks, o = {}) {
133
292
  const names = Object.keys(blocks);
134
293
  if (!names.length)
135
294
  throw new Error("A site needs at least one kind of block");
295
+ const placed = o.placement ?? true;
136
296
  const union = z.discriminatedUnion("type",
137
297
  // Each kind keeps what the site said of it (its title, for CalOS to call it by).
138
298
  names.map((type) => {
139
- const kind = z.object({ type: z.literal(type), ...blockBase, ...(o.placement ? { placement: placement.optional() } : {}) }).extend(blocks[type].shape);
299
+ const kind = z.object({ type: z.literal(type), ...blockBase, ...(placed ? { placement: placement.optional() } : {}) }).extend(blocks[type].shape);
140
300
  const meta = blocks[type].meta();
141
301
  return meta ? kind.meta(meta) : kind;
142
302
  }));
@@ -144,6 +304,14 @@ export function defineSite(blocks, o = {}) {
144
304
  const page = z.object({ ...pageBase(trailingSlash), ...o.pageExtend, blocks: z.array(union).min(1) });
145
305
  const schema = z
146
306
  .object({
307
+ /** Which format of the kit's the content is in (../content-migrate.ts): none for the first; set by the migrations, never edited. */
308
+ format: z
309
+ .number()
310
+ .int()
311
+ .min(0)
312
+ .max(CONTENT_FORMAT, { error: `A newer format than this kit's (${CONTENT_FORMAT}): the site needs a newer @caltho/site-kit` })
313
+ .optional()
314
+ .meta({ title: "Content format", readOnly: true, "x-ui": "hidden" }),
147
315
  /** The address it's served at once live (its main domain). */
148
316
  url: z.url({ protocol: /^https$/, error: "The site's address, starting https://" }).meta({ title: "Web address" }),
149
317
  lang: z.string().default("en-US").meta({ title: "Language" }),
@@ -153,8 +321,23 @@ export function defineSite(blocks, o = {}) {
153
321
  /** The header's links in order, and its button: edited as one list in CalOS. Left out, the pages' menu labels are used. */
154
322
  menu: z
155
323
  .object({
156
- links: z.array(z.object({ label: text, href, emphasis: z.boolean().default(false).meta({ title: "Emphasize" }) })).max(10),
157
- button: z.object({ label: text, href }).optional().meta({ title: "Button" }),
324
+ links: z
325
+ .array(z.object({
326
+ label: text,
327
+ href,
328
+ emphasis: z.boolean().default(false).meta({ title: "Emphasize" }),
329
+ children: z.array(z.object({ label: text, href })).max(8).optional().meta({ title: "Links under it" }),
330
+ }))
331
+ .max(10),
332
+ button: z
333
+ .object({
334
+ label: text,
335
+ href,
336
+ // Drawn in the theme's Main (the usual), Accent or Quiet, or one of the business's own button styles.
337
+ style: buttonStyleName.optional().meta({ title: "Style", "x-ui": "button-style" }),
338
+ })
339
+ .optional()
340
+ .meta({ title: "Button" }),
158
341
  })
159
342
  .optional()
160
343
  .meta({ title: "Menu" }),
@@ -167,11 +350,48 @@ export function defineSite(blocks, o = {}) {
167
350
  })
168
351
  .optional()
169
352
  .meta({ title: "Footer" }),
353
+ /**
354
+ * A header or footer of the business's own, built as a tree like a built section (../tree), in
355
+ * place of the kit's: its links, logo and button are elements, a phone menu an accordion. Left
356
+ * out, the kit's header and footer (from the menu, footer and Look above).
357
+ */
358
+ ownHeader: built.optional().meta({ title: "Your own header", "x-ui": "hidden" }),
359
+ ownFooter: built.optional().meta({ title: "Your own footer", "x-ui": "hidden" }),
360
+ /** A cookie banner, and visits counted only once a visitor accepts: off unless turned on (../consent.ts). */
361
+ consent: consent.optional(),
170
362
  /** Old addresses kept answering, when CalOS doesn't supply them at build. */
171
363
  redirects: z.array(z.object({ from: z.string().startsWith("/"), to: href, status: z.union([z.literal(301), z.literal(302)]).default(301) })).default([]),
172
364
  ...(o.look ? { look: lookSchema(o.look) } : {}),
173
365
  ...o.extend,
174
366
  pages: z.array(page).min(1),
367
+ /**
368
+ * The site's lists (./lists.ts): items of one kind kept once, repeated by built sections and,
369
+ * where a list has a page, each at its own address from one design.
370
+ */
371
+ lists: z
372
+ .record(listKey, z.object({
373
+ name: text.meta({ title: "Name" }),
374
+ fields: z.array(z.object({ key: fieldKey, label: text, kind: z.enum(FIELD_KINDS) })).max(30),
375
+ items: z.array(listItem).max(2000).default([]),
376
+ page: z
377
+ .object({
378
+ path: pageBase(trailingSlash).path,
379
+ title: fieldKey.meta({ title: "The field each page is titled by" }),
380
+ description: fieldKey.optional().meta({ title: "The field each page is described by" }),
381
+ blocks: z.array(union).min(1),
382
+ })
383
+ .optional()
384
+ .meta({ title: "A page for each item" }),
385
+ /** Where its items come from, when CalOS fills it from one of the business's tables: kept by CalOS, never drawn. */
386
+ source: z
387
+ .object({ table: z.string().max(60), fields: z.record(z.string(), z.string().max(60)), publish: z.enum(["draft", "auto"]).default("draft") })
388
+ .optional()
389
+ .meta({ "x-ui": "hidden" }),
390
+ }))
391
+ .default({})
392
+ .meta({ title: "Lists", "x-ui": "hidden" }),
393
+ /** Trees kept once and used by name (`use` blocks), changed in one place for every use: on a site that takes built sections. */
394
+ ...(names.includes("built") || names.includes("use") ? { components: z.array(component).max(50).default([]).meta({ title: "Components" }) } : {}),
175
395
  /** Sections the business put together and named, to add again anywhere ("Package card"): CalOS offers them beside the site's own kinds. */
176
396
  saved: z
177
397
  .array(z.object({ name: text.meta({ title: "Name" }), block: union }))
@@ -186,6 +406,13 @@ export function defineSite(blocks, o = {}) {
186
406
  if (o.look)
187
407
  for (const i of lookProblems(s.look, o.look))
188
408
  ctx.addIssue({ code: "custom", path: i.path, message: i.message });
409
+ if (placed)
410
+ for (const i of backgroundProblems(s, o.look))
411
+ ctx.addIssue({ code: "custom", path: i.path, message: i.message });
412
+ for (const i of builtProblems(s))
413
+ ctx.addIssue({ code: "custom", path: i.path, message: i.message });
414
+ for (const i of listProblems(s))
415
+ ctx.addIssue({ code: "custom", path: i.path, message: i.message });
189
416
  o.refine?.(s, ctx);
190
417
  });
191
418
  return {
@@ -201,9 +428,64 @@ export function defineSite(blocks, o = {}) {
201
428
  csp: o.csp ?? {},
202
429
  share: o.share,
203
430
  look: o.look,
204
- /** The declaration CalOS draws its editing forms from. */
205
- jsonSchema: () => z.toJSONSchema(schema, { io: "input", unrepresentable: "any" }),
431
+ /**
432
+ * The declaration CalOS draws its editing forms from. A site with sections of the business's own
433
+ * (`own`) carries the ready-made ones it can start from, each one this site's schema takes, as
434
+ * `x-presets`: [{ key, name, description, block }].
435
+ */
436
+ jsonSchema: () => {
437
+ const json = z.toJSONSchema(schema, { io: "input", unrepresentable: "any" });
438
+ if (names.includes("own"))
439
+ json["x-presets"] = presets.filter((p) => union.safeParse(p.block).success);
440
+ return json;
441
+ },
442
+ };
443
+ }
444
+ /** A section's background naming a colour the site hasn't: one of the page's roles, or one of the Look's own colours by its slug. */
445
+ function backgroundProblems(site, look) {
446
+ const palette = site.look?.palette ?? look?.palette ?? [];
447
+ const known = new Set([...COLOUR_ROLES, ...palette.map((s) => swatchSlug(s.name))]);
448
+ const out = [];
449
+ const check = (b, at) => {
450
+ const color = b.placement?.background?.color;
451
+ if (color && !known.has(color))
452
+ out.push({ path: [...at, "placement", "background", "color"], message: `"${color}" isn't one of the site's colours: choose another` });
453
+ };
454
+ site.pages.forEach((p, i) => p.blocks.forEach((b, j) => check(b, ["pages", i, "blocks", j])));
455
+ const saved = site.saved ?? [];
456
+ saved.forEach((s, i) => s?.block && check(s.block, ["saved", i, "block"]));
457
+ return out;
458
+ }
459
+ /**
460
+ * What a built section's tree can't say alone (../tree: depth, size, an id used twice), for every
461
+ * tree the content keeps, and that each component a `use` block names is one the site has.
462
+ */
463
+ function builtProblems(site) {
464
+ const out = [];
465
+ const components = Array.isArray(site.components) ? site.components : [];
466
+ const keys = new Set();
467
+ components.forEach((c, i) => {
468
+ if (c?.key && keys.has(c.key))
469
+ out.push({ path: ["components", i, "key"], message: `Two components are called ${c.key}` });
470
+ if (c?.key)
471
+ keys.add(c.key);
472
+ if (c?.root)
473
+ for (const m of treeProblems(c.root))
474
+ out.push({ path: ["components", i, "root"], message: m });
475
+ });
476
+ const design = site.consent?.design;
477
+ if (design && !keys.has(design))
478
+ out.push({ path: ["consent", "design"], message: `The site has no component called ${design}` });
479
+ const check = (b, at) => {
480
+ if (b.type === "built" && b.root && typeof b.root === "object")
481
+ for (const m of treeProblems(b.root))
482
+ out.push({ path: [...at, "root"], message: m });
483
+ if (b.type === "use" && typeof b.component === "string" && !keys.has(b.component))
484
+ out.push({ path: [...at, "component"], message: `The site has no component called ${b.component}` });
206
485
  };
486
+ site.pages.forEach((p, i) => p.blocks.forEach((b, j) => check(b, ["pages", i, "blocks", j])));
487
+ (site.saved ?? []).forEach((s, i) => s?.block && check(s.block, ["saved", i, "block"]));
488
+ return out;
207
489
  }
208
490
  /** Search results cut a title at about 60 characters and a description at about 160: said, not refused, since the page still works. */
209
491
  function lengthWarnings(site) {
@@ -0,0 +1,69 @@
1
+ import { z } from "zod";
2
+ import type { Block, Page } from "./index.ts";
3
+ /** The kinds a list's field may be. */
4
+ export declare const FIELD_KINDS: readonly ["text", "longText", "image", "link", "number", "date"];
5
+ export type FieldKind = (typeof FIELD_KINDS)[number];
6
+ export type ListField = {
7
+ key: string;
8
+ label: string;
9
+ kind: FieldKind;
10
+ };
11
+ /** An item: its id (stable, from CalOS), its address part, and a value per field. */
12
+ export type ListItem = {
13
+ id: string;
14
+ slug: string;
15
+ [field: string]: unknown;
16
+ };
17
+ /** A list's page: where its items live, the fields that title and describe each, and its design. */
18
+ export type ListPage = {
19
+ path: string;
20
+ title: string;
21
+ description?: string;
22
+ blocks: Block[];
23
+ };
24
+ export type List = {
25
+ name: string;
26
+ fields: ListField[];
27
+ items: ListItem[];
28
+ page?: ListPage;
29
+ };
30
+ /** An item page's blocks carry the item they're drawn with (read by built sections as `this.`). */
31
+ export declare const THIS_ITEM = "thisItem";
32
+ type WithLists = {
33
+ pages: Page[];
34
+ lists?: Record<string, List>;
35
+ trailingSlash?: boolean;
36
+ };
37
+ /** An item page's address: under its list's page, by its address part. */
38
+ export declare const itemPath: (page: ListPage, slug: string, trailingSlash?: boolean) => string;
39
+ /** Every item page of every list that has a page, as pages: its design's blocks, each carrying the item. */
40
+ export declare function itemPages(site: WithLists): Page[];
41
+ /**
42
+ * A section's items taken from one of the site's lists, as CalOS sets it when the business picks a
43
+ * list for a section that repeats things (Services, Team, Reviews): which list, the section's array
44
+ * it fills (`into`, fixed by the section), and each of an item's fields from which of the list's
45
+ * (PAGE_LINK: the item's own page). The section's typed items stay, unused, until the list is unpicked.
46
+ */
47
+ export type FromList = {
48
+ list: string;
49
+ into: string;
50
+ fields: Record<string, string>;
51
+ };
52
+ /** In a FromList's fields: the item's own page, where its list has pages. */
53
+ export declare const PAGE_LINK = "@page";
54
+ /**
55
+ * The field a section that repeats things adds to say its items can come from a list: `into` names
56
+ * its array (`items`, `people`). CalOS offers the site's lists for it and matches the fields.
57
+ */
58
+ export declare const fromList: (into: string) => z.ZodOptional<z.ZodObject<{
59
+ list: z.ZodString;
60
+ into: z.ZodDefault<z.ZodLiteral<string>>;
61
+ fields: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
62
+ }, z.core.$strip>>;
63
+ /** A block with its items from a list, where it takes them from one (and the list is there): each item's fields as matched. */
64
+ export declare function withListItems(block: Block, site: Pick<WithLists, "lists" | "trailingSlash">): Block;
65
+ /** The site's pages and its lists' item pages: every address the site answers at, each section's items from its list where it takes them from one. */
66
+ export declare const allPages: (site: WithLists) => Page[];
67
+ /** Whether a page is an item page (its blocks carry an item). */
68
+ export declare const itemOf: (page: Page) => ListItem | null;
69
+ export {};
@@ -0,0 +1,77 @@
1
+ // A site's lists (cal-os docs/design/site-lists.md): items of one kind (products, team members,
2
+ // testimonials) kept once in the content, repeated by built sections (Repeats for
3
+ // `lists.<key>.items`), and, where a list has a page, each item at its own address, drawn from one
4
+ // design: `/products/walnut-desk/` is the products page's blocks with that item, read as `this.<field>`.
5
+ // Pure: the build, the preview and CalOS find the same pages.
6
+ import { z } from "zod";
7
+ /** The kinds a list's field may be. */
8
+ export const FIELD_KINDS = ["text", "longText", "image", "link", "number", "date"];
9
+ /** An item page's blocks carry the item they're drawn with (read by built sections as `this.`). */
10
+ export const THIS_ITEM = "thisItem";
11
+ const listsOf = (site) => Object.entries(site.lists ?? {}).filter(([, l]) => l && typeof l === "object");
12
+ /** An item page's address: under its list's page, by its address part. */
13
+ export const itemPath = (page, slug, trailingSlash = true) => `${page.path.replace(/\/$/, "")}/${slug}${trailingSlash ? "/" : ""}`;
14
+ /** Words for a value used as a page's title or description. */
15
+ const words = (v) => (typeof v === "string" ? v : typeof v === "number" ? String(v) : "");
16
+ /** Every item page of every list that has a page, as pages: its design's blocks, each carrying the item. */
17
+ export function itemPages(site) {
18
+ const out = [];
19
+ for (const [, list] of listsOf(site)) {
20
+ const page = list.page;
21
+ if (!page || !Array.isArray(page.blocks) || !Array.isArray(list.items))
22
+ continue;
23
+ // The item's first picture is what it's shared with (no card made for each item).
24
+ const picture = list.fields.find((f) => f.kind === "image")?.key;
25
+ for (const item of list.items) {
26
+ if (!item?.slug)
27
+ continue;
28
+ const share = picture ? item[picture] : undefined;
29
+ out.push({
30
+ path: itemPath(page, item.slug, site.trailingSlash !== false),
31
+ title: words(item[page.title]) || item.slug,
32
+ description: page.description ? words(item[page.description]) : "",
33
+ noindex: false,
34
+ ...(share && typeof share === "object" && share.src ? { share } : {}),
35
+ blocks: page.blocks.map((b) => ({ ...b, [THIS_ITEM]: item })),
36
+ });
37
+ }
38
+ }
39
+ return out;
40
+ }
41
+ /** In a FromList's fields: the item's own page, where its list has pages. */
42
+ export const PAGE_LINK = "@page";
43
+ /**
44
+ * The field a section that repeats things adds to say its items can come from a list: `into` names
45
+ * its array (`items`, `people`). CalOS offers the site's lists for it and matches the fields.
46
+ */
47
+ export const fromList = (into) => z
48
+ .object({
49
+ list: z.string().regex(/^[\w-]{1,60}$/),
50
+ into: z.literal(into).default(into),
51
+ fields: z.record(z.string().regex(/^[\w-]{1,60}$/), z.string().regex(/^(@page|[\w-]{1,60})$/)).default({}),
52
+ })
53
+ .optional()
54
+ .meta({ title: "Items from a list", "x-ui": "list-source", "x-into": into });
55
+ /** A block with its items from a list, where it takes them from one (and the list is there): each item's fields as matched. */
56
+ export function withListItems(block, site) {
57
+ const from = block.from;
58
+ if (!from?.list || !from.into)
59
+ return block;
60
+ const list = site.lists?.[from.list];
61
+ if (!list || !Array.isArray(list.items))
62
+ return block;
63
+ const kinds = new Map(list.fields.map((f) => [f.key, f.kind]));
64
+ const items = list.items.map((item) => Object.fromEntries(Object.entries(from.fields ?? {}).flatMap(([to, src]) => {
65
+ const v = src === PAGE_LINK ? (list.page && item.slug ? itemPath(list.page, item.slug, site.trailingSlash !== false) : undefined) : item[src];
66
+ if (v !== undefined && v !== null && v !== "")
67
+ return [[to, v]];
68
+ // Words left empty are empty words, so a section drawing them as text has something to draw.
69
+ const kind = src === PAGE_LINK ? undefined : kinds.get(src);
70
+ return kind === "text" || kind === "longText" ? [[to, ""]] : [];
71
+ })));
72
+ return { ...block, [from.into]: items };
73
+ }
74
+ /** The site's pages and its lists' item pages: every address the site answers at, each section's items from its list where it takes them from one. */
75
+ export const allPages = (site) => [...site.pages, ...itemPages(site)].map((p) => (p.blocks.some((b) => b.from?.list) ? { ...p, blocks: p.blocks.map((b) => withListItems(b, site)) } : p));
76
+ /** Whether a page is an item page (its blocks carry an item). */
77
+ export const itemOf = (page) => page.blocks[0]?.[THIS_ITEM] ?? null;
@@ -0,0 +1 @@
1
+ export {};