@caltho/site-kit 0.2.10 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/README.md +146 -16
  2. package/dist/behaviors/accordion.d.ts +4 -0
  3. package/dist/behaviors/accordion.js +56 -0
  4. package/dist/behaviors/carousel.d.ts +4 -0
  5. package/dist/behaviors/carousel.js +127 -0
  6. package/dist/behaviors/filter.d.ts +1 -0
  7. package/dist/behaviors/filter.js +25 -0
  8. package/dist/behaviors/index.d.ts +14 -0
  9. package/dist/behaviors/index.js +78 -0
  10. package/dist/behaviors/motion.d.ts +13 -0
  11. package/dist/behaviors/motion.js +88 -0
  12. package/dist/behaviors/tabs.d.ts +3 -0
  13. package/dist/behaviors/tabs.js +55 -0
  14. package/dist/built/css.d.ts +2 -0
  15. package/dist/built/css.js +26 -0
  16. package/dist/built/theme.d.ts +19 -0
  17. package/dist/built/theme.js +86 -0
  18. package/dist/built/tree.d.ts +24 -0
  19. package/dist/built/tree.js +96 -0
  20. package/dist/built.test.d.ts +1 -0
  21. package/dist/built.test.js +257 -0
  22. package/dist/cli/check.js +31 -1
  23. package/dist/consent.d.ts +33 -0
  24. package/dist/consent.js +36 -0
  25. package/dist/consent.test.d.ts +1 -0
  26. package/dist/consent.test.js +61 -0
  27. package/dist/content/index.d.ts +450 -33
  28. package/dist/content/index.js +297 -15
  29. package/dist/content/lists.d.ts +69 -0
  30. package/dist/content/lists.js +77 -0
  31. package/dist/content/lists.test.d.ts +1 -0
  32. package/dist/content/lists.test.js +257 -0
  33. package/dist/content/look.d.ts +260 -9
  34. package/dist/content/look.js +386 -22
  35. package/dist/content-migrate.d.ts +51 -0
  36. package/dist/content-migrate.js +69 -0
  37. package/dist/finish.d.ts +1 -1
  38. package/dist/finish.js +27 -6
  39. package/dist/fonts/families/dm-sans/LICENSE +93 -0
  40. package/dist/fonts/families/dm-sans/dm-sans-latin-ext-wght-italic.woff2 +0 -0
  41. package/dist/fonts/families/dm-sans/dm-sans-latin-ext-wght-normal.woff2 +0 -0
  42. package/dist/fonts/families/dm-sans/dm-sans-latin-wght-italic.woff2 +0 -0
  43. package/dist/fonts/families/dm-sans/dm-sans-latin-wght-normal.woff2 +0 -0
  44. package/dist/fonts/families/fraunces/LICENSE +93 -0
  45. package/dist/fonts/families/fraunces/fraunces-latin-ext-wght-italic.woff2 +0 -0
  46. package/dist/fonts/families/fraunces/fraunces-latin-ext-wght-normal.woff2 +0 -0
  47. package/dist/fonts/families/fraunces/fraunces-latin-wght-italic.woff2 +0 -0
  48. package/dist/fonts/families/fraunces/fraunces-latin-wght-normal.woff2 +0 -0
  49. package/dist/fonts/families/inter/LICENSE +93 -0
  50. package/dist/fonts/families/inter/inter-latin-ext-wght-italic.woff2 +0 -0
  51. package/dist/fonts/families/inter/inter-latin-ext-wght-normal.woff2 +0 -0
  52. package/dist/fonts/families/inter/inter-latin-wght-italic.woff2 +0 -0
  53. package/dist/fonts/families/inter/inter-latin-wght-normal.woff2 +0 -0
  54. package/dist/fonts/families/lora/LICENSE +93 -0
  55. package/dist/fonts/families/lora/lora-latin-ext-wght-italic.woff2 +0 -0
  56. package/dist/fonts/families/lora/lora-latin-ext-wght-normal.woff2 +0 -0
  57. package/dist/fonts/families/lora/lora-latin-wght-italic.woff2 +0 -0
  58. package/dist/fonts/families/lora/lora-latin-wght-normal.woff2 +0 -0
  59. package/dist/fonts/families/manrope/LICENSE +93 -0
  60. package/dist/fonts/families/manrope/manrope-latin-ext-wght-normal.woff2 +0 -0
  61. package/dist/fonts/families/manrope/manrope-latin-wght-normal.woff2 +0 -0
  62. package/dist/fonts/families/newsreader/LICENSE +93 -0
  63. package/dist/fonts/families/newsreader/newsreader-latin-ext-wght-italic.woff2 +0 -0
  64. package/dist/fonts/families/newsreader/newsreader-latin-ext-wght-normal.woff2 +0 -0
  65. package/dist/fonts/families/newsreader/newsreader-latin-wght-italic.woff2 +0 -0
  66. package/dist/fonts/families/newsreader/newsreader-latin-wght-normal.woff2 +0 -0
  67. package/dist/fonts/families/outfit/LICENSE +93 -0
  68. package/dist/fonts/families/outfit/outfit-latin-ext-wght-normal.woff2 +0 -0
  69. package/dist/fonts/families/outfit/outfit-latin-wght-normal.woff2 +0 -0
  70. package/dist/fonts/families/playfair-display/LICENSE +93 -0
  71. package/dist/fonts/families/playfair-display/playfair-display-latin-ext-wght-italic.woff2 +0 -0
  72. package/dist/fonts/families/playfair-display/playfair-display-latin-ext-wght-normal.woff2 +0 -0
  73. package/dist/fonts/families/playfair-display/playfair-display-latin-wght-italic.woff2 +0 -0
  74. package/dist/fonts/families/playfair-display/playfair-display-latin-wght-normal.woff2 +0 -0
  75. package/dist/fonts/families/source-serif-4/LICENSE +93 -0
  76. package/dist/fonts/families/source-serif-4/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
  77. package/dist/fonts/families/source-serif-4/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
  78. package/dist/fonts/families/source-serif-4/source-serif-4-latin-wght-italic.woff2 +0 -0
  79. package/dist/fonts/families/source-serif-4/source-serif-4-latin-wght-normal.woff2 +0 -0
  80. package/dist/fonts/families/work-sans/LICENSE +93 -0
  81. package/dist/fonts/families/work-sans/work-sans-latin-ext-wght-italic.woff2 +0 -0
  82. package/dist/fonts/families/work-sans/work-sans-latin-ext-wght-normal.woff2 +0 -0
  83. package/dist/fonts/families/work-sans/work-sans-latin-wght-italic.woff2 +0 -0
  84. package/dist/fonts/families/work-sans/work-sans-latin-wght-normal.woff2 +0 -0
  85. package/dist/fonts/google.json +1 -0
  86. package/dist/fonts-google.d.ts +35 -0
  87. package/dist/fonts-google.js +70 -0
  88. package/dist/fonts.css +365 -0
  89. package/dist/frame.test.d.ts +1 -0
  90. package/dist/frame.test.js +58 -0
  91. package/dist/migrate.d.ts +45 -0
  92. package/dist/migrate.js +284 -0
  93. package/dist/next/block-mark.d.ts +7 -0
  94. package/dist/next/block-mark.js +16 -0
  95. package/dist/next/blocks.d.ts +11 -0
  96. package/dist/next/blocks.js +35 -0
  97. package/dist/next/built.d.ts +40 -0
  98. package/dist/next/built.js +318 -0
  99. package/dist/next/consent.d.ts +4 -0
  100. package/dist/next/consent.js +13 -0
  101. package/dist/next/data.d.ts +6 -2
  102. package/dist/next/data.js +10 -4
  103. package/dist/next/frame.d.ts +42 -0
  104. package/dist/next/frame.js +73 -0
  105. package/dist/next/index.d.ts +8 -2
  106. package/dist/next/index.js +9 -3
  107. package/dist/next/menu-group.d.ts +11 -0
  108. package/dist/next/menu-group.js +33 -0
  109. package/dist/next/metadata.js +3 -2
  110. package/dist/next/parts/Img.d.ts +6 -1
  111. package/dist/next/parts/Img.js +11 -3
  112. package/dist/next/parts/LookStyle.js +3 -1
  113. package/dist/next/preview.js +511 -27
  114. package/dist/next/sections.d.ts +19 -10
  115. package/dist/next/sections.js +134 -17
  116. package/dist/policies.js +22 -4
  117. package/dist/prepare.d.ts +15 -1
  118. package/dist/prepare.js +125 -3
  119. package/dist/preview-match.d.ts +29 -0
  120. package/dist/preview-match.js +58 -1
  121. package/dist/schema.d.ts +10 -0
  122. package/dist/schema.js +29 -1
  123. package/dist/sections/icons.d.ts +49 -0
  124. package/dist/sections/icons.js +55 -0
  125. package/dist/sections/index.d.ts +188 -12
  126. package/dist/sections/index.js +136 -10
  127. package/dist/sections/presets.d.ts +13 -0
  128. package/dist/sections/presets.js +300 -0
  129. package/dist/sections.css +1584 -41
  130. package/dist/sections.test.js +276 -14
  131. package/dist/seo.js +2 -1
  132. package/dist/tree/behaviors.d.ts +50 -0
  133. package/dist/tree/behaviors.js +67 -0
  134. package/dist/tree/classes.d.ts +24 -0
  135. package/dist/tree/classes.js +94 -0
  136. package/dist/tree/index.d.ts +145 -0
  137. package/dist/tree/index.js +196 -0
  138. package/dist/tree/messages.d.ts +45 -0
  139. package/dist/tree/messages.js +7 -0
  140. package/package.json +22 -3
