@caltho/site-kit 0.2.2 → 0.2.4

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,3 +1,5 @@
1
+ import type { ShareOptions } from "../share.ts";
2
+ export type { CardNode, ShareCard, ShareFont, ShareOptions } from "../share.ts";
1
3
  import { z } from "zod";
2
4
  export { checkStructure, describePath, type StructureIssue } from "../schema.ts";
3
5
  export { z };
@@ -87,6 +89,8 @@ export type Page = {
87
89
  src: string;
88
90
  alt: string;
89
91
  };
92
+ shareTitle?: string;
93
+ shareText?: string;
90
94
  noindex: boolean;
91
95
  blocks: Block[];
92
96
  };
@@ -99,14 +103,52 @@ export type Redirect = {
99
103
  status: 301 | 302;
100
104
  };
101
105
  /** What every site has, whatever its blocks. */
106
+ export type MenuLink = {
107
+ label: string;
108
+ href: string;
109
+ emphasis?: boolean;
110
+ };
111
+ export type Menu = {
112
+ links: MenuLink[];
113
+ button?: {
114
+ label: string;
115
+ href: string;
116
+ };
117
+ };
118
+ export type FooterLink = {
119
+ label: string;
120
+ href: string;
121
+ };
122
+ export type Footer = {
123
+ columns: {
124
+ heading?: string;
125
+ links: FooterLink[];
126
+ }[];
127
+ legal: FooterLink[];
128
+ note?: string;
129
+ };
102
130
  export type SiteBase = {
103
131
  url: string;
104
132
  lang: string;
105
133
  business: Business;
106
134
  form?: string;
135
+ menu?: Menu;
136
+ footer?: Footer;
107
137
  redirects: Redirect[]; /** Whether page paths end in a slash (the default). */
108
138
  trailingSlash?: boolean;
109
139
  };
140
+ /**
141
+ * The site's menu, as its header draws it: the one edited in CalOS (its links in order, one picked out,
142
+ * and the header's button), else the pages that have a menu label, in page order (and no button: the
143
+ * site's own default).
144
+ */
145
+ export declare function menuOf(site: Pick<Site, "pages" | "menu">): Menu;
146
+ /**
147
+ * The site's footer links, as its footer draws them: the ones edited in CalOS (columns, each with
148
+ * its heading; the legal row; a line of small print), else one column of the menu's links and no
149
+ * legal row (the site's own default, its policy pages say).
150
+ */
151
+ export declare function footerOf(site: Pick<Site, "pages" | "menu" | "footer">): Footer;
110
152
  /** 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
153
  export type Site = SiteBase & {
112
154
  pages: Page[];
@@ -136,6 +178,8 @@ export type DefineOptions = {
136
178
  extend?: z.ZodRawShape;
137
179
  /** Checks only the site can make (a contact block needs somewhere to send), added to the kit's own. */
138
180
  refine?: (site: Site, ctx: z.RefinementCtx) => void;
181
+ /** How each page's share card looks (colours, fonts, a logo, or a card of the site's own); `false` for none (../share.ts). */
182
+ share?: ShareOptions;
139
183
  };
