@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 +25 -0
- package/dist/content/index.d.ts +178 -0
- package/dist/content/index.js +70 -3
- package/dist/content/look.d.ts +59 -0
- package/dist/content/look.js +87 -0
- package/dist/fonts/DMSans-Bold.woff +0 -0
- package/dist/fonts/DMSans-Medium.woff +0 -0
- package/dist/fonts/DMSans-Regular.woff +0 -0
- package/dist/fonts/OFL.txt +93 -0
- package/dist/next/index.d.ts +1 -0
- package/dist/next/index.js +1 -0
- package/dist/next/metadata.js +9 -4
- package/dist/next/parts/LookStyle.d.ts +5 -0
- package/dist/next/parts/LookStyle.js +7 -0
- package/dist/next/preview.d.ts +4 -1
- package/dist/next/preview.js +15 -22
- package/dist/prepare.d.ts +4 -0
- package/dist/prepare.js +4 -1
- package/dist/share.d.ts +54 -0
- package/dist/share.js +87 -0
- package/package.json +4 -2
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
|
package/dist/content/index.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/content/index.js
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
package/dist/next/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/next/index.js
CHANGED
|
@@ -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";
|
package/dist/next/metadata.js
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
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
|
|
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,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
|
+
}
|
package/dist/next/preview.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
import type { Page, Site } from "../content/index.ts";
|
|
3
|
-
|
|
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;
|
package/dist/next/preview.js
CHANGED
|
@@ -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 },
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
package/dist/share.d.ts
ADDED
|
@@ -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
|
+
"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
|
},
|