@@ -1,10 +1,21 @@
1
- // A site's Look, chosen in CalOS: its fonts (from the ones the site carries), its brand and accent
2
- // colours, its corners and its buttons. The site says what may be chosen and its own defaults
3
- // (defineSite's `look`); what's chosen reaches its CSS as variables, which its design reads:
1
+ // A site's Look (its theme), chosen in CalOS: its fonts (from the ones the site carries), its colours
2
+ // (brand and accent, the page's roles, and colours of its own by name), its type scale, its corners
3
+ // and buttons, its content width and the space between its sections. The site says what may be
4
+ // chosen and its own defaults (defineSite's `look`, LookOptions: the site's design as it is); what's
5
+ // chosen reaches its CSS as variables, which its design (and the kit's sections) read:
4
6
  //
5
7
  // --look-heading-font, --look-body-font font-family stacks
6
8
  // --look-brand, --look-on-brand the brand colour, and the text that reads on it
7
- // --look-accent the accent colour
9
+ // --look-accent, --look-on-accent the accent colour, and the text that reads on it
10
+ // --look-page, --look-ink the page's background and its main text
11
+ // --look-muted, --look-line, --look-soft quieter text, lines and borders, a quiet section's background
12
+ // --look-light, --look-dark the lighter and the darker of page and ink (words on a dark or light frame)
13
+ // --look-swatch-<slug> each of the site's own colours, by its name ("Dark sand" → dark-sand)
14
+ // --look-swatch-1 … --look-swatch-12 the same, by place in the list (for static CSS: .kit-bg-swatch-1)
15
+ // --look-type-<role>-size|weight|tracking|leading role: display, h1, h2, h3, body, small
16
+ // --look-width the content's width
17
+ // --look-gutter the space each side of it on a narrower screen (half on a phone)
18
+ // --look-section-pad a section's space above and below
8
19
  // --look-radius, --look-button-radius corners: of cards and pictures, and of buttons
9
20
  // --look-button-bg, --look-button-fg, --look-button-border
10
21
  // --look-button-fill 1 filled, 0 outlined
@@ -12,57 +23,324 @@
12
23
  // Published, they're a <style> in the page (LookStyle, allowed by its hash); in CalOS's preview they're
13
24
  // set on the page as the draft changes (allowed: a style set from script isn't a style attribute).
14
25
  import { z } from "zod";
