@caltho/site-kit 0.2.3 → 0.2.5

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.
package/README.md CHANGED
@@ -66,6 +66,31 @@ the kit only checks it's a URL.
66
66
  | **Preview** (`@caltho/site-kit/next/preview`) | `SitePreview`: CalOS's draft drawn with the site's own components, a click matched to its field, outlines with each section's name. |
67
67
  | **Schema** (`@caltho/site-kit/schema`) | What CalOS needs of a declaration with no site code: validate content against the JSON Schema with messages a person can act on, make a blank block or entry, list the kinds of block, and the checks across pages. |
68
68
 
69
+ ## Share cards
70
+
71
+ Each page is shared (Open Graph) with a 1200×630 card made at build from its words: its title and
72
+ description, or the **Share title** and **Share text** an owner can set in CalOS's page settings, with
73
+ the business's name. Every publish rebuilds the site, so a card always says what the page says. A
74
+ **Share picture** chosen for a page is used instead.
75
+
76
+ A site sets how its cards look in `content/schema.ts`:
77
+
78
+ ```ts
79
+ export default defineSite(blocks, {
80
+ share: {
81
+ background: "#13262f", color: "#ffffff", muted: "#b8c7cf", accent: "#f2b134",
82
+ backgroundImage: "public/og.png", // drawn behind, stretched to 1200×630
83
+ logo: "public/logo.svg", // instead of the business's name in words
84
+ fonts: [{ name: "Brand Sans", path: "assets/fonts/BrandSans-Bold.woff", weight: 700 }],
85
+ prefer: "picture", // a page's first picture before its card
86
+ render: (card, h) => h.el({ width: "100%", height: "100%", padding: 72 }, card.title), // a card of its own
87
+ },
88
+ });
89
+ ```
90
+
91
+ `share: false` turns cards off: pages are shared with a picture from them. The kit carries DM Sans (SIL
92
+ Open Font License, `src/fonts/OFL.txt`) for cards that don't name fonts of their own.
93
+
69
94
  ## A build
70
95
 
