@caltho/site-kit 0.1.1 → 0.2.0
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 +20 -3
- package/dist/cli/check.d.ts +3 -4
- package/dist/cli/check.js +7 -4
- package/dist/cli/index.js +4 -2
- package/dist/cli/serve.js +11 -1
- package/dist/content/index.d.ts +20 -4
- package/dist/content/index.js +48 -67
- package/dist/finish.js +21 -13
- package/dist/images.d.ts +1 -1
- package/dist/images.js +8 -4
- package/dist/next/data-fs.d.ts +1 -0
- package/dist/next/data-fs.js +13 -0
- package/dist/next/data.d.ts +4 -0
- package/dist/next/data.js +20 -14
- package/dist/next/index.server.d.ts +4 -0
- package/dist/next/index.server.js +9 -0
- package/dist/next/preview.d.ts +6 -0
- package/dist/next/preview.js +277 -0
- package/dist/prepare.d.ts +4 -0
- package/dist/prepare.js +46 -7
- package/dist/preview-match.d.ts +23 -0
- package/dist/preview-match.js +78 -0
- package/dist/schema.d.ts +60 -0
- package/dist/schema.js +237 -0
- package/dist/seo.d.ts +3 -1
- package/dist/seo.js +13 -3
- package/package.json +12 -2
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# site-kit
|
|
2
2
|
|
|
3
3
|
What every Caltho site is built with, published to npm as `@caltho/site-kit`. Each client's site is its
|
|
4
|
-
own repository with its own design, started from [site-starter](https://github.com/
|
|
4
|
+
own repository with its own design, started from [site-starter](https://github.com/caltho-sites/site-starter).
|
|
5
5
|
The kit is not a design system. It's the contract that lets CalOS manage any of those sites, and the
|
|
6
6
|
plumbing every one of them needs.
|
|
7
7
|
|
|
@@ -33,6 +33,21 @@ export default defineSite({
|
|
|
33
33
|
remove. Add a kind of block to a site and it appears in CalOS with no work on CalOS's side.
|
|
34
34
|
- A new page is a new entry in the list with a path; the site's catch-all route renders it.
|
|
35
35
|
|
|
36
|
+
**Editing on the page.** A site gives CalOS a page at `/calos-preview` that draws a draft with the
|
|
37
|
+
site's own components, as CalOS sends it (`SitePreview` from `@caltho/site-kit/next/preview`):
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
// app/calos-preview/page.tsx
|
|
41
|
+
import { previewOrigins } from "@caltho/site-kit/next";
|
|
42
|
+
import { Preview } from "@/components/Preview"; // "use client": <SitePreview origins={origins} render={(page) => <SitePage page={page} />} />
|
|
43
|
+
export default function CalosPreview() { return <Preview origins={previewOrigins()} />; }
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Components read content only through the kit (`site()`, `image()`), so the same components draw the
|
|
47
|
+
built pages (from `.site/prepared.json`) and the draft (from CalOS). A click on the preview is matched
|
|
48
|
+
back to the field it meant with nothing added to the components. Only the business's own CalOS
|
|
49
|
+
addresses, which the build is given with the content, may frame the page or send it anything.
|
|
50
|
+
|
|
36
51
|
Forms are separate and optional. A block can post to any address (a CalOS form, another service, or none);
|
|
37
52
|
the kit only checks it's a URL.
|
|
38
53
|
|
|
@@ -41,13 +56,15 @@ the kit only checks it's a URL.
|
|
|
41
56
|
| part | what it does |
|
|
42
57
|
| --- | --- |
|
|
43
58
|
| **Content** (`@caltho/site-kit/content`) | `defineSite` and the pieces to build blocks from (`text`, `md`, `image`, `link`, `href`, `business`, …); the checks, including that every link lands on a page and every `#part` exists; the JSON Schema. |
|
|
44
|
-
| **prepare** | Loads the content from CalOS (`CALOS_CONTENT_URL`, `CALOS_SITE_TOKEN
|
|
59
|
+
| **prepare** | Loads the content from CalOS (`CALOS_CONTENT_URL`, `CALOS_SITE_TOKEN`; the site's own while nothing's published there) or `content/site.json`, sends CalOS the schema, checks it, writes the schema, and makes every picture the content names as WebP at the widths screens need plus a 1200×630 share crop, named by content so they cache for a year. |
|
|
45
60
|
| **finish** | After the framework's build: a content security policy in every page (each inline script named by hash), `_headers` (security headers, long caching, `noindex` on preview addresses), and `_redirects` from CalOS (`CALOS_REDIRECTS_URL`; a build that can't reach CalOS fails rather than lose them). |
|
|
46
61
|
| **check** | Every page in a real browser through axe (WCAG 2.0–2.2 A and AA, best practice) on a computer and a phone; fails on any violation, on anything the policy blocks, and on anything that doesn't load. |
|
|
47
62
|
| **Search** | Titles that fit, canonical addresses, the business as schema.org data (hours, areas, address), FAQ and breadcrumb data, robots.txt, the sitemap. |
|
|
48
63
|
| **Policies** | Privacy, accessibility and terms, written from the business's facts and what the site does. Optional: a site calls `policy()` where it wants them. |
|
|
49
64
|
| **Helpers for components** (`@caltho/site-kit/lib`) | The pieces that touch no files: `md` (Markdown that can't run HTML), `plain`, `policy`, the search data, `imagesIn`, `clock`, `dayRange`, `telHref`. |
|
|
50
|
-
| **Next.js helpers** (`@caltho/site-kit/next`) | The prepared content, a page by its path, page metadata, `Img` (the prepared widths), `Md` (Markdown that can't run HTML), `JsonLd`.
|
|
65
|
+
| **Next.js helpers** (`@caltho/site-kit/next`) | The prepared content, a page by its path, page metadata, `Img` (the prepared widths), `Md` (Markdown that can't run HTML), `JsonLd`, `previewOrigins`. The content is a store: on the server it reads `.site/prepared.json`; in the preview, what CalOS sends. |
|
|
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
|
+
| **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. |
|
|
51
68
|
|
|
52
69
|
## A build
|
|
53
70
|
|
package/dist/cli/check.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
export declare function check(dir: string,
|
|
2
|
-
|
|
3
|
-
(
|
|
4
|
-
(message?: any, ...optionalParams: any[]): void;
|
|
1
|
+
export declare function check(dir: string, o?: {
|
|
2
|
+
allow?: string[];
|
|
3
|
+
log?: (line: string) => void;
|
|
5
4
|
}): Promise<boolean>;
|
package/dist/cli/check.js
CHANGED
|
@@ -14,7 +14,9 @@ import { readFileSync } from "node:fs";
|
|
|
14
14
|
import { createRequire } from "node:module";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import { serve } from "./serve.js";
|
|
17
|
-
export async function check(dir,
|
|
17
|
+
export async function check(dir, o = {}) {
|
|
18
|
+
const log = o.log ?? console.log;
|
|
19
|
+
const allow = new Set(o.allow ?? []);
|
|
18
20
|
const require = createRequire(join(process.cwd(), "package.json"));
|
|
19
21
|
const pw = (await import(__rewriteRelativeImportExtension(require.resolve("playwright"))));
|
|
20
22
|
const chromium = pw.chromium ?? pw.default.chromium;
|
|
@@ -40,7 +42,8 @@ export async function check(dir, log = console.log) {
|
|
|
40
42
|
let where = "";
|
|
41
43
|
page.on("console", (m) => m.type() === "error" && problems.push(`${where} (${screen}): console: ${m.text()}`));
|
|
42
44
|
page.on("pageerror", (e) => problems.push(`${where} (${screen}): script error: ${e.message}`));
|
|
43
|
-
|
|
45
|
+
// A request the browser cancelled itself (a link prefetch dropped as the page moves on) isn't a failure.
|
|
46
|
+
page.on("requestfailed", (r) => r.failure()?.errorText !== "net::ERR_ABORTED" && problems.push(`${where} (${screen}): didn't load ${r.url()}`));
|
|
44
47
|
page.on("response", (r) => {
|
|
45
48
|
if (r.status() >= 400 && !r.url().endsWith("/404.html"))
|
|
46
49
|
problems.push(`${where} (${screen}): ${r.status()} for ${r.url()}`);
|
|
@@ -53,7 +56,7 @@ export async function check(dir, log = console.log) {
|
|
|
53
56
|
const result = (await page.evaluate(() =>
|
|
54
57
|
// @ts-expect-error axe is defined on the page by the line above
|
|
55
58
|
window.axe.run(document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "best-practice"] } })));
|
|
56
|
-
for (const v of result.violations) {
|
|
59
|
+
for (const v of result.violations.filter((x) => !allow.has(x.id))) {
|
|
57
60
|
const targets = v.nodes.slice(0, 3).map((n) => n.target.join(" "));
|
|
58
61
|
problems.push(`${path} (${screen}): ${v.id}: ${v.help} [${v.impact}] at ${targets.join(", ")}${v.nodes.length > 3 ? ` and ${v.nodes.length - 3} more` : ""}`);
|
|
59
62
|
}
|
|
@@ -69,6 +72,6 @@ export async function check(dir, log = console.log) {
|
|
|
69
72
|
log(`✗ ${dir}: ${problems.length} problem${problems.length === 1 ? "" : "s"}\n - ${[...new Set(problems)].join("\n - ")}`);
|
|
70
73
|
return false;
|
|
71
74
|
}
|
|
72
|
-
log(`✓ ${dir}: ${paths.length} pages, on a computer and a phone: no accessibility problems axe can find, nothing blocked or broken`);
|
|
75
|
+
log(`✓ ${dir}: ${paths.length} pages, on a computer and a phone: no accessibility problems axe can find${allow.size ? ` (except the ones allowed: ${[...allow].join(", ")})` : ""}, nothing blocked or broken`);
|
|
73
76
|
return true;
|
|
74
77
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// site-kit prepare content checked, schema written, images ready for the build (.site/, public/)
|
|
3
3
|
// site-kit finish [out] after the build: the content security policy, _headers, _redirects
|
|
4
4
|
// site-kit serve [out] [port] the build served as Cloudflare Pages will
|
|
5
|
-
// site-kit check [out]
|
|
5
|
+
// site-kit check [out] [--allow=rule,rule] every page in a real browser: axe, blocked or broken loads
|
|
6
6
|
import { resolve } from "node:path";
|
|
7
7
|
import { finish } from "../finish.js";
|
|
8
8
|
import { prepare } from "../prepare.js";
|
|
@@ -21,7 +21,9 @@ try {
|
|
|
21
21
|
}
|
|
22
22
|
else if (command === "check") {
|
|
23
23
|
const { check } = await import("./check.js");
|
|
24
|
-
|
|
24
|
+
const allow = args.filter((a) => a.startsWith("--allow=")).flatMap((a) => a.slice(8).split(","));
|
|
25
|
+
const dir = args.find((a) => !a.startsWith("--")) ?? "out";
|
|
26
|
+
process.exitCode = (await check(resolve(dir), { allow })) ? 0 : 1;
|
|
25
27
|
}
|
|
26
28
|
else {
|
|
27
29
|
console.error("Usage: site-kit prepare | finish [out] | serve [out] [port] | check [out]");
|
package/dist/cli/serve.js
CHANGED
|
@@ -67,7 +67,17 @@ export function serve(dir, port) {
|
|
|
67
67
|
res.writeHead(400);
|
|
68
68
|
return res.end();
|
|
69
69
|
}
|
|
70
|
-
|
|
70
|
+
// Pages' clean URLs: /about is about.html when there is one (and /about/ goes to /about); a folder's
|
|
71
|
+
// index.html otherwise (and /about goes to /about/).
|
|
72
|
+
const bare = path.replace(/\/$/, "");
|
|
73
|
+
if (bare && !extname(bare) && existsSync(join(root, `${bare}.html`)) && !path.startsWith("/_")) {
|
|
74
|
+
if (path.endsWith("/")) {
|
|
75
|
+
res.writeHead(308, { Location: bare });
|
|
76
|
+
return res.end();
|
|
77
|
+
}
|
|
78
|
+
file = join(root, `${bare}.html`);
|
|
79
|
+
}
|
|
80
|
+
else if (existsSync(file) && statSync(file).isDirectory()) {
|
|
71
81
|
if (!path.endsWith("/")) {
|
|
72
82
|
res.writeHead(308, { Location: `${path}/` });
|
|
73
83
|
return res.end();
|
package/dist/content/index.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
export { checkStructure, describePath, type StructureIssue } from "../schema.ts";
|
|
2
3
|
export { z };
|
|
3
4
|
export declare const text: z.ZodString;
|
|
5
|
+
/** Longer plain text: drawn as a text area. */
|
|
6
|
+
export declare const longText: z.ZodString;
|
|
4
7
|
/** Markdown: paragraphs, **bold**, *italic*, [links](/contact), lists, ### small headings. No HTML. */
|
|
5
8
|
export declare const md: z.ZodString;
|
|
6
9
|
/** A file in the site's `content/images/` folder. */
|
|
@@ -101,7 +104,8 @@ export type SiteBase = {
|
|
|
101
104
|
lang: string;
|
|
102
105
|
business: Business;
|
|
103
106
|
form?: string;
|
|
104
|
-
redirects: Redirect[];
|
|
107
|
+
redirects: Redirect[]; /** Whether page paths end in a slash (the default). */
|
|
108
|
+
trailingSlash?: boolean;
|
|
105
109
|
};
|
|
106
110
|
/** 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. */
|
|
107
111
|
export type Site = SiteBase & {
|
|
@@ -116,12 +120,18 @@ export type BlocksOf<B extends Record<string, z.ZodObject>> = {
|
|
|
116
120
|
};
|
|
117
121
|
}[keyof B & string];
|
|
118
122
|
/** A site's content with its own blocks typed: `ContentOf<typeof blocks> & { headerAction?: Link }`. */
|
|
119
|
-
export type ContentOf<B extends Record<string, z.ZodObject
|
|
120
|
-
pages: (Omit<Page, "blocks"> & {
|
|
123
|
+
export type ContentOf<B extends Record<string, z.ZodObject>, P extends object = {}> = SiteBase & {
|
|
124
|
+
pages: (Omit<Page, "blocks"> & P & {
|
|
121
125
|
blocks: BlocksOf<B>[];
|
|
122
126
|
})[];
|
|
123
127
|
};
|
|
124
128
|
export type DefineOptions = {
|
|
129
|
+
/** Whether page paths end in a slash: `/services/` (the default) or `/services`. Match the site's existing addresses. */
|
|
130
|
+
trailingSlash?: boolean;
|
|
131
|
+
/** Fields of a page beyond the kit's own (text for a share picture, a tag). */
|
|
132
|
+
pageExtend?: z.ZodRawShape;
|
|
133
|
+
/** Sources the content security policy should allow beyond the site's own, by directive: `{ "connect-src": ["https://analytics.example"] }`. */
|
|
134
|
+
csp?: Record<string, string[]>;
|
|
125
135
|
/** Fields of the site beyond the kit's own (a theme, a header button, a form address). */
|
|
126
136
|
extend?: z.ZodRawShape;
|
|
127
137
|
/** Checks only the site can make (a contact block needs somewhere to send), added to the kit's own. */
|
|
@@ -271,12 +281,18 @@ export declare function defineSite<const B extends Record<string, z.ZodObject>>(
|
|
|
271
281
|
to: z.ZodString;
|
|
272
282
|
status: z.ZodDefault<z.ZodUnion<readonly [z.ZodLiteral<301>, z.ZodLiteral<302>]>>;
|
|
273
283
|
}, z.core.$strip>>>;
|
|
274
|
-
}, z.core.$strip
|
|
284
|
+
}, z.core.$strip>> & {
|
|
285
|
+
trailingSlash: boolean;
|
|
286
|
+
};
|
|
287
|
+
warnings: string[];
|
|
275
288
|
problems?: never;
|
|
276
289
|
} | {
|
|
277
290
|
site?: never;
|
|
291
|
+
warnings?: never;
|
|
278
292
|
problems: string[];
|
|
279
293
|
};
|
|
294
|
+
trailingSlash: boolean;
|
|
295
|
+
csp: Record<string, string[]>;
|
|
280
296
|
/** The declaration CalOS draws its editing forms from. */
|
|
281
297
|
jsonSchema: () => Record<string, unknown>;
|
|
282
298
|
};
|
package/dist/content/index.js
CHANGED
|
@@ -9,19 +9,30 @@
|
|
|
9
9
|
// Nothing here knows how a block looks. A custom design adds its own kinds of block and CalOS edits
|
|
10
10
|
// them with no further work.
|
|
11
11
|
import { z } from "zod";
|
|
12
|
+
import { checkStructure, describePath } from "../schema.js";
|
|
13
|
+
export { checkStructure, describePath } from "../schema.js";
|
|
12
14
|
export { z };
|
|
15
|
+
// Fields carry an "x-ui" hint in the JSON Schema, so CalOS draws the right control: a text area, a
|
|
16
|
+
// Markdown box, a picture picker.
|
|
13
17
|
export const text = z.string().trim().min(1);
|
|
18
|
+
/** Longer plain text: drawn as a text area. */
|
|
19
|
+
export const longText = text.meta({ "x-ui": "textarea" });
|
|
14
20
|
/** Markdown: paragraphs, **bold**, *italic*, [links](/contact), lists, ### small headings. No HTML. */
|
|
15
|
-
export const md = text;
|
|
21
|
+
export const md = text.meta({ "x-ui": "markdown" });
|
|
16
22
|
/** A file in the site's `content/images/` folder. */
|
|
17
|
-
export const file = z
|
|
23
|
+
export const file = z
|
|
24
|
+
.string()
|
|
25
|
+
.regex(/^[\w.\-/]+\.(jpe?g|png|webp|avif|svg)$/i, { error: "An image file in content/images, like team/anna.jpg" })
|
|
26
|
+
.meta({ "x-ui": "image-file", "x-error": "A picture file: choose or upload one" });
|
|
18
27
|
/** Describe what's in the picture for someone who can't see it; "" only when it's decoration. */
|
|
19
|
-
export const image = z.object({ src: file, alt: z.string() });
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
28
|
+
export const image = z.object({ src: file, alt: z.string() }).meta({ "x-ui": "image" });
|
|
29
|
+
const HREF_HELP = "A link: a page on the site (/services), a part of a page (#contact), a full address (https://…), tel: or mailto:";
|
|
30
|
+
export const href = z
|
|
31
|
+
.string()
|
|
32
|
+
.regex(/^(\/[\w\-./#?=&%]*|#[\w-]+|https?:\/\/\S+|tel:\+?[\d\s()-]+|mailto:\S+@\S+)$/, { error: HREF_HELP })
|
|
33
|
+
.meta({ "x-error": HREF_HELP });
|
|
23
34
|
export const link = z.object({ label: text, href, style: z.enum(["primary", "secondary"]).optional() });
|
|
24
|
-
export const time = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, { error: "A time like 08:00 or 17:30" });
|
|
35
|
+
export const time = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, { error: "A time like 08:00 or 17:30" }).meta({ "x-error": "A time like 08:00 or 17:30" });
|
|
25
36
|
export const DAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
|
|
26
37
|
/** What every block has: a place on the page, for links like /#contact. */
|
|
27
38
|
const blockBase = { id: z.string().regex(/^[a-z][\w-]*$/).optional() };
|
|
@@ -38,7 +49,7 @@ export const business = z.object({
|
|
|
38
49
|
/** The schema.org kind: Plumber, Electrician, HVACBusiness, Dentist, HairSalon, Restaurant, LocalBusiness… */
|
|
39
50
|
kind: z.string().regex(/^[A-Z]\w+$/).default("LocalBusiness"),
|
|
40
51
|
tagline: text.optional(),
|
|
41
|
-
phone: z.string().regex(/^\+?[\d\s().-]{7,}$/, { error: "A phone number like (555) 010-2030" }).optional(),
|
|
52
|
+
phone: z.string().regex(/^\+?[\d\s().-]{7,}$/, { error: "A phone number like (555) 010-2030" }).meta({ "x-error": "A phone number like (555) 010-2030" }).optional(),
|
|
42
53
|
email: z.email().optional(),
|
|
43
54
|
address: address.optional(),
|
|
44
55
|
/** Off for a business that goes to its customers and doesn't take visitors at its address. */
|
|
@@ -56,18 +67,20 @@ export const business = z.object({
|
|
|
56
67
|
.partial()
|
|
57
68
|
.default({}),
|
|
58
69
|
});
|
|
59
|
-
const pageBase = {
|
|
60
|
-
path:
|
|
70
|
+
const pageBase = (trailingSlash) => ({
|
|
71
|
+
path: trailingSlash
|
|
72
|
+
? z.string().regex(/^\/([a-z0-9-]+\/)*$/, { error: "A path like / or /services/ or /areas/springfield/ (lowercase, ending in /)" }).meta({ "x-error": "A path like / or /services/ or /areas/springfield/ (lowercase, ending in /)" })
|
|
73
|
+
: z.string().regex(/^\/([a-z0-9-]+(\/[a-z0-9-]+)*)?$/, { error: "A path like / or /services or /areas/springfield (lowercase, no / at the end)" }).meta({ "x-error": "A path like / or /services or /areas/springfield (lowercase, no / at the end)" }),
|
|
61
74
|
/** The page's title in search results and the browser tab. The business's name is added after it. */
|
|
62
|
-
title: text
|
|
75
|
+
title: text,
|
|
63
76
|
/** What search results show under the title. */
|
|
64
|
-
description: text
|
|
77
|
+
description: text,
|
|
65
78
|
/** Its label in the menu; leave out to keep it off the menu. */
|
|
66
79
|
menu: text.optional(),
|
|
67
80
|
/** The picture shown when the page is shared; defaults to the first image on it. */
|
|
68
81
|
share: image.optional(),
|
|
69
82
|
noindex: z.boolean().default(false),
|
|
70
|
-
};
|
|
83
|
+
});
|
|
71
84
|
/**
|
|
72
85
|
* A site's content shape from its blocks: each a name and the fields it has. The site's own block
|
|
73
86
|
* type is `z.output<typeof content.schema>["pages"][number]["blocks"][number]`.
|
|
@@ -76,8 +89,15 @@ export function defineSite(blocks, o = {}) {
|
|
|
76
89
|
const names = Object.keys(blocks);
|
|
77
90
|
if (!names.length)
|
|
78
91
|
throw new Error("A site needs at least one kind of block");
|
|
79
|
-
const union = z.discriminatedUnion("type",
|
|
80
|
-
|
|
92
|
+
const union = z.discriminatedUnion("type",
|
|
93
|
+
// Each kind keeps what the site said of it (its title, for CalOS to call it by).
|
|
94
|
+
names.map((type) => {
|
|
95
|
+
const kind = z.object({ type: z.literal(type), ...blockBase }).extend(blocks[type].shape);
|
|
96
|
+
const meta = blocks[type].meta();
|
|
97
|
+
return meta ? kind.meta(meta) : kind;
|
|
98
|
+
}));
|
|
99
|
+
const trailingSlash = o.trailingSlash ?? true;
|
|
100
|
+
const page = z.object({ ...pageBase(trailingSlash), ...o.pageExtend, blocks: z.array(union).min(1) });
|
|
81
101
|
const schema = z
|
|
82
102
|
.object({
|
|
83
103
|
/** The address it's served at once live (its main domain). */
|
|
@@ -93,15 +113,8 @@ export function defineSite(blocks, o = {}) {
|
|
|
93
113
|
})
|
|
94
114
|
.superRefine((site, ctx) => {
|
|
95
115
|
const s = site;
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
if (paths.has(p.path))
|
|
99
|
-
ctx.addIssue({ code: "custom", path: ["pages", i, "path"], message: `Two pages at ${p.path}` });
|
|
100
|
-
paths.add(p.path);
|
|
101
|
-
});
|
|
102
|
-
if (!paths.has("/"))
|
|
103
|
-
ctx.addIssue({ code: "custom", path: ["pages"], message: "There's no home page: add a page with path /" });
|
|
104
|
-
checkLinks(s, ctx);
|
|
116
|
+
for (const i of checkStructure(s))
|
|
117
|
+
ctx.addIssue({ code: "custom", path: i.path, message: i.message });
|
|
105
118
|
o.refine?.(s, ctx);
|
|
106
119
|
});
|
|
107
120
|
return {
|
|
@@ -109,54 +122,22 @@ export function defineSite(blocks, o = {}) {
|
|
|
109
122
|
/** The content checked, or every problem in it as "where: what" lines. */
|
|
110
123
|
parse(raw) {
|
|
111
124
|
const r = schema.safeParse(raw);
|
|
112
|
-
|
|
125
|
+
if (!r.success)
|
|
126
|
+
return { problems: r.error.issues.map((i) => `${describePath(raw, i.path)}: ${i.message}`) };
|
|
127
|
+
return { site: { ...r.data, trailingSlash }, warnings: lengthWarnings(r.data) };
|
|
113
128
|
},
|
|
129
|
+
trailingSlash,
|
|
130
|
+
csp: o.csp ?? {},
|
|
114
131
|
/** The declaration CalOS draws its editing forms from. */
|
|
115
132
|
jsonSchema: () => z.toJSONSchema(schema, { io: "input", unrepresentable: "any" }),
|
|
116
133
|
};
|
|
117
134
|
}
|
|
118
|
-
/**
|
|
119
|
-
function
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
else if (value && typeof value === "object") {
|
|
125
|
-
for (const [k, v] of Object.entries(value)) {
|
|
126
|
-
if (k === "href" && typeof v === "string" && (v.startsWith("#") || v.startsWith("/"))) {
|
|
127
|
-
const to = v.startsWith("#") && page ? page.path + v : v;
|
|
128
|
-
const [p, hash] = to.split("#");
|
|
129
|
-
const target = p.split("?")[0];
|
|
130
|
-
if (!ids.has(target))
|
|
131
|
-
ctx.addIssue({ code: "custom", path: [...path, k], message: `No page at ${target}` });
|
|
132
|
-
else if (hash && !ids.get(target).has(hash))
|
|
133
|
-
ctx.addIssue({ code: "custom", path: [...path, k], message: `${target} has no block with id "${hash}"` });
|
|
134
|
-
}
|
|
135
|
-
else
|
|
136
|
-
visit(v, [...path, k], page);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
};
|
|
140
|
-
site.pages.forEach((p, i) => visit(p.blocks, ["pages", i, "blocks"], p));
|
|
141
|
-
for (const [k, v] of Object.entries(site))
|
|
142
|
-
if (k !== "pages" && k !== "redirects")
|
|
143
|
-
visit(v, [k], null);
|
|
144
|
-
}
|
|
145
|
-
/** "pages[2] (/services/) › blocks[1] (services) › items[0].title", readable in a build log. */
|
|
146
|
-
function where(raw, path) {
|
|
147
|
-
let at = raw;
|
|
148
|
-
const out = [];
|
|
149
|
-
for (const key of path) {
|
|
150
|
-
at = at && typeof at === "object" ? at[key] : undefined;
|
|
151
|
-
if (typeof key === "number") {
|
|
152
|
-
const o = at;
|
|
153
|
-
const label = o && typeof o === "object" ? (o.path ?? o.type ?? o.title ?? o.name ?? o.label) : undefined;
|
|
154
|
-
out[out.length - 1] = `${out[out.length - 1] ?? ""}[${key}]${typeof label === "string" ? ` (${label})` : ""}`;
|
|
155
|
-
}
|
|
156
|
-
else
|
|
157
|
-
out.push(String(key));
|
|
158
|
-
}
|
|
159
|
-
return out.join(" › ") || "site.json";
|
|
135
|
+
/** Search results cut a title at about 60 characters and a description at about 160: said, not refused, since the page still works. */
|
|
136
|
+
function lengthWarnings(site) {
|
|
137
|
+
return site.pages.flatMap((p, i) => [
|
|
138
|
+
...(p.title.length > 60 ? [`pages[${i}] (${p.path}) › title: ${p.title.length} characters; search results show about 60`] : []),
|
|
139
|
+
...(p.description.length > 160 ? [`pages[${i}] (${p.path}) › description: ${p.description.length} characters; search results show about 160`] : []),
|
|
140
|
+
]);
|
|
160
141
|
}
|
|
161
142
|
/** "Mon–Fri", "Sat", "Mon, Wed, Fri" for a set of days. */
|
|
162
143
|
export function dayRange(days) {
|
package/dist/finish.js
CHANGED
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
7
7
|
import { join, relative } from "node:path";
|
|
8
|
-
import { headersFile, loadRedirects } from "./seo.js";
|
|
8
|
+
import { headersFile, loadRedirects, PREVIEW_PATH } from "./seo.js";
|
|
9
9
|
export async function finish(root, outDir, env = process.env, log = console.log) {
|
|
10
10
|
const prepared = JSON.parse(await readFile(join(root, ".site", "prepared.json"), "utf8"));
|
|
11
11
|
const site = prepared.site;
|
|
12
12
|
const formOrigin = site.form ? new URL(site.form).origin : null;
|
|
13
|
+
const editors = prepared.editors ?? [];
|
|
13
14
|
const pages = (await walk(outDir)).filter((f) => f.endsWith(".html"));
|
|
14
15
|
const attributes = [];
|
|
15
16
|
for (const file of pages) {
|
|
@@ -27,17 +28,24 @@ export async function finish(root, outDir, env = process.env, log = console.log)
|
|
|
27
28
|
// A style attribute can't be allowed by hash without allowing every one: the template uses classes.
|
|
28
29
|
if (/<[a-z][^>]*\sstyle\s*=/i.test(html))
|
|
29
30
|
attributes.push(relative(outDir, file));
|
|
30
|
-
const
|
|
31
|
-
"default-src 'self'",
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"img-src 'self' data:",
|
|
35
|
-
"font-src 'self'",
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
|
|
31
|
+
const directives = {
|
|
32
|
+
"default-src": ["'self'"],
|
|
33
|
+
"script-src": ["'self'", ...[...scripts].map((h) => `'${h}'`)],
|
|
34
|
+
"style-src": ["'self'", ...[...styles].map((h) => `'${h}'`)],
|
|
35
|
+
"img-src": ["'self'", "data:"],
|
|
36
|
+
"font-src": ["'self'"],
|
|
37
|
+
// A form sends with fetch as often as by posting the page: its address for both.
|
|
38
|
+
"connect-src": ["'self'", ...(formOrigin ? [formOrigin] : [])],
|
|
39
|
+
"form-action": ["'self'", ...(formOrigin ? [formOrigin] : [])],
|
|
40
|
+
"base-uri": ["'self'"],
|
|
41
|
+
"object-src": ["'none'"],
|
|
42
|
+
};
|
|
43
|
+
for (const [directive, sources] of Object.entries(prepared.csp ?? {}))
|
|
44
|
+
directives[directive] = [...new Set([...(directives[directive] ?? ["'self'"]), ...sources])];
|
|
45
|
+
// The preview shows the draft's pictures from CalOS, before any build has made them.
|
|
46
|
+
if (relative(outDir, file).replace(/(\/index)?\.html$/, "") === PREVIEW_PATH.slice(1))
|
|
47
|
+
directives["img-src"].push(...editors);
|
|
48
|
+
const policy = Object.entries(directives).map(([d, v]) => `${d} ${v.join(" ")}`).join("; ");
|
|
41
49
|
html = html.replace(/<meta http-equiv="content-security-policy"[^>]*>/i, "");
|
|
42
50
|
html = html.replace(/<head([^>]*)>/i, `<head$1><meta http-equiv="content-security-policy" content="${policy}">`);
|
|
43
51
|
await writeFile(file, html);
|
|
@@ -45,7 +53,7 @@ export async function finish(root, outDir, env = process.env, log = console.log)
|
|
|
45
53
|
if (attributes.length) {
|
|
46
54
|
throw new Error(`These pages have inline style attributes, which the content security policy blocks: ${attributes.join(", ")}. Use a class instead.`);
|
|
47
55
|
}
|
|
48
|
-
await writeFile(join(outDir, "_headers"), headersFile());
|
|
56
|
+
await writeFile(join(outDir, "_headers"), headersFile(editors));
|
|
49
57
|
const redirects = await loadRedirects(site, env.CALOS_REDIRECTS_URL);
|
|
50
58
|
await writeFile(join(outDir, "_redirects"), redirects.file);
|
|
51
59
|
log(`site-kit: a content security policy in ${pages.length} pages; _headers; _redirects from ${redirects.from}`);
|
package/dist/images.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Image, Page, Site } from "./content/index.ts";
|
|
2
|
-
/** Every image the content names
|
|
2
|
+
/** Every image the content names. */
|
|
3
3
|
export declare function imagesIn(value: unknown, found?: Set<string>): Set<string>;
|
|
4
4
|
/** The picture a page shows when it's shared: its own, else its first, else the home page's first. */
|
|
5
5
|
export declare function shareImageOf(site: Site, page: Page): Image | undefined;
|
package/dist/images.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
// Which pictures a site's content names, and which one a page is shared with. Pure: no files, no
|
|
2
2
|
// sharp, so a site's components can use it as well as the build step.
|
|
3
|
-
|
|
3
|
+
const IMAGE = /\.(jpe?g|png|webp|avif|svg)$/i;
|
|
4
|
+
/** Every image the content names. */
|
|
4
5
|
export function imagesIn(value, found = new Set()) {
|
|
5
6
|
if (Array.isArray(value))
|
|
6
7
|
value.forEach((v) => imagesIn(v, found));
|
|
7
8
|
else if (value && typeof value === "object") {
|
|
8
9
|
const o = value;
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
// A picture has an `alt` and one or more files named `src` or ending in it (`src`, `phoneSrc`).
|
|
11
|
+
if ("alt" in o)
|
|
12
|
+
for (const [k, v] of Object.entries(o))
|
|
13
|
+
if (/src$/i.test(k) && typeof v === "string" && IMAGE.test(v))
|
|
14
|
+
found.add(v);
|
|
11
15
|
Object.values(o).forEach((v) => imagesIn(v, found));
|
|
12
16
|
}
|
|
13
17
|
return found;
|
|
@@ -28,7 +32,7 @@ function findImage(value, src) {
|
|
|
28
32
|
}
|
|
29
33
|
else if (value && typeof value === "object") {
|
|
30
34
|
const o = value;
|
|
31
|
-
if (o.
|
|
35
|
+
if (typeof o.alt === "string" && Object.entries(o).some(([k, v]) => /src$/i.test(k) && v === src))
|
|
32
36
|
return { src, alt: o.alt };
|
|
33
37
|
for (const v of Object.values(o)) {
|
|
34
38
|
const f = findImage(v, src);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// On the server: the store reads what `site-kit prepare` wrote. Loaded by the server entry
|
|
2
|
+
// (index.server.ts) only, so nothing that runs in a browser imports the file system.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { loadPreparedWith } from "./data.js";
|
|
6
|
+
loadPreparedWith(() => {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(readFileSync(join(process.cwd(), ".site", "prepared.json"), "utf8"));
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
throw new Error("The site isn't prepared: run `site-kit prepare` before the build (the dev and build scripts do)");
|
|
12
|
+
}
|
|
13
|
+
});
|
package/dist/next/data.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { Page } from "../content/index.ts";
|
|
2
2
|
import type { Prepared, PreparedImage } from "../prepare.ts";
|
|
3
|
+
/** Where the server reads the prepared content from (./data-fs.ts). */
|
|
4
|
+
export declare function loadPreparedWith(load: () => Prepared): void;
|
|
5
|
+
/** Puts content in the store: CalOS's preview, with the draft it's editing. */
|
|
6
|
+
export declare function setPrepared(p: Prepared): void;
|
|
3
7
|
export declare function prepared(): Prepared;
|
|
4
8
|
export declare const site: () => import("../content/index.ts").Site;
|
|
5
9
|
export declare function image(src: string): PreparedImage;
|
package/dist/next/data.js
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
// The site's prepared content as components read it: a store, filled on the server from
|
|
2
|
+
// `.site/prepared.json` (./data-fs.ts, which the server entry loads) and in CalOS's preview by the
|
|
3
|
+
// draft CalOS sends (./preview.tsx). Components read it the same way in both, so one set of
|
|
4
|
+
// components draws the published site and the draft being edited.
|
|
5
|
+
let current = null;
|
|
6
|
+
let loader = null;
|
|
7
|
+
/** Where the server reads the prepared content from (./data-fs.ts). */
|
|
8
|
+
export function loadPreparedWith(load) {
|
|
9
|
+
loader = load;
|
|
10
|
+
}
|
|
11
|
+
/** Puts content in the store: CalOS's preview, with the draft it's editing. */
|
|
12
|
+
export function setPrepared(p) {
|
|
13
|
+
current = p;
|
|
14
|
+
}
|
|
5
15
|
export function prepared() {
|
|
6
|
-
if (!
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
throw new Error("The site isn't prepared: run `site-kit prepare` before the build (the dev and build scripts do)");
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
return cached;
|
|
16
|
+
if (loader && (!current || process.env.NODE_ENV === "development"))
|
|
17
|
+
current = loader();
|
|
18
|
+
if (!current)
|
|
19
|
+
throw new Error("The site's content isn't loaded: on the server, run `site-kit prepare` first; in the preview, it comes from CalOS");
|
|
20
|
+
return current;
|
|
15
21
|
}
|
|
16
22
|
export const site = () => prepared().site;
|
|
17
23
|
export function image(src) {
|
|
@@ -22,6 +28,6 @@ export function image(src) {
|
|
|
22
28
|
}
|
|
23
29
|
/** The page at a catch-all route's segments (`[[...path]]`), or null. */
|
|
24
30
|
export function pageAt(path) {
|
|
25
|
-
const at = path?.length ? `/${path.join("/")}
|
|
31
|
+
const at = path?.length ? `/${path.join("/")}${site().trailingSlash === false ? "" : "/"}` : "/";
|
|
26
32
|
return site().pages.find((p) => p.path === at) ?? null;
|
|
27
33
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// The kit's Next.js entry on the server (the "react-server" and "node" conditions): the same as
|
|
2
|
+
// ./index.ts, with the store reading `.site/prepared.json`.
|
|
3
|
+
import "./data-fs.js";
|
|
4
|
+
import { prepared } from "./data.js";
|
|
5
|
+
export * from "./index.js";
|
|
6
|
+
/** The CalOS addresses that may draw drafts in this site's preview (from the content's source); none for content from the repository. */
|
|
7
|
+
export function previewOrigins() {
|
|
8
|
+
return prepared().editors ?? [];
|
|
9
|
+
}
|