26
+ import { googleFamily, googleFont, isFontName } from "../fonts-google.js";
27
+ export const TYPE_ROLES = ["display", "h1", "h2", "h3", "body", "small"];
28
+ /** The phone's menu button: what it shows (its icon, the word, both); its look is a button style (menuStyle). */
29
+ export const MENU_SHOWS = ["both", "icon", "word"];
30
+ export const BUTTON_SIZES = ["small", "medium", "large"];
31
+ /** How many styles of its own a site can have (the classes sections.css draws, kit-btn-1 to -8). */
32
+ export const MAX_BUTTON_STYLES = 8;
33
+ /** The theme's own, always there: a style of the business's can't take their names. */
34
+ export const THEME_BUTTONS = ["main", "accent", "quiet"];
35
+ export const HEADER_STYLES = ["line", "plain", "brand", "floating"];
36
+ export const HEADER_LAYOUTS = ["spread", "centered", "left"];
37
+ const HEADER_DEFAULT = { sticky: true, style: "line", layout: "spread", menuShows: "both" };
38
+ /** The header as it's drawn: what the business chose, else the site's own, else the kit's. */
39
+ export function headerOf(look, o) {
40
+ const h = { ...HEADER_DEFAULT, ...o.header, ...look?.header };
41
+ return {
42
+ sticky: typeof h.sticky === "boolean" ? h.sticky : HEADER_DEFAULT.sticky,
43
+ style: HEADER_STYLES.includes(h.style) ? h.style : HEADER_DEFAULT.style,
44
+ layout: HEADER_LAYOUTS.includes(h.layout) ? h.layout : HEADER_DEFAULT.layout,
45
+ menuShows: MENU_SHOWS.includes(h.menuShows) ? h.menuShows : HEADER_DEFAULT.menuShows,
46
+ ...(typeof h.menuStyle === "string" && h.menuStyle ? { menuStyle: h.menuStyle } : {}),
47
+ };
48
+ }
15
49
  const CORNERS = ["square", "soft", "round"];
16
50
  const BUTTONS = ["solid", "outline"];
51
+ const SPACES = ["compact", "normal", "roomy"];
52
+ /** The colours of the page any section's background may be, beside the site's own (by their slug). */
53
+ /**
54
+ * The kit's fonts (fonts.css, imported once in the site's layout): variable, every weight, by the
55
+ * name CalOS shows and the family it's drawn with. A site's look.ts lists them as its fonts (with
56
+ * any of its own), so the business picks any of them for its headings and text.
57
+ */
58
+ export const KIT_FONTS = {
59
+ Fraunces: '"Fraunces Variable", Georgia, serif',
60
+ Newsreader: '"Newsreader Variable", Georgia, serif',
61
+ "Source Serif 4": '"Source Serif 4 Variable", Georgia, serif',
62
+ "Playfair Display": '"Playfair Display Variable", Georgia, serif',
63
+ Lora: '"Lora Variable", Georgia, serif',
64
+ Inter: '"Inter Variable", system-ui, Arial, sans-serif',
65
+ "DM Sans": '"DM Sans Variable", system-ui, Arial, sans-serif',
66
+ Manrope: '"Manrope Variable", system-ui, Arial, sans-serif',
67
+ "Work Sans": '"Work Sans Variable", system-ui, Arial, sans-serif',
68
+ Outfit: '"Outfit Variable", system-ui, Arial, sans-serif',
69
+ };
70
+ export const COLOUR_ROLES = ["page", "soft", "brand", "accent", "ink"];
17
71
  const HEX = /^#[0-9a-f]{6}$/i;
72
+ const hex = (v) => typeof v === "string" && HEX.test(v);
18
73
  const color = z.string().regex(HEX, { error: "A colour like #2b5f8a" }).meta({ "x-ui": "color", "x-error": "A colour like #2b5f8a" });
74
+ /** The kit's type scale, for what a site doesn't say. */
75
+ export const KIT_TYPE = {
76
+ display: { size: 72, weight: 600, tracking: -0.03, leading: 1.05 },
77
+ h1: { size: 56, weight: 600, tracking: -0.02, leading: 1.1 },
78
+ h2: { size: 40, weight: 600, tracking: -0.02, leading: 1.15 },
79
+ h3: { size: 24, weight: 600, tracking: -0.01, leading: 1.25 },
80
+ body: { size: 17, weight: 400, tracking: 0, leading: 1.6 },
81
+ small: { size: 14, weight: 400, tracking: 0, leading: 1.5 },
82
+ };
83
+ const SECTION_PAD = {
84
+ compact: "clamp(40px, 6vw, 64px)",
85
+ normal: "clamp(56px, 8vw, 96px)",
86
+ roomy: "clamp(80px, 11vw, 144px)",
87
+ };
88
+ /** 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). */
89
+ export function swatchSlug(name) {
90
+ return name
91
+ .toLowerCase()
92
+ .replace(/[^a-z0-9]+/g, "-")
93
+ .replace(/^-+|-+$/g, "");
94
+ }
95
+ /** One colour part way to another: mix("#000000", "#ffffff", 0.5) is mid grey. */
96
+ function mix(a, b, t) {
97
+ const ch = (h, i) => parseInt(h.slice(i, i + 2), 16);
98
+ return `#${[1, 3, 5].map((i) => Math.round(ch(a, i) + (ch(b, i) - ch(a, i)) * t).toString(16).padStart(2, "0")).join("")}`;
99
+ }
100
+ /** The site's own role colours: what it said, else worked out from its page and ink. */
101
+ function ownRoles(o) {
102
+ const page = hex(o.page) ? o.page : hex(o.background) ? o.background : "#ffffff";
103
+ const ink = hex(o.ink) ? o.ink : "#111111";
104
+ return {
105
+ page,
106
+ ink,
107
+ muted: hex(o.muted) ? o.muted : mix(ink, page, 0.38),
108
+ line: hex(o.line) ? o.line : mix(page, ink, 0.15),
109
+ soft: hex(o.soft) ? o.soft : mix(page, ink, 0.05),
110
+ };
111
+ }
112
+ /** The site's own type scale: what it said of each role over the kit's. */
113
+ function ownType(o) {
114
+ return Object.fromEntries(TYPE_ROLES.map((r) => [r, { ...KIT_TYPE[r], ...o.type?.[r] }]));
115
+ }
116
+ const typeStyle = z
117
+ .object({
118
+ size: z.number().min(10).max(200).optional().meta({ title: "Size" }),
119
+ weight: z.literal([300, 400, 500, 600, 700]).optional().meta({ title: "Weight" }),
120
+ tracking: z.number().min(-0.1).max(0.3).optional().meta({ title: "Letter spacing" }),
121
+ leading: z.number().min(0.8).max(2.5).optional().meta({ title: "Line height" }),
122
+ })
123
+ .optional();
124
+ const TYPE_TITLES = { display: "Display", h1: "Page heading", h2: "Section heading", h3: "Small heading", body: "Text", small: "Small text" };
19
125
  /** The Look's fields, for the site's content. */
