@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 +25 -0
- package/dist/content/index.d.ts +99 -0
- package/dist/content/index.js +39 -2
- 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/metadata.js +9 -4
- package/dist/next/preview.js +6 -0
- 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,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
|
};
|
package/dist/content/index.js
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
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
|
};
|
package/dist/next/preview.js
CHANGED
|
@@ -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
|
|
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.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
|
},
|