@caltho/site-kit 0.2.10 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +61 -12
  2. package/dist/cli/check.js +31 -1
  3. package/dist/content/index.d.ts +310 -23
  4. package/dist/content/index.js +142 -15
  5. package/dist/content/look.d.ts +260 -9
  6. package/dist/content/look.js +386 -22
  7. package/dist/finish.js +9 -1
  8. package/dist/fonts/families/dm-sans/LICENSE +93 -0
  9. package/dist/fonts/families/dm-sans/dm-sans-latin-ext-wght-italic.woff2 +0 -0
  10. package/dist/fonts/families/dm-sans/dm-sans-latin-ext-wght-normal.woff2 +0 -0
  11. package/dist/fonts/families/dm-sans/dm-sans-latin-wght-italic.woff2 +0 -0
  12. package/dist/fonts/families/dm-sans/dm-sans-latin-wght-normal.woff2 +0 -0
  13. package/dist/fonts/families/fraunces/LICENSE +93 -0
  14. package/dist/fonts/families/fraunces/fraunces-latin-ext-wght-italic.woff2 +0 -0
  15. package/dist/fonts/families/fraunces/fraunces-latin-ext-wght-normal.woff2 +0 -0
  16. package/dist/fonts/families/fraunces/fraunces-latin-wght-italic.woff2 +0 -0
  17. package/dist/fonts/families/fraunces/fraunces-latin-wght-normal.woff2 +0 -0
  18. package/dist/fonts/families/inter/LICENSE +93 -0
  19. package/dist/fonts/families/inter/inter-latin-ext-wght-italic.woff2 +0 -0
  20. package/dist/fonts/families/inter/inter-latin-ext-wght-normal.woff2 +0 -0
  21. package/dist/fonts/families/inter/inter-latin-wght-italic.woff2 +0 -0
  22. package/dist/fonts/families/inter/inter-latin-wght-normal.woff2 +0 -0
  23. package/dist/fonts/families/lora/LICENSE +93 -0
  24. package/dist/fonts/families/lora/lora-latin-ext-wght-italic.woff2 +0 -0
  25. package/dist/fonts/families/lora/lora-latin-ext-wght-normal.woff2 +0 -0
  26. package/dist/fonts/families/lora/lora-latin-wght-italic.woff2 +0 -0
  27. package/dist/fonts/families/lora/lora-latin-wght-normal.woff2 +0 -0
  28. package/dist/fonts/families/manrope/LICENSE +93 -0
  29. package/dist/fonts/families/manrope/manrope-latin-ext-wght-normal.woff2 +0 -0
  30. package/dist/fonts/families/manrope/manrope-latin-wght-normal.woff2 +0 -0
  31. package/dist/fonts/families/newsreader/LICENSE +93 -0
  32. package/dist/fonts/families/newsreader/newsreader-latin-ext-wght-italic.woff2 +0 -0
  33. package/dist/fonts/families/newsreader/newsreader-latin-ext-wght-normal.woff2 +0 -0
  34. package/dist/fonts/families/newsreader/newsreader-latin-wght-italic.woff2 +0 -0
  35. package/dist/fonts/families/newsreader/newsreader-latin-wght-normal.woff2 +0 -0
  36. package/dist/fonts/families/outfit/LICENSE +93 -0
  37. package/dist/fonts/families/outfit/outfit-latin-ext-wght-normal.woff2 +0 -0
  38. package/dist/fonts/families/outfit/outfit-latin-wght-normal.woff2 +0 -0
  39. package/dist/fonts/families/playfair-display/LICENSE +93 -0
  40. package/dist/fonts/families/playfair-display/playfair-display-latin-ext-wght-italic.woff2 +0 -0
  41. package/dist/fonts/families/playfair-display/playfair-display-latin-ext-wght-normal.woff2 +0 -0
  42. package/dist/fonts/families/playfair-display/playfair-display-latin-wght-italic.woff2 +0 -0
  43. package/dist/fonts/families/playfair-display/playfair-display-latin-wght-normal.woff2 +0 -0
  44. package/dist/fonts/families/source-serif-4/LICENSE +93 -0
  45. package/dist/fonts/families/source-serif-4/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
  46. package/dist/fonts/families/source-serif-4/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
  47. package/dist/fonts/families/source-serif-4/source-serif-4-latin-wght-italic.woff2 +0 -0
  48. package/dist/fonts/families/source-serif-4/source-serif-4-latin-wght-normal.woff2 +0 -0
  49. package/dist/fonts/families/work-sans/LICENSE +93 -0
  50. package/dist/fonts/families/work-sans/work-sans-latin-ext-wght-italic.woff2 +0 -0
  51. package/dist/fonts/families/work-sans/work-sans-latin-ext-wght-normal.woff2 +0 -0
  52. package/dist/fonts/families/work-sans/work-sans-latin-wght-italic.woff2 +0 -0
  53. package/dist/fonts/families/work-sans/work-sans-latin-wght-normal.woff2 +0 -0
  54. package/dist/fonts/google.json +1 -0
  55. package/dist/fonts-google.d.ts +35 -0
  56. package/dist/fonts-google.js +70 -0
  57. package/dist/fonts.css +365 -0
  58. package/dist/frame.test.d.ts +1 -0
  59. package/dist/frame.test.js +33 -0
  60. package/dist/migrate.d.ts +45 -0
  61. package/dist/migrate.js +284 -0
  62. package/dist/next/block-mark.d.ts +7 -0
  63. package/dist/next/block-mark.js +16 -0
  64. package/dist/next/blocks.d.ts +11 -0
  65. package/dist/next/blocks.js +35 -0
  66. package/dist/next/data.d.ts +4 -0
  67. package/dist/next/data.js +4 -0
  68. package/dist/next/frame.d.ts +34 -0
  69. package/dist/next/frame.js +70 -0
  70. package/dist/next/index.d.ts +6 -2
  71. package/dist/next/index.js +7 -3
  72. package/dist/next/menu-group.d.ts +11 -0
  73. package/dist/next/menu-group.js +33 -0
  74. package/dist/next/parts/LookStyle.js +3 -1
  75. package/dist/next/preview.js +282 -25
  76. package/dist/next/sections.d.ts +19 -10
  77. package/dist/next/sections.js +129 -17
  78. package/dist/prepare.d.ts +5 -1
  79. package/dist/prepare.js +59 -1
  80. package/dist/preview-match.d.ts +8 -0
  81. package/dist/preview-match.js +33 -1
  82. package/dist/schema.d.ts +10 -0
  83. package/dist/schema.js +26 -0
  84. package/dist/sections/icons.d.ts +47 -0
  85. package/dist/sections/icons.js +51 -0
  86. package/dist/sections/index.d.ts +165 -11
  87. package/dist/sections/index.js +129 -8
  88. package/dist/sections/presets.d.ts +13 -0
  89. package/dist/sections/presets.js +300 -0
  90. package/dist/sections.css +1260 -41
  91. package/dist/sections.test.js +276 -14
  92. package/package.json +8 -3