20
126
  export function lookSchema(o) {
21
127
  const fonts = Object.keys(o.fonts ?? {});
22
128
  // Each starts as the site's own, so CalOS shows what the site looks like now.
23
- const font = (title, own) => z.enum(fonts).default(own && fonts.includes(own) ? own : fonts[0]).meta({ title });
129
+ // One of the site's own fonts, or any Google font (its name): offered in CalOS as a search, its own first.
130
+ // Whether it's one is checked with the Look's own uploaded fonts in view (below).
131
+ const font = (title, own) => z
132
+ .string()
133
+ .trim()
134
+ .min(1)
135
+ .max(80)
136
+ .default(own && fonts.includes(own) ? own : fonts[0])
137
+ .meta({ title, "x-ui": "font", "x-fonts": fonts });
138
+ const roles = ownRoles(o);
24
139
  return z
25
140
  .object({
26
141
  ...(fonts.length ? { headingFont: font("Heading font", o.headingFont), bodyFont: font("Text font", o.bodyFont) } : {}),
27
142
  brand: color.default(o.brand).meta({ title: "Brand colour" }),
28
143
  accent: color.default(o.accent ?? o.brand).meta({ title: "Accent colour" }),
144
+ page: color.default(roles.page).meta({ title: "Page background" }),
145
+ ink: color.default(roles.ink).meta({ title: "Main text" }),
146
+ muted: color.default(roles.muted).meta({ title: "Quieter text" }),
147
+ line: color.default(roles.line).meta({ title: "Lines" }),
148
+ soft: color.default(roles.soft).meta({ title: "Quiet background" }),
149
+ palette: z
150
+ .array(z.object({ name: z.string().trim().min(1).max(30).meta({ title: "Name" }), color: color.meta({ title: "Colour" }) }))
151
+ .max(12)
152
+ .default(o.palette ?? [])
153
+ .meta({ title: "Your colours", "x-ui": "palette" }),
154
+ type: z
155
+ .object(Object.fromEntries(TYPE_ROLES.map((r) => [r, typeStyle.meta({ title: TYPE_TITLES[r] })])))
156
+ .default(ownType(o))
157
+ .meta({ title: "Type", "x-ui": "type-scale" }),
158
+ width: z.number().int().min(800).max(1600).default(o.width ?? 1200).meta({ title: "Content width" }),
159
+ space: z.enum(SPACES).default(o.space ?? "normal").meta({ title: "Space between sections" }),
29
160
  corners: z.enum(CORNERS).default(o.corners ?? "soft").meta({ title: "Corners" }),
30
161
  buttons: z.enum(BUTTONS).default(o.buttons ?? "solid").meta({ title: "Buttons" }),
162
+ // Exact corners, past the three presets: cards and pictures (px), and buttons (px, or 999 for a pill).
163
+ radius: z.number().int().min(0).max(40).optional().meta({ title: "Corner radius" }),
164
+ buttonRadius: z
165
+ .number()
166
+ .int()
167
+ .min(0)
168
+ .refine((n) => n <= 40 || n === 999, { error: "Up to 40px, or 999 for a pill" })
169
+ .optional()
170
+ .meta({ title: "Button corner radius" }),
171
+ buttonStyles: buttonStylesField.optional().meta({ title: "Button styles", "x-ui": "button-styles" }),
172
+ // A border round buttons: its width (0 for none) and colour (a role, a palette colour's slug, or the button's own).
173
+ buttonBorder: z
174
+ .object({ width: z.number().int().min(0).max(4).meta({ title: "Width" }), color: z.string().trim().max(40).optional().meta({ title: "Colour", "x-ui": "swatch" }) })
175
+ .optional()
176
+ .meta({ title: "Button border" }),
177
+ // Offered only to a site whose look.ts lists a header (the kit's KitHeader, or one of its own that
178
+ // follows headerOf): an older site, or one whose own header ignores these, never shows them.
179
+ ...(o.header ? { header: headerField(o) } : {}),
180
+ // The kit's footer's background: the site's dark (as it's always been), one of its colours by role, or one of its own by slug.
181
+ ...(o.header ? { footer: z.object({ background: z.string().trim().min(1).max(40).default("dark").meta({ title: "Background", "x-ui": "swatch" }) }).default({ background: "dark" }).meta({ title: "Footer" }) } : {}),
182
+ // Fonts the business uploaded (its own brand fonts): each family's files, kept with the site's pictures.
183
+ ...(fonts.length ? { uploadedFonts: uploadedFonts.optional().meta({ title: "Your fonts", "x-ui": "uploaded-fonts" }) } : {}),
184
+ })
185
+ .superRefine((l, c) => {
186
+ const uploaded = new Set((l.uploadedFonts ?? []).map((f) => f.family));
187
+ for (const k of ["headingFont", "bodyFont"]) {
188
+ const name = l[k];
189
+ if (name !== undefined && !isFontName(name, o.fonts ?? {}) && !uploaded.has(name))
190
+ c.addIssue({ code: "custom", path: [k], message: "A font the site has, one uploaded, or a Google font by its name" });
191
+ }
31
192
  })
32
193
  .optional()
33
194
  .meta({ title: "Look" });
34
195
  }