71
96
  ```sh
@@ -1,4 +1,8 @@
1
+ import type { ShareOptions } from "../share.ts";
2
+ export type { CardNode, ShareCard, ShareFont, ShareOptions } from "../share.ts";
1
3
  import { z } from "zod";
4
+ import { type Look, type LookOptions } from "./look.ts";
5
+ export { contrast, lookCss, lookProblems, lookVars, type Look, type LookOptions } from "./look.ts";
2
6
  export { checkStructure, describePath, type StructureIssue } from "../schema.ts";
3
7
  export { z };
4
8
  export declare const text: z.ZodString;
@@ -24,6 +28,38 @@ export declare const link: z.ZodObject<{
24
28
  }, z.core.$strip>;
25
29
  export declare const time: z.ZodString;
26
30
  export declare const DAYS: readonly ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
31
+ /**
32
+ * How a section sits on the page, for a site that offers it (defineSite's `placement`): its alignment,
33
+ * width, spacing and the screens it shows on. The site's design says what each means at every width,
34
+ * so a choice here stays sensible on a phone. CalOS shows it as the section's Layout. (A block's own
35
+ * `layout`, a site's choice of how it's drawn, is a different thing and keeps its name.)
36
+ */
37
+ export declare const placement: z.ZodObject<{
38
+ align: z.ZodDefault<z.ZodEnum<{
39
+ left: "left";
40
+ center: "center";
41
+ }>>;
42
+ width: z.ZodDefault<z.ZodEnum<{
43
+ normal: "normal";
44
+ narrow: "narrow";
45
+ wide: "wide";
46
+ }>>;
47
+ spacing: z.ZodDefault<z.ZodEnum<{
48
+ normal: "normal";
49
+ compact: "compact";
50
+ roomy: "roomy";
51
+ }>>;
52
+ show: z.ZodDefault<z.ZodEnum<{
53
+ phone: "phone";
54
+ all: "all";
55
+ desktop: "desktop";
56
+ }>>;
57
+ }, z.core.$strip>;
58
+ export type Placement = z.output<typeof placement>;
59
+ /** A section's placement as class names, only what differs from the usual: "place-align-center place-width-narrow", or "". */
60
+ export declare function placementClass(block: {
61
+ placement?: Partial<Placement>;
62
+ }): string;
27
63
  /** The business's facts: the header, footer, hours, contact details and search data all come from here. */
28
64
  export declare const business: z.ZodObject<{
29
65
  name: z.ZodString;
@@ -87,6 +123,8 @@ export type Page = {
87
123
  src: string;
88
124
  alt: string;
89
125
  };
126
+ shareTitle?: string;
127
+ shareText?: string;
90
128
  noindex: boolean;
91
129
  blocks: Block[];
92
130
  };
@@ -99,14 +137,53 @@ export type Redirect = {
99
137
  status: 301 | 302;
100
138
  };
101
139
  /** What every site has, whatever its blocks. */
140
+ export type MenuLink = {
141
+ label: string;
142
+ href: string;
143
+ emphasis?: boolean;
144
+ };
145
+ export type Menu = {
146
+ links: MenuLink[];
147
+ button?: {
148
+ label: string;
149
+ href: string;
150
+ };
151
+ };
152
+ export type FooterLink = {
153
+ label: string;
154
+ href: string;
155
+ };
156
+ export type Footer = {
157
+ columns: {
158
+ heading?: string;
159
+ links: FooterLink[];
160
+ }[];
161
+ legal: FooterLink[];
162
+ note?: string;
163
+ };
102
164
  export type SiteBase = {
103
165
  url: string;
104
166
  lang: string;
105
167
  business: Business;
106
168
  form?: string;
169
+ menu?: Menu;
170
+ footer?: Footer;
171
+ look?: Look;
107
172
  redirects: Redirect[]; /** Whether page paths end in a slash (the default). */
108
173
  trailingSlash?: boolean;
109
174
  };
175
+ /**
176
+ * The site's menu, as its header draws it: the one edited in CalOS (its links in order, one picked out,
177
+ * and the header's button), else the pages that have a menu label, in page order (and no button: the
178
+ * site's own default).
179
+ */
180
+ export declare function menuOf(site: Pick<Site, "pages" | "menu">): Menu;
181
+ /**
182
+ * The site's footer links, as its footer draws them: the ones edited in CalOS (columns, each with
183
+ * its heading; the legal row; a line of small print), else one column of the menu's links and no
184
+ * legal row (the site's own default, its policy pages say).
185
+ */
186
+ export declare function footerOf(site: Pick<Site, "pages" | "menu" | "footer">): Footer;
110
187
  /** The content as the kit sees it: what search, security, images and the checks need. A site's own type has its blocks and extra fields. */
111
188
  export type Site = SiteBase & {
112
189
  pages: Page[];
@@ -117,6 +194,7 @@ export type BlocksOf<B extends Record<string, z.ZodObject>> = {
117
194
  [K in keyof B & string]: z.output<B[K]> & {
118
195
  type: K;
119
196
  id?: string;
197
+ placement?: Placement;
120
198
  };
121
199
  }[keyof B & string];
122
200
  /** A site's content with its own blocks typed: `ContentOf<typeof blocks> & { headerAction?: Link }`. */
@@ -136,6 +214,12 @@ export type DefineOptions = {
136
214
  extend?: z.ZodRawShape;
137
215
  /** Checks only the site can make (a contact block needs somewhere to send), added to the kit's own. */
138
216
  refine?: (site: Site, ctx: z.RefinementCtx) => void;
217
+ /** How each page's share card looks (colours, fonts, a logo, or a card of the site's own); `false` for none (../share.ts). */
218
+ share?: ShareOptions;
219
+ /** Every section gets a Layout (placement: alignment, width, spacing, screens), drawn by the site through placementClass. */
220
+ placement?: boolean;
221
+ /** The site's Look, chosen in CalOS (fonts from the ones it carries, colours, corners, buttons), drawn through CSS variables (./look.ts). */
222
+ look?: LookOptions;
139
223
  };
140
224
  /**
141
225
  * A site's content shape from its blocks: each a name and the fields it has. The site's own block
@@ -153,8 +237,29 @@ export declare function defineSite<const B extends Record<string, z.ZodObject>>(
153
237
  src: z.ZodString;
154
238
  alt: z.ZodString;
155
239
  }, z.core.$strip>>;
240
+ shareTitle: z.ZodOptional<z.ZodString>;
241
+ shareText: z.ZodOptional<z.ZodString>;
156
242
  noindex: z.ZodDefault<z.ZodBoolean>;
157
243
  }, z.core.$strip>>;
244
+ look?: z.ZodOptional<z.ZodObject<{
245
+ brand: z.ZodDefault<z.ZodString>;
246
+ accent: z.ZodDefault<z.ZodString>;
247
+ corners: z.ZodDefault<z.ZodEnum<{
248
+ square: "square";
249
+ soft: "soft";
250
+ round: "round";
251
+ }>>;
252
+ buttons: z.ZodDefault<z.ZodEnum<{
253
+ solid: "solid";
254
+ outline: "outline";
255
+ }>>;
256
+ headingFont?: z.ZodDefault<z.ZodEnum<{
257
+ [x: string]: string;
258
+ }>> | undefined;
259
+ bodyFont?: z.ZodDefault<z.ZodEnum<{
260
+ [x: string]: string;
261
+ }>> | undefined;
262
+ }, z.core.$strip>> | undefined;
158
263
  url: z.ZodURL;
159
264
  lang: z.ZodDefault<z.ZodString>;
160
265
  business: z.ZodObject<{
@@ -205,6 +310,31 @@ export declare function defineSite<const B extends Record<string, z.ZodObject>>(
205
310
  }, z.core.$strip>>;
206
311
  }, z.core.$strip>;
207
312
  form: z.ZodOptional<z.ZodURL>;
313
+ menu: z.ZodOptional<z.ZodObject<{
314
+ links: z.ZodArray<z.ZodObject<{
315
+ label: z.ZodString;
316
+ href: z.ZodString;
317
+ emphasis: z.ZodDefault<z.ZodBoolean>;
318
+ }, z.core.$strip>>;
319
+ button: z.ZodOptional<z.ZodObject<{
320
+ label: z.ZodString;
321
+ href: z.ZodString;
322
+ }, z.core.$strip>>;
323
+ }, z.core.$strip>>;
324
+ footer: z.ZodOptional<z.ZodObject<{
325
+ columns: z.ZodArray<z.ZodObject<{
326
+ heading: z.ZodOptional<z.ZodString>;
327
+ links: z.ZodArray<z.ZodObject<{
328
+ label: z.ZodString;
329
+ href: z.ZodString;
330
+ }, z.core.$strip>>;
331
+ }, z.core.$strip>>;
332
+ legal: z.ZodDefault<z.ZodArray<z.ZodObject<{
333
+ label: z.ZodString;
334
+ href: z.ZodString;
335
+ }, z.core.$strip>>>;
336
+ note: z.ZodOptional<z.ZodString>;
337
+ }, z.core.$strip>>;
208
338
  redirects: z.ZodDefault<z.ZodArray<z.ZodObject<{
209
339
  from: z.ZodString;
210
340
  to: z.ZodString;
@@ -224,8 +354,29 @@ export declare function defineSite<const B extends Record<string, z.ZodObject>>(
224
354
  src: z.ZodString;
225
355
  alt: z.ZodString;
226
356
  }, z.core.$strip>>;
357
+ shareTitle: z.ZodOptional<z.ZodString>;
358
+ shareText: z.ZodOptional<z.ZodString>;
227
359
  noindex: z.ZodDefault<z.ZodBoolean>;
228
360
  }, z.core.$strip>>;
361
+ look?: z.ZodOptional<z.ZodObject<{
362
+ brand: z.ZodDefault<z.ZodString>;
363
+ accent: z.ZodDefault<z.ZodString>;
364
+ corners: z.ZodDefault<z.ZodEnum<{
365
+ square: "square";
366
+ soft: "soft";
367
+ round: "round";
368
+ }>>;
369
+ buttons: z.ZodDefault<z.ZodEnum<{
370
+ solid: "solid";
371
+ outline: "outline";
372
+ }>>;
373
+ headingFont?: z.ZodDefault<z.ZodEnum<{
374
+ [x: string]: string;
375
+ }>> | undefined;
376
+ bodyFont?: z.ZodDefault<z.ZodEnum<{
377
+ [x: string]: string;
378
+ }>> | undefined;
379
+ }, z.core.$strip>> | undefined;
229
380
  url: z.ZodURL;
230
381
  lang: z.ZodDefault<z.ZodString>;
231
382
  business: z.ZodObject<{
@@ -276,6 +427,31 @@ export declare function defineSite<const B extends Record<string, z.ZodObject>>(
276
427
  }, z.core.$strip>>;
277
428
  }, z.core.$strip>;
278
429
  form: z.ZodOptional<z.ZodURL>;
430
+ menu: z.ZodOptional<z.ZodObject<{
431
+ links: z.ZodArray<z.ZodObject<{
432
+ label: z.ZodString;
433
+ href: z.ZodString;
434
+ emphasis: z.ZodDefault<z.ZodBoolean>;
435
+ }, z.core.$strip>>;
436
+ button: z.ZodOptional<z.ZodObject<{
437
+ label: z.ZodString;
438
+ href: z.ZodString;
439
+ }, z.core.$strip>>;
440
+ }, z.core.$strip>>;
441
+ footer: z.ZodOptional<z.ZodObject<{
442
+ columns: z.ZodArray<z.ZodObject<{
443
+ heading: z.ZodOptional<z.ZodString>;
444
+ links: z.ZodArray<z.ZodObject<{
445
+ label: z.ZodString;
446
+ href: z.ZodString;
447
+ }, z.core.$strip>>;
448
+ }, z.core.$strip>>;
449
+ legal: z.ZodDefault<z.ZodArray<z.ZodObject<{
450
+ label: z.ZodString;
451
+ href: z.ZodString;
452
+ }, z.core.$strip>>>;
453
+ note: z.ZodOptional<z.ZodString>;
454
+ }, z.core.$strip>>;
279
455
  redirects: z.ZodDefault<z.ZodArray<z.ZodObject<{
280
456
  from: z.ZodString;
281
457
  to: z.ZodString;
@@ -293,6 +469,8 @@ export declare function defineSite<const B extends Record<string, z.ZodObject>>(
293
469
  };
294
470
  trailingSlash: boolean;
295
471
  csp: Record<string, string[]>;
472
+ share: ShareOptions | undefined;
473
+ look: LookOptions | undefined;
296
474
  /** The declaration CalOS draws its editing forms from. */
297
475
  jsonSchema: () => Record<string, unknown>;
298
476
  };
@@ -10,6 +10,8 @@
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";
13
15
  export { checkStructure, describePath } from "../schema.js";
14
16
  export { z };
15
17
  // Fields carry an "x-ui" hint in the JSON Schema, so CalOS draws the right control: a text area, a
@@ -36,6 +38,29 @@ export const time = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, { error: "A ti
36
38
  export const DAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
37
39
  /** What every block has: a place on the page, for links like /#contact. */
38
40
  const blockBase = { id: z.string().regex(/^[a-z][\w-]*$/).optional() };
41
+ /**
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.)
46
+ */
47
+ export const placement = z
48
+ .object({
49
+ align: z.enum(["left", "center"]).default("left").meta({ title: "Alignment" }),
50
+ width: z.enum(["narrow", "normal", "wide"]).default("normal").meta({ title: "Width" }),
51
+ spacing: z.enum(["compact", "normal", "roomy"]).default("normal").meta({ title: "Spacing" }),
52
+ show: z.enum(["all", "desktop", "phone"]).default("all").meta({ title: "Show on" }),
53
+ })
54
+ .meta({ title: "Layout" });
55
+ /** A section's placement as class names, only what differs from the usual: "place-align-center place-width-narrow", or "". */
56
+ export function placementClass(block) {
57
+ const l = block.placement ?? {};
58
+ const usual = { align: "left", width: "normal", spacing: "normal", show: "all" };
59
+ return Object.keys(usual)
60
+ .filter((k) => l[k] && l[k] !== usual[k])
61
+ .map((k) => `place-${k}-${l[k]}`)
62
+ .join(" ");
63
+ }
39
64
  const address = z.object({
40
65
  street: text,
41
66
  locality: text,
@@ -77,10 +102,29 @@ const pageBase = (trailingSlash) => ({
77
102
  description: text,
78
103
  /** Its label in the menu; leave out to keep it off the menu. */
79
104
  menu: text.optional(),
80
- /** The picture shown when the page is shared; defaults to the first image on it. */
81
- share: image.optional(),
105
+ /** A picture to share the page with instead of its card (made from its words at build). */
106
+ share: image.optional().meta({ title: "Share picture" }),
107
+ /** What the page's share card says, when not its title and description. */
108
+ shareTitle: text.optional().meta({ title: "Share title" }),
109
+ shareText: longText.optional().meta({ title: "Share text" }),
82
110
  noindex: z.boolean().default(false),
83
111
  });
112
+ /**
113
+ * 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
115
+ * site's own default).
116
+ */
117
+ export function menuOf(site) {
118
+ return site.menu ?? { links: site.pages.filter((p) => p.menu).map((p) => ({ label: p.menu, href: p.path })) };
119
+ }
120
+ /**
121
+ * The site's footer links, as its footer draws them: the ones edited in CalOS (columns, each with
122
+ * its heading; the legal row; a line of small print), else one column of the menu's links and no
123
+ * legal row (the site's own default, its policy pages say).
124
+ */
125
+ export function footerOf(site) {
126
+ return site.footer ?? { columns: [{ links: menuOf(site).links.map(({ label, href }) => ({ label, href })) }], legal: [] };
127
+ }
84
128
  /**
85
129
  * A site's content shape from its blocks: each a name and the fields it has. The site's own block
86
130
  * type is `z.output<typeof content.schema>["pages"][number]["blocks"][number]`.
@@ -92,7 +136,7 @@ export function defineSite(blocks, o = {}) {
92
136
  const union = z.discriminatedUnion("type",
93
137
  // Each kind keeps what the site said of it (its title, for CalOS to call it by).
94
138
  names.map((type) => {
95
- const kind = z.object({ type: z.literal(type), ...blockBase }).extend(blocks[type].shape);
139
+ const kind = z.object({ type: z.literal(type), ...blockBase, ...(o.placement ? { placement: placement.optional() } : {}) }).extend(blocks[type].shape);
96
140
  const meta = blocks[type].meta();
97
141
  return meta ? kind.meta(meta) : kind;
98
142
  }));
@@ -106,8 +150,26 @@ export function defineSite(blocks, o = {}) {
106
150
  business,
107
151
  /** Where the contact forms post to, if the site has any (a CalOS form's address, or another service's). */
108
152
  form: z.url({ protocol: /^https?$/ }).meta({ title: "Where its forms send" }).optional(),
153
+ /** The header's links in order, and its button: edited as one list in CalOS. Left out, the pages' menu labels are used. */
154
+ menu: z
155
+ .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" }),
158
+ })
159
+ .optional()
160
+ .meta({ title: "Menu" }),
161
+ /** The footer's links: columns, the legal row, and a line of small print. Left out, the menu's links are used. */
162
+ footer: z
163
+ .object({
164
+ columns: z.array(z.object({ heading: text.optional(), links: z.array(z.object({ label: text, href })).max(12) })).max(4),
165
+ legal: z.array(z.object({ label: text, href })).max(6).default([]).meta({ title: "Legal links" }),
166
+ note: text.optional().meta({ title: "Small print" }),
167
+ })
168
+ .optional()
169
+ .meta({ title: "Footer" }),
109
170
  /** Old addresses kept answering, when CalOS doesn't supply them at build. */
110
171
  redirects: z.array(z.object({ from: z.string().startsWith("/"), to: href, status: z.union([z.literal(301), z.literal(302)]).default(301) })).default([]),
172
+ ...(o.look ? { look: lookSchema(o.look) } : {}),
111
173
  ...o.extend,
112
174
  pages: z.array(page).min(1),
113
175
  })
@@ -115,6 +177,9 @@ export function defineSite(blocks, o = {}) {
115
177
  const s = site;
116
178
  for (const i of checkStructure(s))
117
179
  ctx.addIssue({ code: "custom", path: i.path, message: i.message });
180
+ if (o.look)
181
+ for (const i of lookProblems(s.look, o.look))
182
+ ctx.addIssue({ code: "custom", path: i.path, message: i.message });
118
183
  o.refine?.(s, ctx);
119
184
  });
120
185
  return {
@@ -128,6 +193,8 @@ export function defineSite(blocks, o = {}) {
128
193
  },
129
194
  trailingSlash,
130
195
  csp: o.csp ?? {},
196
+ share: o.share,
197
+ look: o.look,
131
198
  /** The declaration CalOS draws its editing forms from. */
132
199
  jsonSchema: () => z.toJSONSchema(schema, { io: "input", unrepresentable: "any" }),
133
200
  };
@@ -0,0 +1,59 @@
1
+ import { z } from "zod";
2
+ export type LookOptions = {
3
+ /** The fonts the site carries, by the name CalOS shows → the font-family it's drawn with. */
4
+ fonts?: Record<string, string>;
5
+ /** Which of them the site's headings and text are in, until others are chosen; else the first. */
6
+ headingFont?: string;
7
+ bodyFont?: string;
8
+ /** The site's own colours, used until others are chosen. */
9
+ brand: string;
10
+ accent?: string;
11
+ /** The page's background, which a brand colour must read against. */
12
+ background?: string;
13
+ corners?: Corners;
14
+ buttons?: Buttons;
15
+ };
16
+ declare const CORNERS: readonly ["square", "soft", "round"];
17
+ declare const BUTTONS: readonly ["solid", "outline"];
18
+ type Corners = (typeof CORNERS)[number];
19
+ type Buttons = (typeof BUTTONS)[number];
20
+ /** The Look's fields, for the site's content. */
21
+ export declare function lookSchema(o: LookOptions): z.ZodOptional<z.ZodObject<{
22
+ brand: z.ZodDefault<z.ZodString>;
23
+ accent: z.ZodDefault<z.ZodString>;
24
+ corners: z.ZodDefault<z.ZodEnum<{
25
+ square: "square";
26
+ soft: "soft";
27
+ round: "round";
28
+ }>>;
29
+ buttons: z.ZodDefault<z.ZodEnum<{
30
+ solid: "solid";
31
+ outline: "outline";
32
+ }>>;
33
+ headingFont?: z.ZodDefault<z.ZodEnum<{
34
+ [x: string]: string;
35
+ }>> | undefined;
36
+ bodyFont?: z.ZodDefault<z.ZodEnum<{
37
+ [x: string]: string;
38
+ }>> | undefined;
39
+ }, z.core.$strip>>;
40
+ export type Look = {
41
+ headingFont?: string;
42
+ bodyFont?: string;
43
+ brand?: string;
44
+ accent?: string;
45
+ corners?: Corners;
46
+ buttons?: Buttons;
47
+ };
48
+ /** How readable one colour is on another (WCAG): 1 to 21. */
49
+ export declare function contrast(a: string, b: string): number;
50
+ /** The colours chosen that won't read: the brand colour is used for text and buttons on the page, so it must stand out from it. */
51
+ export declare function lookProblems(look: Look | undefined, o: LookOptions): {
52
+ path: string[];
53
+ message: string;
54
+ }[];
55
+ /** The Look as CSS variables: what's chosen, else the site's own. */
56
+ export declare function lookVars(look: Look | undefined, o: LookOptions): Record<string, string>;
57
+ /** The Look as a stylesheet for the page's head: `:root { … }`. */
58
+ export declare function lookCss(look: Look | undefined, o: LookOptions): string;
59
+ export {};
@@ -0,0 +1,87 @@
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:
4
+ //
5
+ // --look-heading-font, --look-body-font font-family stacks
6
+ // --look-brand, --look-on-brand the brand colour, and the text that reads on it
7
+ // --look-accent the accent colour
8
+ // --look-radius, --look-button-radius corners: of cards and pictures, and of buttons
9
+ // --look-button-bg, --look-button-fg, --look-button-border
10
+ // --look-button-fill 1 filled, 0 outlined
11
+ //
12
+ // Published, they're a <style> in the page (LookStyle, allowed by its hash); in CalOS's preview they're
13
+ // set on the page as the draft changes (allowed: a style set from script isn't a style attribute).
14
+ import { z } from "zod";
15
+ const CORNERS = ["square", "soft", "round"];
16
+ const BUTTONS = ["solid", "outline"];
17
+ const HEX = /^#[0-9a-f]{6}$/i;
18
+ const color = z.string().regex(HEX, { error: "A colour like #2b5f8a" }).meta({ "x-ui": "color", "x-error": "A colour like #2b5f8a" });
19
+ /** The Look's fields, for the site's content. */
20
+ export function lookSchema(o) {
21
+ const fonts = Object.keys(o.fonts ?? {});
22
+ // 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 });
24
+ return z
25
+ .object({
26
+ ...(fonts.length ? { headingFont: font("Heading font", o.headingFont), bodyFont: font("Text font", o.bodyFont) } : {}),
27
+ brand: color.default(o.brand).meta({ title: "Brand colour" }),
28
+ accent: color.default(o.accent ?? o.brand).meta({ title: "Accent colour" }),
29
+ corners: z.enum(CORNERS).default(o.corners ?? "soft").meta({ title: "Corners" }),
30
+ buttons: z.enum(BUTTONS).default(o.buttons ?? "solid").meta({ title: "Buttons" }),
31
+ })
32
+ .optional()
33
+ .meta({ title: "Look" });
34
+ }
35
+ /** How readable one colour is on another (WCAG): 1 to 21. */
36
+ 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);
42
+ return (x + 0.05) / (y + 0.05);
43
+ }
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. */
45
+ 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 [];
50
+ }
51
+ const RADIUS = { square: ["0", "0"], soft: ["8px", "8px"], round: ["16px", "999px"] };
52
+ /** The Look as CSS variables: what's chosen, else the site's own. */
53
+ export function lookVars(look, o) {
54
+ const l = look ?? {};
55
+ const fonts = o.fonts ?? {};
56
+ 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"];
61
+ const outline = (l.buttons ?? o.buttons ?? "solid") === "outline";
62
+ const vars = {
63
+ "--look-brand": brand,
64
+ "--look-on-brand": onBrand,
65
+ "--look-accent": (l.accent && HEX.test(l.accent) ? l.accent : o.accent) ?? brand,
66
+ "--look-radius": radius,
67
+ "--look-button-radius": buttonRadius,
68
+ "--look-button-bg": outline ? "transparent" : brand,
69
+ "--look-button-fg": outline ? brand : onBrand,
70
+ "--look-button-border": brand,
71
+ /** 1 for a filled button, 0 for an outlined one: for a design that mixes its own colours by it. */
72
+ "--look-button-fill": outline ? "0" : "1",
73
+ };
74
+ const heading = (l.headingFont && fonts[l.headingFont]) || own(o.headingFont);
75
+ const body = (l.bodyFont && fonts[l.bodyFont]) || own(o.bodyFont);
76
+ if (heading)
77
+ vars["--look-heading-font"] = heading;
78
+ if (body)
79
+ vars["--look-body-font"] = body;
80
+ return vars;
81
+ }
82
+ /** The Look as a stylesheet for the page's head: `:root { … }`. */
83
+ export function lookCss(look, o) {
84
+ return `:root{${Object.entries(lookVars(look, o))
85
+ .map(([k, v]) => `${k}:${v.replace(/[<>{};]/g, "")}`)
86
+ .join(";")}}`;
87
+ }
Binary file
Binary file
Binary file
@@ -0,0 +1,93 @@
1
+ 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
+ https://openfontlicense.org
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) and 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.
@@ -2,4 +2,5 @@ export { image, pageAt, prepared, site } from "./data.ts";
2
2
  export { JsonLd } from "./JsonLd.tsx";
3
3
  export { pageMetadata, robots, sitemap, staticParams } from "./metadata.ts";
4
4
  export { Img, type Ratio } from "./parts/Img.tsx";
5
+ export { LookStyle } from "./parts/LookStyle.tsx";
5
6
  export { Md } from "./parts/Md.tsx";
@@ -5,4 +5,5 @@ export { image, pageAt, prepared, site } from "./data.js";
5
5
  export { JsonLd } from "./JsonLd.js";
6
6
  export { pageMetadata, robots, sitemap, staticParams } from "./metadata.js";
7
7
  export { Img } from "./parts/Img.js";
8
+ export { LookStyle } from "./parts/LookStyle.js";
8
9
  export { Md } from "./parts/Md.js";
@@ -2,13 +2,18 @@
2
2
  // all from the prepared content.
3
3
  import { shareImageOf } from "../images.js";
4
4
  import { canonical, fullTitle } from "../seo.js";
5
- import { image, site } from "./data.js";
5
+ import { image, prepared, site } from "./data.js";
6
6
  export function pageMetadata(page) {
7
7
  const s = site();
8
8
  const title = fullTitle(s, page);
9
9
  const url = canonical(s, page.path);
10
- const share = shareImageOf(s, page);
11
- const shareUrl = share ? image(share.src).share : undefined;
10
+ // Shared with: the picture chosen for it, else its card (else, with cards off or a site that
11
+ // prefers them, its first picture).
12
+ const p = prepared();
13
+ const card = p.cards?.[page.path];
14
+ const picture = page.share ?? (!card || p.preferPicture ? shareImageOf(s, page) : undefined);
15
+ const share = picture && !picture.src.endsWith(".svg") ? picture : undefined;
16
+ const shareUrl = share ? image(share.src).share : card;
12
17
  return {
13
18
  metadataBase: new URL(s.url),
14
19
  title: { absolute: title },
@@ -22,7 +27,7 @@ export function pageMetadata(page) {
22
27
  description: page.description,
23
28
  url,
24
29
  locale: s.lang.replace("-", "_"),
25
- images: shareUrl && share ? [{ url: new URL(shareUrl, s.url).href, width: 1200, height: 630, alt: share.alt }] : undefined,
30
+ images: shareUrl ? [{ url: new URL(shareUrl, s.url).href, width: 1200, height: 630, alt: share?.alt ?? page.title }] : undefined,
26
31
  },
27
32
  twitter: { card: shareUrl ? "summary_large_image" : "summary" },
28
33
  };
@@ -0,0 +1,5 @@
1
+ import { type LookOptions } from "../../content/look.ts";
2
+ /** The site's Look as CSS variables, for its layout's <head> (content/look.ts). */
3
+ export declare function LookStyle({ look }: {
4
+ look: LookOptions;
5
+ }): import("react").JSX.Element;
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { lookCss } from "../../content/look.js";
3
+ import { prepared } from "../data.js";
4
+ /** The site's Look as CSS variables, for its layout's <head> (content/look.ts). */
5
+ export function LookStyle({ look }) {
6
+ return _jsx("style", { dangerouslySetInnerHTML: { __html: lookCss(prepared().site.look, look) } });
7
+ }
@@ -1,6 +1,9 @@
1
1
  import { type ReactNode } from "react";
2
2
  import type { Page, Site } from "../content/index.ts";
3
- export declare function SitePreview({ origins, render }: {
3
+ import { type LookOptions } from "../content/look.ts";
4
+ /** `look`: the same Look options the site's defineSite has, for the draft's Look to be drawn as it's chosen. */
5
+ export declare function SitePreview({ origins, render, look }: {
4
6
  origins: string[];
5
7
  render: (page: Page, site: Site) => ReactNode;
8
+ look?: LookOptions;
6
9
  }): import("react").JSX.Element | null;
@@ -7,12 +7,14 @@ import { jsx as _jsx } from "react/jsx-runtime";
7
7
  // them: its caltho.app address and its own domain, from the build) are listened to.
8
8
  //
9
9
  // CalOS → page: { type: "calos:show", site, images, path, selected?, scroll?, labels? }
10
- // page → CalOS: { type: "calos:ready" }, { type: "calos:pick", path, block }, { type: "calos:go", path },
10
+ // page → CalOS: { type: "calos:ready" }, { type: "calos:pick", path, block },
11
11
  // { type: "calos:error", message }
12
12
  import { Component, useEffect, useRef, useState } from "react";
13
+ import { lookVars } from "../content/look.js";
13
14
  import { BUILT_IMAGES, fieldForImage, fieldForText } from "../preview-match.js";
14
15
  import { setPrepared } from "./data.js";
15
- export function SitePreview({ origins, render }) {
16
+ /** `look`: the same Look options the site's defineSite has, for the draft's Look to be drawn as it's chosen. */
17
+ export function SitePreview({ origins, render, look }) {
16
18
  const [shown, setShown] = useState(null);
17
19
  const [round, setRound] = useState(0);
18
20
  const latest = useRef(null);
@@ -66,14 +68,7 @@ export function SitePreview({ origins, render }) {
66
68
  return;
67
69
  e.preventDefault();
68
70
  e.stopPropagation();
69
- const main = document.querySelector("main");
70
- const link = target.closest("a");
71
- if (link && !main?.contains(link)) {
72
- const to = internalPath(link.getAttribute("href"), m.site);
73
- if (to)
74
- post({ type: "calos:go", path: to });
75
- return;
76
- }
71
+ // A link anywhere (a section's, the menu's, the footer's) is edited, never followed: CalOS's page picker moves between pages.
77
72
  // A picture under the pointer counts, even when something over it (a frame, a caption's box) took the click.
78
73
  const under = document.elementsFromPoint(e.clientX, e.clientY).find((el) => el.tagName === "IMG") ?? target;
79
74
  post({ type: "calos:pick", ...pick(under, m, wordsAt(e.clientX, e.clientY)) });
@@ -138,6 +133,13 @@ export function SitePreview({ origins, render }) {
138
133
  window.removeEventListener("resize", keep);
139
134
  };
140
135
  }, [shown, round]);
136
+ // The draft's Look, set on the page (LookStyle drew the published one into the build).
137
+ useEffect(() => {
138
+ if (!shown || !look)
139
+ return;
140
+ for (const [k, v] of Object.entries(lookVars(shown.site.look, look)))
141
+ document.documentElement.style.setProperty(k, v);
142
+ }, [shown, look]);
141
143
  if (!shown)
142
144
  return null;
143
145
  const page = shown.site.pages.find((p) => p.path === shown.path) ?? shown.site.pages[0];
@@ -169,17 +171,6 @@ function asBuilt(images) {
169
171
  }
170
172
  return out;
171
173
  }
172
- /** A path on this site from a link's address, as the content writes it; null for anywhere else. */
173
- function internalPath(href, site) {
174
- if (!href || href.startsWith("#") || /^(mailto|tel):/.test(href))
175
- return null;
176
- const url = new URL(href, location.href);
177
- if (url.origin !== location.origin && url.origin !== new URL(site.url).origin)
178
- return null;
179
- const bare = url.pathname.replace(/\/$/, "") || "/";
180
- const withSlash = bare === "/" ? "/" : `${bare}/`;
181
- return site.pages.some((p) => p.path === bare) ? bare : site.pages.some((p) => p.path === withSlash) ? withSlash : null;
182
- }
183
174
  const pageIndex = (m) => m.site.pages.findIndex((p) => p.path === m.path);
184
175
  /** What's in `main` without being drawn: a page's structured data (JSON-LD) and the like. */
185
176
  const UNDRAWN = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "LINK", "META"]);
@@ -226,7 +217,9 @@ function pick(target, m, words = null) {
226
217
  return { path: found, block: blockOf(found) ?? block };
227
218
  }
228
219
  if (words) {
229
- const exact = (scope && fieldForText(scope, block, words, false)) ?? fieldForText(shared, [], words, false) ?? fieldForText(pageOwn, ["pages", pi], words, false);
220
+ // Outside a section, a menu made from the pages' menu labels is any page's label.
221
+ const labels = { pages: m.site.pages.map((p) => ({ menu: p.menu })) };
222
+ const exact = (scope && fieldForText(scope, block, words, false)) ?? fieldForText(shared, [], words, false) ?? fieldForText(pageOwn, ["pages", pi], words, false) ?? (n < 0 ? fieldForText(labels, [], words, false) : null);
230
223
  if (exact)
231
224
  return { path: exact, block: blockOf(exact) ?? block };
232
225
  }
package/dist/prepare.d.ts CHANGED
@@ -21,6 +21,10 @@ export type Prepared = {
21
21
  csp: Record<string, string[]>;
22
22
  /** The addresses of the CalOS this content came from (every one the business uses it on): those that may draw drafts in the preview. */
23
23
  editors?: string[];
24
+ /** Each page's share card (../share.ts), by its path; none when the site turned them off. */
25
+ cards?: Record<string, string>;
26
+ /** Whether a page with pictures but no share picture chosen is shared with its first picture rather than its card. */
27
+ preferPicture?: boolean;
24
28
  };
25
29
  type Log = (line: string) => void;
26
30
  export declare function prepare(root: string, env?: Record<string, string | undefined>, log?: Log): Promise<Prepared>;
package/dist/prepare.js CHANGED
@@ -20,6 +20,7 @@ import { dirname, extname, join, relative } from "node:path";
20
20
  import sharp from "sharp";
21
21
  import { pathToFileURL } from "node:url";
22
22
  import { imagesIn, shareImageOf } from "./images.js";
23
+ import { makeCards } from "./share.js";
23
24
  const WIDTHS = [400, 640, 800, 1024, 1280, 1600, 2000];
24
25
  export async function prepare(root, env = process.env, log = console.log) {
25
26
  const out = join(root, ".site");
@@ -86,7 +87,9 @@ export async function prepare(root, env = process.env, log = console.log) {
86
87
  if (env.CALOS_CONTENT_URL)
87
88
  await pushSchema(env.CALOS_CONTENT_URL, env.CALOS_SCHEMA_URL, env.CALOS_SITE_TOKEN, schema, log);
88
89
  const images = await pictures(site, imagesDir, pub, log);
89
- const prepared = { site, images, csp: definition.csp, ...(editors.length ? { editors } : {}) };
90
+ const cards = await makeCards(site, definition.share, root, pub, log);
91
+ const preferPicture = !!definition.share && definition.share.prefer === "picture";
92
+ const prepared = { site, images, csp: definition.csp, cards, ...(preferPicture ? { preferPicture } : {}), ...(editors.length ? { editors } : {}) };
90
93
  await writeFile(join(out, "prepared.json"), JSON.stringify(prepared));
91
94
  log(`site-kit: content from ${from}, ${site.pages.length} pages, ${Object.keys(images).length} images`);
92
95
  return prepared;
@@ -0,0 +1,54 @@
1
+ import type { Page, Site } from "./content/index.ts";
2
+ export declare const SHARE_WIDTH = 1200;
3
+ export declare const SHARE_HEIGHT = 630;
4
+ /** What a card says. */
5
+ export type ShareCard = {
6
+ title: string;
7
+ text: string;
8
+ business: string;
9
+ path: string;
10
+ };
11
+ /** A node of the card, as satori draws it (a subset of HTML and CSS, flexbox only). */
12
+ export type CardNode = {
13
+ type: string;
14
+ props: Record<string, unknown> & {
15
+ style?: Record<string, unknown>;
16
+ children?: unknown;
17
+ };
18
+ };
19
+ export type ShareFont = {
20
+ name: string; /** A .woff, .ttf or .otf, from the site's root. */
21
+ path: string;
22
+ weight?: 400 | 500 | 600 | 700;
23
+ style?: "normal" | "italic";
24
+ };
25
+ export type ShareOptions = false | {
26
+ /** The card's colour behind everything (any CSS colour). */
27
+ background?: string;
28
+ /** A picture behind it, from the site's root (public/og.png), stretched to 1200×630. */
29
+ backgroundImage?: string;
30
+ /** The title's colour, the text's, and the line above the title. */
31
+ color?: string;
32
+ muted?: string;
33
+ accent?: string;
34
+ /** The business's logo (a .png or .svg from the site's root), top left; else its name in words. */
35
+ logo?: string;
36
+ /** The site's own fonts; the first's name is the family used. Else DM Sans, which the kit carries. */
37
+ fonts?: ShareFont[];
38
+ /** Which first, for a page that has pictures but no share picture chosen: the card (the default) or its first picture. */
39
+ prefer?: "card" | "picture";
40
+ /** A card of the site's own: given what it says and helpers, the node to draw. */
41
+ render?: (card: ShareCard, h: {
42
+ el: typeof el;
43
+ image: (path: string) => Promise<string>;
44
+ }) => CardNode | Promise<CardNode>;
45
+ };
46
+ /** A box: a div, flex by default as satori wants it. */
47
+ export declare const el: (style: Record<string, unknown>, children?: unknown, props?: Record<string, unknown>) => CardNode;
48
+ /** What a page's card says: the share title and text CalOS can set, else its title and description. */
49
+ export declare function cardOf(site: Site, page: Page): ShareCard;
50
+ /**
51
+ * Each page's card, made into public/_img/share/ (named from what it says and how it's drawn, so a
52
+ * card already made is kept); its address by page path. Nothing when the site turned cards off.
53
+ */
54
+ export declare function makeCards(site: Site, options: ShareOptions | undefined, root: string, pub: string, log?: (line: string) => void): Promise<Record<string, string>>;
package/dist/share.js ADDED
@@ -0,0 +1,87 @@
1
+ // The card each page is shared with (Open Graph, 1200×630): made at build from the page's own words
2
+ // (its share title and text when CalOS gives it them, else its title and description) and the
3
+ // business's name, in the site's colours and fonts. Content edited in CalOS rebuilds the site, so a
4
+ // card always says what the page says. A page with a share picture chosen keeps the picture.
5
+ //
6
+ // A site sets how its cards look with `defineSite(blocks, { share })`: colours, a background picture,
7
+ // fonts, a logo, or its own `render` for a different card altogether; `share: false` turns cards
8
+ // off (pages are shared with a picture from them, as before).
9
+ import { createHash } from "node:crypto";
10
+ import { existsSync } from "node:fs";
11
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
12
+ import { extname, join } from "node:path";
13
+ import { Resvg } from "@resvg/resvg-js";
14
+ import satori from "satori";
15
+ export const SHARE_WIDTH = 1200;
16
+ export const SHARE_HEIGHT = 630;
17
+ /** A box: a div, flex by default as satori wants it. */
18
+ export const el = (style, children, props = {}) => ({ type: "div", props: { ...props, style: { display: "flex", ...style }, children } });
19
+ /** What a page's card says: the share title and text CalOS can set, else its title and description. */
20
+ export function cardOf(site, page) {
21
+ const p = page;
22
+ return { title: (p.shareTitle ?? page.title).slice(0, 120), text: (p.shareText ?? page.description).slice(0, 180), business: site.business.name, path: page.path };
23
+ }
24
+ const KIT_FONTS = [
25
+ { name: "DM Sans", path: "DMSans-Regular.woff", weight: 400 },
26
+ { name: "DM Sans", path: "DMSans-Medium.woff", weight: 500 },
27
+ { name: "DM Sans", path: "DMSans-Bold.woff", weight: 700 },
28
+ ];
29
+ const TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".svg": "image/svg+xml" };
30
+ async function dataUri(root, path) {
31
+ const type = TYPES[extname(path).toLowerCase()];
32
+ if (!type)
33
+ throw new Error(`The share card's picture ${path} isn't a .png, .jpg, .webp or .svg`);
34
+ return `data:${type};base64,${(await readFile(join(root, path))).toString("base64")}`;
35
+ }
36
+ /** The card the kit draws when a site doesn't draw its own: its name (or logo), the title large, the text under it. */
37
+ async function defaultCard(card, o, root, family) {
38
+ const size = card.title.length > 60 ? 54 : card.title.length > 32 ? 66 : 80;
39
+ const bg = o.backgroundImage ? { backgroundImage: `url(${await dataUri(root, o.backgroundImage)})`, backgroundSize: "100% 100%" } : {};
40
+ const top = o.logo
41
+ ? { type: "img", props: { src: await dataUri(root, o.logo), height: 56, style: { height: 56, objectFit: "contain" } } }
42
+ : el({ fontSize: 30, fontWeight: 700, color: o.color ?? "#16181d", letterSpacing: "-0.02em" }, card.business);
43
+ return el({ width: "100%", height: "100%", flexDirection: "column", justifyContent: "space-between", padding: "72px", background: o.background ?? "#f7f6f2", fontFamily: family, ...bg }, [
44
+ top,
45
+ el({ flexDirection: "column", gap: "24px", maxWidth: "980px" }, [
46
+ el({ width: 72, height: 6, borderRadius: 3, background: o.accent ?? "#2b5f8a" }),
47
+ el({ fontSize: size, fontWeight: 700, lineHeight: 1.08, letterSpacing: "-0.03em", color: o.color ?? "#16181d" }, card.title),
48
+ ...(card.text ? [el({ fontSize: 30, fontWeight: 400, lineHeight: 1.4, color: o.muted ?? "#575c66", maxWidth: "900px" }, card.text)] : []),
49
+ ]),
50
+ ]);
51
+ }
52
+ /**
53
+ * Each page's card, made into public/_img/share/ (named from what it says and how it's drawn, so a
54
+ * card already made is kept); its address by page path. Nothing when the site turned cards off.
55
+ */
56
+ export async function makeCards(site, options, root, pub, log = () => { }) {
57
+ if (options === false)
58
+ return {};
59
+ const o = options ?? {};
60
+ const fonts = o.fonts?.length ? o.fonts.map((f) => ({ ...f, from: root })) : KIT_FONTS.map((f) => ({ ...f, from: new URL("./fonts/", import.meta.url).pathname }));
61
+ const loaded = await Promise.all(fonts.map(async (f) => ({ name: f.name, data: await readFile(join(f.from, f.path)), weight: f.weight ?? 400, style: f.style ?? "normal" })));
62
+ const family = fonts[0].name;
63
+ const dir = join(pub, "_img", "share");
64
+ await mkdir(dir, { recursive: true });
65
+ // How the cards are drawn, in the names: a change of colours, fonts or the site's own render makes them anew.
66
+ const look = createHash("sha256")
67
+ .update(JSON.stringify({ ...o, render: o.render?.toString() ?? null }))
68
+ .digest("hex");
69
+ const out = {};
70
+ let made = 0;
71
+ for (const page of site.pages) {
72
+ const card = cardOf(site, page);
73
+ const hash = createHash("sha256").update(look).update(JSON.stringify(card)).digest("hex").slice(0, 10);
74
+ const slug = page.path.replace(/^\/|\/$/g, "").replace(/[^\w-]+/g, "-") || "home";
75
+ const name = `${slug}.${hash}.png`;
76
+ out[page.path] = `/_img/share/${name}`;
77
+ if (existsSync(join(dir, name)))
78
+ continue;
79
+ const node = o.render ? await o.render(card, { el, image: (path) => dataUri(root, path) }) : await defaultCard(card, o, root, family);
80
+ const svg = await satori(node, { width: SHARE_WIDTH, height: SHARE_HEIGHT, fonts: loaded });
81
+ await writeFile(join(dir, name), new Resvg(svg, { fitTo: { mode: "width", value: SHARE_WIDTH } }).render().asPng());
82
+ made++;
83
+ }
84
+ if (made)
85
+ log(`site-kit: ${made} share card${made === 1 ? "" : "s"} made`);
86
+ return out;
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caltho/site-kit",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "What every Caltho site is built with: the contract with CalOS (content, and how a site declares what is editable), images, search and security, and the checks",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -50,13 +50,15 @@
50
50
  "node": ">=22.18"
51
51
  },
52
52
  "scripts": {
53
- "build": "rm -rf dist && tsc -p tsconfig.build.json && chmod +x dist/cli/index.js",
53
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && cp -R src/fonts dist/fonts && chmod +x dist/cli/index.js",
54
54
  "typecheck": "tsc -p tsconfig.json --noEmit",
55
55
  "test": "bun test src",
56
56
  "prepack": "bun run build"
57
57
  },
58
58
  "dependencies": {
59
+ "@resvg/resvg-js": "2.6.2",
59
60
  "marked": "18.1.0",
61
+ "satori": "0.33.4",
60
62
  "sharp": "0.35.5",
61
63
  "zod": "4.6.7"
62
64
  },