@@ -1,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
+ }
package/dist/finish.js CHANGED
@@ -8,6 +8,7 @@ import { createHash } from "node:crypto";
8
8
  import { readdir, readFile, writeFile } from "node:fs/promises";
9
9
  import { join, relative } from "node:path";
10
10
  import { BUILT_IMAGES } from "./preview-match.js";
11
+ import { VIDEO_ORIGINS } from "./sections/index.js";
11
12
  import { headersFile, loadRedirects, PREVIEW_PATH } from "./seo.js";
12
13
  export async function finish(root, outDir, env = process.env, log = console.log) {
13
14
  const prepared = JSON.parse(await readFile(join(root, ".site", "prepared.json"), "utf8"));
@@ -42,13 +43,20 @@ export async function finish(root, outDir, env = process.env, log = console.log)
42
43
  "script-src": ["'self'", ...[...scripts].map((h) => `'${h}'`), ...(prepared.analytics && !isPreview ? [new URL(prepared.analytics.script).origin] : [])],
43
44
  "style-src": ["'self'", ...[...styles].map((h) => `'${h}'`)],
44
45
  "img-src": ["'self'", "data:"],
45
- "font-src": ["'self'"],
46
+ // The preview may show a Google font just chosen, from Fontsource's CDN, until the next build serves it.
47
+ // So may a font the business uploaded, from CalOS (the editor's origin).
48
+ "font-src": ["'self'", ...(isPreview ? ["https://cdn.jsdelivr.net", ...editors] : [])],
46
49
  // A form sends with fetch as often as by posting the page: its address for both.
47
50
  "connect-src": ["'self'", ...(formOrigin ? [formOrigin] : []), ...(prepared.analytics && !isPreview ? [new URL(prepared.analytics.api).origin] : [])],
48
51
  "form-action": ["'self'", ...(formOrigin ? [formOrigin] : [])],
49
52
  "base-uri": ["'self'"],
50
53
  "object-src": ["'none'"],
51
54
  };
55
+ // A page showing a video (an `own` section's video part) lets its player in, and only that page;
56
+ // the preview may show any, as the draft changes.
57
+ const players = isPreview ? [...VIDEO_ORIGINS] : VIDEO_ORIGINS.filter((o) => new RegExp(`<iframe\\b[^>]*\\ssrc="${o.replace(/[.]/g, "\\.")}/`, "i").test(html));
58
+ if (players.length)
59
+ directives["frame-src"] = ["'self'", ...players];
52
60
  for (const [directive, sources] of Object.entries(prepared.csp ?? {}))
53
61
  directives[directive] = [...new Set([...(directives[directive] ?? ["'self'"]), ...sources])];
54
62
  if (isPreview) {
@@ -0,0 +1,93 @@
1
+ Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts) DMSans-Italic[opsz,wght].ttf: Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ http://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.