196
+ /** A button style's name, by its slug where a button names it ("main", "accent", "quiet", or one of the business's). */
197
+ export const buttonStyleName = z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, { error: "A button style's name" });
198
+ const colourKey = z.string().trim().min(1).max(40);
199
+ const buttonStylesField = z
200
+ .array(z.object({
201
+ name: z.string().trim().min(1).max(30).meta({ title: "Name" }),
202
+ fill: colourKey.optional().meta({ title: "Fill" }),
203
+ text: colourKey.optional().meta({ title: "Words" }),
204
+ border: z.object({ width: z.number().int().min(0).max(4).optional(), color: colourKey.optional() }).optional().meta({ title: "Border" }),
205
+ radius: z.number().int().min(0).refine((n) => n <= 40 || n === 999, { error: "Up to 40px, or 999 for round ends" }).optional().meta({ title: "Corners" }),
206
+ size: z.enum(BUTTON_SIZES).optional().meta({ title: "Size" }),
207
+ weight: z.number().int().min(300).max(900).optional().meta({ title: "Weight" }),
208
+ caps: z.boolean().optional().meta({ title: "Capitals" }),
209
+ }))
210
+ .max(MAX_BUTTON_STYLES)
211
+ .superRefine((styles, c) => {
212
+ const seen = new Set();
213
+ styles.forEach((st, i) => {
214
+ const slug = swatchSlug(st.name);
215
+ if (!slug)
216
+ c.addIssue({ code: "custom", path: [i, "name"], message: "A style's name needs a letter or number" });
217
+ else if (THEME_BUTTONS.includes(slug))
218
+ c.addIssue({ code: "custom", path: [i, "name"], message: `"${st.name}" is one of the theme's: name it something else` });
219
+ else if (seen.has(slug))
220
+ c.addIssue({ code: "custom", path: [i, "name"], message: `Another style is already called "${st.name}"` });
221
+ seen.add(slug);
222
+ });
223
+ });
224
+ const BUTTON_SIZE = { small: ["0.45em 0.95em", "0.875em"], medium: ["0.7em 1.3em", "1em"], large: ["0.9em 1.7em", "1.125em"] };
225
+ /** An uploaded font file's place among the site's files, and the font name a family can have (drawn in CSS as it is). */
226
+ const FONT_FILE = /^fonts\/[a-z0-9._-]+\.(woff2|woff|ttf|otf)$/;
227
+ const FAMILY = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,59}$/;
228
+ const uploadedFonts = z
229
+ .array(z.object({
230
+ family: z.string().regex(FAMILY),
231
+ files: z
232
+ .array(z.object({ src: z.string().regex(FONT_FILE), weight: z.string().regex(/^\d{1,4}( \d{1,4})?$/), style: z.enum(["normal", "italic"]) }))
233
+ .min(1)
234
+ .max(24),
235
+ }))
236
+ .max(8)
237
+ .refine((fs) => new Set(fs.map((f) => f.family.toLowerCase())).size === fs.length, { error: "Each uploaded font has its own name" });
238
+ const FORMATS = { woff2: "woff2", woff: "woff", ttf: "truetype", otf: "opentype" };
239
+ /** The @font-face rules for an uploaded font, each file at the address `at` gives its path. */
240
+ export function uploadedFontCss(f, at) {
241
+ return f.files
242
+ .map((x) => `@font-face{font-family:"${f.family}";font-style:${x.style};font-display:swap;font-weight:${x.weight};src:url(${at(x.src)}) format("${FORMATS[x.src.split(".").pop()] ?? "woff2"}")}`)
243
+ .join("\n");
244
+ }
245
+ function headerField(o) {
246
+ return z
247
+ .object({
248
+ sticky: z.boolean().default(o.header?.sticky ?? HEADER_DEFAULT.sticky).meta({ title: "Stays at the top", description: "Stays in view as the page scrolls." }),
249
+ style: z
250
+ .enum(HEADER_STYLES)
251
+ .default(o.header?.style ?? HEADER_DEFAULT.style)
252
+ .meta({ title: "Style", "x-labels": { line: "Line under it", plain: "Plain", brand: "Brand colour", floating: "Floating bar" } }),
253
+ layout: z
254
+ .enum(HEADER_LAYOUTS)
255
+ .default(o.header?.layout ?? HEADER_DEFAULT.layout)
256
+ .meta({ title: "Layout", "x-labels": { spread: "Name left, menu right", centered: "Centred", left: "All on the left" } }),
257
+ menuShows: z
258
+ .enum(MENU_SHOWS)
259
+ .default(o.header?.menuShows ?? HEADER_DEFAULT.menuShows)
260
+ .meta({ title: "Phone menu button", "x-labels": { both: "Icon and word", icon: "Icon only", word: "Word only" } }),
261
+ // The phone's menu button in one of the site's button styles; left out, outlined in the header's line.
262
+ menuStyle: buttonStyleName.optional().meta({ title: "Phone menu button's style", "x-ui": "button-style" }),
263
+ })
264
+ .default({ ...HEADER_DEFAULT, ...o.header })
265
+ .meta({ title: "Header" });
266
+ }
35
267
  /** How readable one colour is on another (WCAG): 1 to 21. */
36
268
  export function contrast(a, b) {
37
- const lum = (hex) => {
38
- const [r, g, bl] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
39
- return 0.2126 * r + 0.7152 * g + 0.0722 * bl;
40
- };
41
- const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m);
269
+ const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m);
42
270
  return (x + 0.05) / (y + 0.05);
43
271
  }