140
184
  /**
141
185
  * A site's content shape from its blocks: each a name and the fields it has. The site's own block
@@ -153,6 +197,8 @@ export declare function defineSite<const B extends Record<string, z.ZodObject>>(
153
197
  src: z.ZodString;
154
198
  alt: z.ZodString;
155
199
  }, z.core.$strip>>;
200
+ shareTitle: z.ZodOptional<z.ZodString>;
201
+ shareText: z.ZodOptional<z.ZodString>;
156
202
  noindex: z.ZodDefault<z.ZodBoolean>;
157
203
  }, z.core.$strip>>;
158
204
  url: z.ZodURL;
@@ -205,6 +251,31 @@ export declare function defineSite<const B extends Record<string, z.ZodObject>>(
205
251
  }, z.core.$strip>>;
206
252
  }, z.core.$strip>;
207
253
  form: z.ZodOptional<z.ZodURL>;
254
+ menu: z.ZodOptional<z.ZodObject<{
255
+ links: z.ZodArray<z.ZodObject<{
256
+ label: z.ZodString;
257
+ href: z.ZodString;
258
+ emphasis: z.ZodDefault<z.ZodBoolean>;
259
+ }, z.core.$strip>>;
260
+ button: z.ZodOptional<z.ZodObject<{
261
+ label: z.ZodString;
262
+ href: z.ZodString;
263
+ }, z.core.$strip>>;
264
+ }, z.core.$strip>>;
265
+ footer: z.ZodOptional<z.ZodObject<{
266
+ columns: z.ZodArray<z.ZodObject<{
267
+ heading: z.ZodOptional<z.ZodString>;
268
+ links: z.ZodArray<z.ZodObject<{
269
+ label: z.ZodString;
270
+ href: z.ZodString;
271
+ }, z.core.$strip>>;
272
+ }, z.core.$strip>>;
273
+ legal: z.ZodDefault<z.ZodArray<z.ZodObject<{
274
+ label: z.ZodString;
275
+ href: z.ZodString;
276
+ }, z.core.$strip>>>;
277
+ note: z.ZodOptional<z.ZodString>;
278
+ }, z.core.$strip>>;
208
279
  redirects: z.ZodDefault<z.ZodArray<z.ZodObject<{
209
280
  from: z.ZodString;
210
281
  to: z.ZodString;
@@ -224,6 +295,8 @@ export declare function defineSite<const B extends Record<string, z.ZodObject>>(
224
295
  src: z.ZodString;
225
296
  alt: z.ZodString;
226
297
  }, z.core.$strip>>;
298
+ shareTitle: z.ZodOptional<z.ZodString>;
299
+ shareText: z.ZodOptional<z.ZodString>;
227
300
  noindex: z.ZodDefault<z.ZodBoolean>;
228
301
  }, z.core.$strip>>;
229
302
  url: z.ZodURL;
@@ -276,6 +349,31 @@ export declare function defineSite<const B extends Record<string, z.ZodObject>>(
276
349
  }, z.core.$strip>>;
277
350
  }, z.core.$strip>;
278
351
  form: z.ZodOptional<z.ZodURL>;
352
+ menu: z.ZodOptional<z.ZodObject<{
353
+ links: z.ZodArray<z.ZodObject<{
354
+ label: z.ZodString;
355
+ href: z.ZodString;
356
+ emphasis: z.ZodDefault<z.ZodBoolean>;
357
+ }, z.core.$strip>>;
358
+ button: z.ZodOptional<z.ZodObject<{
359
+ label: z.ZodString;
360
+ href: z.ZodString;
361
+ }, z.core.$strip>>;
362
+ }, z.core.$strip>>;
363
+ footer: z.ZodOptional<z.ZodObject<{
364
+ columns: z.ZodArray<z.ZodObject<{
365
+ heading: z.ZodOptional<z.ZodString>;
366
+ links: z.ZodArray<z.ZodObject<{
367
+ label: z.ZodString;
368
+ href: z.ZodString;
369
+ }, z.core.$strip>>;
370
+ }, z.core.$strip>>;
371
+ legal: z.ZodDefault<z.ZodArray<z.ZodObject<{
372
+ label: z.ZodString;
373
+ href: z.ZodString;
374
+ }, z.core.$strip>>>;
375
+ note: z.ZodOptional<z.ZodString>;
376
+ }, z.core.$strip>>;
279
377
  redirects: z.ZodDefault<z.ZodArray<z.ZodObject<{
280
378
  from: z.ZodString;
281
379
  to: z.ZodString;
@@ -293,6 +391,7 @@ export declare function defineSite<const B extends Record<string, z.ZodObject>>(
293
391
  };
294
392
  trailingSlash: boolean;
295
393
  csp: Record<string, string[]>;
394
+ share: ShareOptions | undefined;
296
395
  /** The declaration CalOS draws its editing forms from. */
297
396
  jsonSchema: () => Record<string, unknown>;
298
397
  };
@@ -77,10 +77,29 @@ const pageBase = (trailingSlash) => ({
77
77
  description: text,
78
78
  /** Its label in the menu; leave out to keep it off the menu. */
79
79
  menu: text.optional(),
80
- /** The picture shown when the page is shared; defaults to the first image on it. */
81
- share: image.optional(),
80
+ /** A picture to share the page with instead of its card (made from its words at build). */
81
+ share: image.optional().meta({ title: "Share picture" }),
82
+ /** What the page's share card says, when not its title and description. */
83
+ shareTitle: text.optional().meta({ title: "Share title" }),
84
+ shareText: longText.optional().meta({ title: "Share text" }),
82
85
  noindex: z.boolean().default(false),
83
86
  });