44
- /** 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. */
272
+ function luminance(hex) {
273
+ const [r, g, bl] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
274
+ return 0.2126 * r + 0.7152 * g + 0.0722 * bl;
275
+ }
276
+ /** Whether a colour is dark: white words read better on it than near-black ones. */
277
+ export function isDark(color) {
278
+ return hex(color) && contrast(color, "#ffffff") >= contrast(color, "#111111");
279
+ }
280
+ /** 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. */
45
281
  export function lookProblems(look, o) {
46
- const bg = o.background ?? "#ffffff";
47
- if (look?.brand && HEX.test(look.brand) && HEX.test(bg) && contrast(look.brand, bg) < 3)
48
- return [{ path: ["look", "brand"], message: "Too close to the page's background to read: choose a darker or stronger colour" }];
49
- return [];
282
+ const out = [];
283
+ const page = hex(look?.page) ? look.page : ownRoles(o).page;
284
+ if (hex(look?.brand) && contrast(look.brand, page) < 3)
285
+ out.push({ path: ["look", "brand"], message: "Too close to the page's background to read: choose a darker or stronger colour" });
286
+ if (hex(look?.ink) && contrast(look.ink, page) < 4.5)
287
+ out.push({ path: ["look", "ink"], message: "Too close to the page's background to read: choose a colour further from it" });
288
+ if (hex(look?.muted) && contrast(look.muted, page) < 3)
289
+ out.push({ path: ["look", "muted"], message: "Too close to the page's background to read: choose a colour further from it" });
290
+ const seen = new Set();
291
+ (look?.palette ?? []).forEach((s, i) => {
292
+ const slug = swatchSlug(s.name ?? "");
293
+ if (!slug)
294
+ out.push({ path: ["look", "palette", i, "name"], message: "A name with a letter or number in it" });
295
+ else if (COLOUR_ROLES.includes(slug))
296
+ out.push({ path: ["look", "palette", i, "name"], message: `"${s.name}" is already one of the page's colours: choose another name` });
297
+ else if (seen.has(slug))
298
+ out.push({ path: ["look", "palette", i, "name"], message: `Another colour is already called "${s.name}"` });
299
+ seen.add(slug);
300
+ });
301
+ return out;
50
302
  }
51
303
  const RADIUS = { square: ["0", "0"], soft: ["8px", "8px"], round: ["16px", "999px"] };
304
+ const num = (v) => String(Math.round(v * 1000) / 1000);
52
305
  /** The Look as CSS variables: what's chosen, else the site's own. */
53
306
  export function lookVars(look, o) {
54
307
  const l = look ?? {};
55
308
  const fonts = o.fonts ?? {};
56
309
  const first = Object.values(fonts)[0];
57
- const own = (name) => (name && fonts[name]) || first;
58
- const brand = l.brand && HEX.test(l.brand) ? l.brand : o.brand;
59
- const onBrand = HEX.test(brand) && contrast(brand, "#ffffff") >= contrast(brand, "#111111") ? "#ffffff" : "#111111";
60
- const [radius, buttonRadius] = RADIUS[l.corners ?? o.corners ?? "soft"];
310
+ // The site's own font by its name, else any Google font (fetched and served by the site when it's built), else its first.
311
+ const own = (name) => {
312
+ if (name && fonts[name])
313
+ return fonts[name];
314
+ const g = name ? googleFont(name) : null;
315
+ return g ? googleFamily(g) : first;
316
+ };
317
+ const roles = ownRoles(o);
318
+ const pick = (k) => (hex(l[k]) ? l[k] : roles[k]);
319
+ const page = pick("page");
320
+ const ink = pick("ink");
321
+ const brand = hex(l.brand) ? l.brand : o.brand;
322
+ const onBrand = hex(brand) && !isDark(brand) ? "#111111" : "#ffffff";
323
+ const accent = (hex(l.accent) ? l.accent : o.accent) ?? brand;
324
+ // The darker of page and ink on a light accent (sand reads in the site's own dark), else white.
325
+ const onAccent = hex(accent) && !isDark(accent) ? (luminance(pick("page")) < luminance(pick("ink")) ? pick("page") : pick("ink")) : "#ffffff";
326
+ const [presetRadius, presetButtonRadius] = RADIUS[l.corners ?? o.corners ?? "soft"];
327
+ const px = (n, max) => (typeof n === "number" && Number.isInteger(n) && n >= 0 && (n <= max || n === 999) ? `${n}px` : null);
328
+ const radius = px(l.radius, 40) ?? presetRadius;
329
+ const buttonRadius = px(l.buttonRadius, 40) ?? presetButtonRadius;
61
330
  const outline = (l.buttons ?? o.buttons ?? "solid") === "outline";
331
+ const [light, dark] = luminance(page) >= luminance(ink) ? [page, ink] : [ink, page];
62
332
  const vars = {
63
333
  "--look-brand": brand,
64
334
  "--look-on-brand": onBrand,
65
- "--look-accent": (l.accent && HEX.test(l.accent) ? l.accent : o.accent) ?? brand,
335
+ "--look-page": page,
336
+ "--look-ink": ink,
337
+ "--look-muted": pick("muted"),
338
+ "--look-line": pick("line"),
339
+ "--look-soft": pick("soft"),
340
+ "--look-light": light,
341
+ "--look-dark": dark,
342
+ "--look-accent": accent,
343
+ "--look-on-accent": onAccent,
66
344
  "--look-radius": radius,
67
345
  "--look-button-radius": buttonRadius,
68
346
  "--look-button-bg": outline ? "transparent" : brand,
@@ -71,17 +349,103 @@ export function lookVars(look, o) {
71
349
  /** 1 for a filled button, 0 for an outlined one: for a design that mixes its own colours by it. */
72
350
  "--look-button-fill": outline ? "0" : "1",
73
351
  };
74
- const heading = (l.headingFont && fonts[l.headingFont]) || own(o.headingFont);
75
- const body = (l.bodyFont && fonts[l.bodyFont]) || own(o.bodyFont);
352
+ const palette = (Array.isArray(l.palette) ? l.palette : (o.palette ?? [])).slice(0, 12);
353
+ palette.forEach((s, i) => {
354
+ if (!hex(s?.color))
355
+ return;
356
+ const slug = swatchSlug(s.name ?? "");
357
+ if (slug)
358
+ vars[`--look-swatch-${slug}`] = s.color;
359
+ vars[`--look-swatch-${i + 1}`] = s.color;
360
+ });
361
+ // Buttons' border, where chosen: its width, and its colour by role or palette slug (else the button's own).
362
+ const bb = l.buttonBorder;
363
+ if (bb && typeof bb.width === "number" && bb.width >= 0 && bb.width <= 4)
364
+ vars["--look-button-border-width"] = `${Math.round(bb.width)}px`;
365
+ if (bb?.color) {
366
+ const c = bb.color === "dark" ? dark : bb.color === "light" ? light : colourOf(bb.color, vars);
367
+ if (c && hex(c))
368
+ vars["--look-button-border-colour"] = c;
369
+ }
370
+ // The business's own button styles: each one's values as --look-btn-<n>-*, and --look-btn-<slug> its number,
371
+ // so a button naming it finds its class (kit-btn-<n>, sections.css).
372
+ const named = (key) => (key === "dark" ? dark : key === "light" ? light : key ? colourOf(key, vars) : undefined);
373
+ (Array.isArray(l.buttonStyles) ? l.buttonStyles : []).slice(0, MAX_BUTTON_STYLES).forEach((st, i) => {
374
+ const slug = swatchSlug(st?.name ?? "");
375
+ if (!slug || THEME_BUTTONS.includes(slug))
376
+ return;
377
+ const n = i + 1;
378
+ const none = st.fill === "none";
379
+ const bg = none ? null : (named(st.fill) ?? brand);
380
+ const fg = named(st.text) ?? (bg && hex(bg) ? (isDark(bg) ? light : dark) : ink);
381
+ const [pad, size] = BUTTON_SIZE[st.size ?? "medium"];
382
+ vars[`--look-btn-${slug}`] = String(n);
383
+ vars[`--look-btn-${n}-bg`] = bg ?? "transparent";
384
+ vars[`--look-btn-${n}-fg`] = fg;
385
+ vars[`--look-btn-${n}-bw`] = `${typeof st.border?.width === "number" ? Math.min(4, Math.max(0, Math.round(st.border.width))) : 1}px`;
386
+ vars[`--look-btn-${n}-bc`] = named(st.border?.color) ?? (bg ?? fg);
387
+ vars[`--look-btn-${n}-radius`] = px(st.radius, 40) ?? buttonRadius;
388
+ vars[`--look-btn-${n}-pad`] = pad;
389
+ vars[`--look-btn-${n}-size`] = size;
390
+ vars[`--look-btn-${n}-weight`] = String(typeof st.weight === "number" ? st.weight : 600);
391
+ vars[`--look-btn-${n}-case`] = st.caps ? "uppercase" : "none";
392
+ vars[`--look-btn-${n}-track`] = st.caps ? "0.06em" : "0";
393
+ });
394
+ // The footer in another of the site's colours, its words in whichever of page and ink reads on it.
395
+ const fb = l.footer?.background;
396
+ if (fb && fb !== "dark") {
397
+ const c = fb === "light" ? light : colourOf(fb, vars);
398
+ if (c && hex(c)) {
399
+ vars["--kit-footer-bg"] = c;
400
+ vars["--kit-footer-fg"] = isDark(c) ? light : dark;
401
+ }
402
+ }
403
+ const scale = ownType(o);
404
+ for (const r of TYPE_ROLES) {
405
+ const t = { ...scale[r], ...definedOnly(l.type?.[r]) };
406
+ vars[`--look-type-${r}-size`] = `${num(t.size)}px`;
407
+ vars[`--look-type-${r}-weight`] = String(t.weight);
408
+ vars[`--look-type-${r}-tracking`] = `${num(t.tracking)}em`;
409
+ vars[`--look-type-${r}-leading`] = num(t.leading);
410
+ }
411
+ const width = typeof l.width === "number" && l.width >= 800 && l.width <= 1600 ? l.width : (o.width ?? 1200);
412
+ vars["--look-width"] = `${Math.round(width)}px`;
413
+ const gutter = typeof o.gutter === "number" && o.gutter >= 8 && o.gutter <= 96 ? Math.round(o.gutter) : 40;
414
+ vars["--look-gutter"] = `clamp(${Math.round(gutter / 2)}px, 4vw, ${gutter}px)`;
415
+ vars["--look-section-pad"] = SECTION_PAD[l.space && SECTION_PAD[l.space] ? l.space : (o.space ?? "normal")];
416
+ const uploaded = (name) => (l.uploadedFonts ?? []).find((f) => f.family === name);
417
+ const chosen = (name) => (name && uploaded(name) && !fonts[name] ? `"${name}", system-ui, sans-serif` : name && (fonts[name] || googleFont(name)) ? own(name) : undefined);
418
+ const heading = chosen(l.headingFont) || own(o.headingFont);
419
+ const body = chosen(l.bodyFont) || own(o.bodyFont);
76
420
  if (heading)
77
421
  vars["--look-heading-font"] = heading;
78
422
  if (body)
79
423
  vars["--look-body-font"] = body;
80
424
  return vars;
81
425
  }
426
+ function definedOnly(t) {
427
+ return Object.fromEntries(Object.entries(t ?? {}).filter(([, v]) => typeof v === "number" && Number.isFinite(v)));
428
+ }
82
429
  /** The Look as a stylesheet for the page's head: `:root { … }`. */
83
430
  export function lookCss(look, o) {
84
431
  return `:root{${Object.entries(lookVars(look, o))
85
432
  .map(([k, v]) => `${k}:${v.replace(/[<>{};]/g, "")}`)
86
433
  .join(";")}}`;
87
434
  }
435
+ /**
436
+ * The colour a section's background names (a role, or one of the site's own by its slug), from the
437
+ * Look's variables; undefined when it names none.
438
+ */
439
+ export function colourOf(name, vars) {
440
+ return COLOUR_ROLES.includes(name) ? vars[`--look-${name}`] : vars[`--look-swatch-${name}`];
441
+ }
442
+ /**
443
+ * The class that draws a button in one of the business's own styles (kit-btn-3), from the Look's
444
+ * values (lookVars): undefined for the theme's own (main, accent, quiet) or a style it doesn't have.
445
+ */
446
+ export function buttonStyleClass(style, vars) {
447
+ if (!style || THEME_BUTTONS.includes(style))
448
+ return undefined;
449
+ const n = vars[`--look-btn-${style}`];
450
+ return n ? `kit-btn kit-btn-${n}` : undefined;
451
+ }
@@ -0,0 +1,51 @@
1
+ /** The format this kit's content is in. 0: before built sections; 1: built sections and components. */
2
+ export declare const CONTENT_FORMAT = 1;
3
+ export type Content = Record<string, unknown>;
4
+ export type ContentMigration = {
5
+ /** Stable, unique, in order: "0001-built-sections". */
6
+ id: string;
7
+ from: number;
8
+ to: number;
9
+ /** What it changes, for whoever looks at a site's history. */
10
+ title: string;
11
+ /** The content in format `from`, moved to `to` (a copy: the content given is never changed). Throws a MigrationError for what it can't move. */
12
+ run(content: Content): Content;
13
+ };
14
+ /** What a migration can't move, and where: a fault for Caltho to fix in the migration, never a site's to fix by hand. */
15
+ export declare class MigrationError extends Error {
16
+ readonly path: (string | number)[];
17
+ constructor(message: string, path?: (string | number)[]);
18
+ }
19
+ /** Every migration, in order: each one's `from` is the one before's `to`. */
20
+ export declare const CONTENT_MIGRATIONS: readonly ContentMigration[];
21
+ /** The format content is in: its `format`, or 0 for content from before formats were said. */
22
+ export declare function formatOf(content: unknown): number;
23
+ export type MigrateResult = {
24
+ ok: true;
25
+ content: Content;
26
+ from: number;
27
+ format: number;
28
+ applied: {
29
+ id: string;
30
+ title: string;
31
+ }[];
32
+ } | {
33
+ ok: false;
34
+ content: Content;
35
+ from: number;
36
+ format: number;
37
+ error: {
38
+ id: string;
39
+ message: string;
40
+ path: (string | number)[];
41
+ };
42
+ };
43
+ /**
44
+ * Content moved to this kit's format (or `to`): each migration it hasn't had, in order, then its
45
+ * `format` set. Idempotent: content already there comes back as it was, with nothing applied. A
46
+ * migration that fails stops it: the content is returned as given, with the failure (`ok: false`).
47
+ * Content in a format newer than this kit knows is refused the same way (the site needs the newer kit).
48
+ */
49
+ export declare function migrateContent(content: Content, o?: {
50
+ to?: number;
51
+ }): MigrateResult;
@@ -0,0 +1,69 @@
1
+ // A site's content moved from one format to the next. Every client site is the starter plus its
2
+ // configuration (its content: pages, Look, built sections, components), with no code of its own,
3
+ // so the configuration's format is the kit's, and it changes only with the kit. The content says
4
+ // which format it's in (`format`; none is 0, the format before built sections). When a newer kit
5
+ // changes a shape, a migration here moves content from the format before to its own, as data:
6
+ // pure, in order, each once (content already at or past its format is left alone), never needing a
7
+ // person to edit a site by hand. One that can't move something says so (a MigrationError) for
8
+ // Caltho to fix in the migration; the content is then left exactly as it was.
9
+ //
10
+ // Most changes need none: a new kind of block (built, use) or a new optional field leaves existing
11
+ // content valid, and only the format number moves. CalOS runs these on a site's draft and published
12
+ // content when the site moves to a newer kit (migrateContent); the build runs them too (prepare),
13
+ // so content CalOS hasn't moved yet still builds. Code migrations (a site's files) are ./migrate.ts.
14
+ /** The format this kit's content is in. 0: before built sections; 1: built sections and components. */
15
+ export const CONTENT_FORMAT = 1;
16
+ /** What a migration can't move, and where: a fault for Caltho to fix in the migration, never a site's to fix by hand. */
17
+ export class MigrationError extends Error {
18
+ path;
19
+ constructor(message, path = []) {
20
+ super(message);
21
+ this.path = path;
22
+ this.name = "MigrationError";
23
+ }
24
+ }
25
+ /** Every migration, in order: each one's `from` is the one before's `to`. */
26
+ export const CONTENT_MIGRATIONS = [
27
+ {
28
+ id: "0001-built-sections",
29
+ from: 0,
30
+ to: 1,
31
+ title: "Built sections, components and the cookie banner: new kinds of block (built, use), the site's components and its consent setting, none of which changes existing content",
32
+ run: (c) => c,
33
+ },
34
+ ];
35
+ /** The format content is in: its `format`, or 0 for content from before formats were said. */
36
+ export function formatOf(content) {
37
+ const f = content?.format;
38
+ return typeof f === "number" && Number.isInteger(f) && f >= 0 ? f : 0;
39
+ }
40
+ /**
41
+ * Content moved to this kit's format (or `to`): each migration it hasn't had, in order, then its
42
+ * `format` set. Idempotent: content already there comes back as it was, with nothing applied. A
43
+ * migration that fails stops it: the content is returned as given, with the failure (`ok: false`).
44
+ * Content in a format newer than this kit knows is refused the same way (the site needs the newer kit).
45
+ */
46
+ export function migrateContent(content, o = {}) {
47
+ const to = o.to ?? CONTENT_FORMAT;
48
+ const from = formatOf(content);
49
+ if (from > CONTENT_FORMAT)
50
+ return { ok: false, content, from, format: from, error: { id: "format", message: `The content is in format ${from}, newer than this kit's (${CONTENT_FORMAT}): the site needs a newer @caltho/site-kit`, path: ["format"] } };
51
+ let at = structuredClone(content);
52
+ const applied = [];
53
+ for (const m of CONTENT_MIGRATIONS) {
54
+ if (m.from < from || m.to > to)
55
+ continue;
56
+ try {
57
+ at = m.run(structuredClone(at));
58
+ }
59
+ catch (err) {
60
+ const e = err;
61
+ return { ok: false, content, from, format: from, error: { id: m.id, message: e.message, path: e instanceof MigrationError ? e.path : [] } };
62
+ }
63
+ at.format = m.to;
64
+ applied.push({ id: m.id, title: m.title });
65
+ }
66
+ if (from < to && formatOf(at) !== to)
67
+ at.format = to;
68
+ return { ok: true, content: at, from, format: formatOf(at), applied };
69
+ }
package/dist/finish.d.ts CHANGED
@@ -6,5 +6,5 @@ export declare function finish(root: string, outDir: string, env?: Record<string
6
6
  * out, and what's marked to count), and each phone link and map link marked: a call or directions
7
7
  * tapped is what a business wants to know came from the site. No cookies: nothing to consent to.
8
8
  */
9
- export declare function counted(html: string, a: Analytics): string;
9
+ export declare function counted(html: string, a: Analytics, asks?: boolean): string;
10
10
  export {};