87
+ /**
88
+ * The site's menu, as its header draws it: the one edited in CalOS (its links in order, one picked out,
89
+ * and the header's button), else the pages that have a menu label, in page order (and no button: the
90
+ * site's own default).
91
+ */
92
+ export function menuOf(site) {
93
+ return site.menu ?? { links: site.pages.filter((p) => p.menu).map((p) => ({ label: p.menu, href: p.path })) };
94
+ }
95
+ /**
96
+ * The site's footer links, as its footer draws them: the ones edited in CalOS (columns, each with
97
+ * its heading; the legal row; a line of small print), else one column of the menu's links and no
98
+ * legal row (the site's own default, its policy pages say).
99
+ */
100
+ export function footerOf(site) {
101
+ return site.footer ?? { columns: [{ links: menuOf(site).links.map(({ label, href }) => ({ label, href })) }], legal: [] };
102
+ }
84
103
  /**
85
104
  * A site's content shape from its blocks: each a name and the fields it has. The site's own block
86
105
  * type is `z.output<typeof content.schema>["pages"][number]["blocks"][number]`.
@@ -106,6 +125,23 @@ export function defineSite(blocks, o = {}) {
106
125
  business,
107
126
  /** Where the contact forms post to, if the site has any (a CalOS form's address, or another service's). */
108
127
  form: z.url({ protocol: /^https?$/ }).meta({ title: "Where its forms send" }).optional(),
128
+ /** The header's links in order, and its button: edited as one list in CalOS. Left out, the pages' menu labels are used. */
129
+ menu: z
130
+ .object({
131
+ links: z.array(z.object({ label: text, href, emphasis: z.boolean().default(false).meta({ title: "Emphasize" }) })).max(10),
132
+ button: z.object({ label: text, href }).optional().meta({ title: "Button" }),
133
+ })
134
+ .optional()
135
+ .meta({ title: "Menu" }),
136
+ /** The footer's links: columns, the legal row, and a line of small print. Left out, the menu's links are used. */
137
+ footer: z
138
+ .object({
139
+ columns: z.array(z.object({ heading: text.optional(), links: z.array(z.object({ label: text, href })).max(12) })).max(4),
140
+ legal: z.array(z.object({ label: text, href })).max(6).default([]).meta({ title: "Legal links" }),
141
+ note: text.optional().meta({ title: "Small print" }),
142
+ })
143
+ .optional()
144
+ .meta({ title: "Footer" }),
109
145
  /** Old addresses kept answering, when CalOS doesn't supply them at build. */
110
146
  redirects: z.array(z.object({ from: z.string().startsWith("/"), to: href, status: z.union([z.literal(301), z.literal(302)]).default(301) })).default([]),
111
147
  ...o.extend,
@@ -128,6 +164,7 @@ export function defineSite(blocks, o = {}) {
128
164
  },
129
165
  trailingSlash,
130
166
  csp: o.csp ?? {},
167
+ share: o.share,
131
168
  /** The declaration CalOS draws its editing forms from. */
132
169
  jsonSchema: () => z.toJSONSchema(schema, { io: "input", unrepresentable: "any" }),
133
170
  };
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,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
  };
@@ -119,6 +119,10 @@ export function SitePreview({ origins, render }) {
119
119
  hovered = null;
120
120
  outline("section", null);
121
121
  outline("hover", null);
122
+ // Another page starts at its top, unless a section on it is to be brought into view.
123
+ if (shown.path !== drawnPath && !(el && shown.scroll))
124
+ window.scrollTo({ top: 0 });
125
+ drawnPath = shown.path;
122
126
  // Scrolled here only: scrollIntoView would scroll CalOS's page around the frame too.
123
127
  if (el && shown.scroll)
124
128
  window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - window.innerHeight / 4, behavior: "smooth" });
@@ -265,6 +269,8 @@ const looks = {
265
269
  hover: { outline: `1px dashed ${BLUE}`, outlineOffset: "3px", borderRadius: "3px" },
266
270
  };
267
271
  let hovered = null;
272
+ /** The page last drawn, so another one starts at its top. */
273
+ let drawnPath = null;
268
274
  const boxes = {};
269
275
  function outline(which, el, label) {
270
276
  let made = boxes[which];
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.2",
3
+ "version": "0.2.4",
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
  },