@caltho/site-kit 0.1.0 → 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 +25 -8
- 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/dist/schema.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Working with a site's declaration as data, with no zod and nothing else: CalOS (its server and its
|
|
2
|
+
// editor in the browser) has only the JSON Schema a site's build wrote, not the site's code. From it:
|
|
3
|
+
// check content with messages a person can act on, make a blank new item (a block, a list entry),
|
|
4
|
+
// and find the kinds of block a site has. Also the checks that need no schema at all (pages, home,
|
|
5
|
+
// links), and readable places in the content.
|
|
6
|
+
const unions = (s) => s.anyOf ?? s.oneOf;
|
|
7
|
+
/** Follows a `$ref` to its definition in the same document. */
|
|
8
|
+
export function resolve(s, root) {
|
|
9
|
+
if (!s.$ref)
|
|
10
|
+
return s;
|
|
11
|
+
const name = s.$ref.replace(/^#\/\$defs\//, "");
|
|
12
|
+
return root.$defs?.[name] ?? s;
|
|
13
|
+
}
|
|
14
|
+
const kind = (s) => (Array.isArray(s.type) ? s.type.find((t) => t !== "null") : s.type);
|
|
15
|
+
const isNull = (s) => s.type === "null";
|
|
16
|
+
/** A union of objects told apart by a `type` field: its branches by that field's value; else null. */
|
|
17
|
+
export function branches(s, root) {
|
|
18
|
+
const u = unions(resolve(s, root));
|
|
19
|
+
if (!u)
|
|
20
|
+
return null;
|
|
21
|
+
const out = {};
|
|
22
|
+
for (const b0 of u) {
|
|
23
|
+
const b = resolve(b0, root);
|
|
24
|
+
const t = b.properties?.type?.const ?? b.properties?.type?.enum?.[0];
|
|
25
|
+
if (typeof t !== "string")
|
|
26
|
+
return null;
|
|
27
|
+
out[t] = b;
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
/** The kinds of block (or any discriminated list item) a list's items can be, in the order the site gave them. */
|
|
32
|
+
export function kindsOf(items, root) {
|
|
33
|
+
return Object.keys(branches(items, root) ?? {});
|
|
34
|
+
}
|
|
35
|
+
const words = (path) => path.filter((p) => typeof p === "string").at(-1);
|
|
36
|
+
/** What's wrong with `value` against `schema`, as places and plain messages. */
|
|
37
|
+
export function validate(schema, value, root = schema, path = []) {
|
|
38
|
+
const s = resolve(schema, root);
|
|
39
|
+
const issues = [];
|
|
40
|
+
const at = (message) => issues.push({ path, message: s["x-error"] ?? message });
|
|
41
|
+
const u = unions(s);
|
|
42
|
+
if (u) {
|
|
43
|
+
if (value === null && u.some(isNull))
|
|
44
|
+
return [];
|
|
45
|
+
const by = branches(s, root);
|
|
46
|
+
if (by) {
|
|
47
|
+
const t = value && typeof value === "object" ? value.type : undefined;
|
|
48
|
+
const branch = typeof t === "string" ? by[t] : undefined;
|
|
49
|
+
if (!branch)
|
|
50
|
+
return [{ path: [...path, "type"], message: `Choose one of: ${Object.keys(by).join(", ")}` }];
|
|
51
|
+
return validate(branch, value, root, path);
|
|
52
|
+
}
|
|
53
|
+
// Any one of them will do; if none does, say what's wrong with the nearest.
|
|
54
|
+
const tried = u.filter((b) => !isNull(b)).map((b) => validate(b, value, root, path));
|
|
55
|
+
return tried.some((t) => !t.length) ? [] : tried.sort((a, b) => a.length - b.length)[0] ?? [];
|
|
56
|
+
}
|
|
57
|
+
if (s.const !== undefined)
|
|
58
|
+
return value === s.const ? [] : [{ path, message: `Should be ${JSON.stringify(s.const)}` }];
|
|
59
|
+
if (s.enum)
|
|
60
|
+
return s.enum.includes(value) ? [] : [{ path, message: `Choose one of: ${s.enum.join(", ")}` }];
|
|
61
|
+
switch (kind(s)) {
|
|
62
|
+
case "object": {
|
|
63
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
64
|
+
return [{ path, message: "Should be a group of fields" }];
|
|
65
|
+
const o = value;
|
|
66
|
+
for (const key of s.required ?? []) {
|
|
67
|
+
if (o[key] === undefined || o[key] === null)
|
|
68
|
+
issues.push({ path: [...path, key], message: "This is needed" });
|
|
69
|
+
}
|
|
70
|
+
for (const [key, sub] of Object.entries(s.properties ?? {})) {
|
|
71
|
+
if (o[key] === undefined || o[key] === null)
|
|
72
|
+
continue;
|
|
73
|
+
issues.push(...validate(sub, o[key], root, [...path, key]));
|
|
74
|
+
}
|
|
75
|
+
return issues;
|
|
76
|
+
}
|
|
77
|
+
case "array": {
|
|
78
|
+
if (!Array.isArray(value))
|
|
79
|
+
return [{ path, message: "Should be a list" }];
|
|
80
|
+
if (s.minItems && value.length < s.minItems)
|
|
81
|
+
at(s.minItems === 1 ? "Add at least one" : `Add at least ${s.minItems}`);
|
|
82
|
+
if (s.maxItems && value.length > s.maxItems)
|
|
83
|
+
at(`At most ${s.maxItems}`);
|
|
84
|
+
if (s.items)
|
|
85
|
+
value.forEach((v, i) => issues.push(...validate(s.items, v, root, [...path, i])));
|
|
86
|
+
return issues;
|
|
87
|
+
}
|
|
88
|
+
case "string": {
|
|
89
|
+
if (typeof value !== "string")
|
|
90
|
+
return [{ path, message: "Should be text" }];
|
|
91
|
+
if (s.minLength && value.trim().length < s.minLength)
|
|
92
|
+
return [{ path, message: s.minLength === 1 ? "This can't be empty" : `At least ${s.minLength} characters` }];
|
|
93
|
+
if (s.maxLength && value.length > s.maxLength)
|
|
94
|
+
return [{ path, message: `At most ${s.maxLength} characters` }];
|
|
95
|
+
if (s.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
|
|
96
|
+
return [{ path, message: "An email address like name@example.com" }];
|
|
97
|
+
if (s.format === "uri" && !/^[a-z][a-z0-9+.-]*:\/\/\S+$/i.test(value))
|
|
98
|
+
return [{ path, message: "A full address starting https://" }];
|
|
99
|
+
if (s.pattern && !safeRegex(s.pattern).test(value))
|
|
100
|
+
return [{ path, message: s["x-error"] ?? `That doesn't look right` }];
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
case "integer":
|
|
104
|
+
case "number": {
|
|
105
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
106
|
+
return [{ path, message: "Should be a number" }];
|
|
107
|
+
if (kind(s) === "integer" && !Number.isInteger(value))
|
|
108
|
+
return [{ path, message: "Should be a whole number" }];
|
|
109
|
+
if (s.minimum !== undefined && value < s.minimum)
|
|
110
|
+
return [{ path, message: `At least ${s.minimum}` }];
|
|
111
|
+
if (s.maximum !== undefined && value > s.maximum)
|
|
112
|
+
return [{ path, message: `At most ${s.maximum}` }];
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
case "boolean":
|
|
116
|
+
return typeof value === "boolean" ? [] : [{ path, message: "Should be yes or no" }];
|
|
117
|
+
default:
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const cache = new Map();
|
|
122
|
+
function safeRegex(pattern) {
|
|
123
|
+
let r = cache.get(pattern);
|
|
124
|
+
if (!r) {
|
|
125
|
+
try {
|
|
126
|
+
r = new RegExp(pattern);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
r = /.*/s;
|
|
130
|
+
}
|
|
131
|
+
cache.set(pattern, r);
|
|
132
|
+
}
|
|
133
|
+
return r;
|
|
134
|
+
}
|
|
135
|
+
/** A blank value for a schema: what a new list entry or block starts as, with what's needed in place. */
|
|
136
|
+
export function blank(schema, root = schema, kindOfBlock) {
|
|
137
|
+
const s = resolve(schema, root);
|
|
138
|
+
if (s.default !== undefined)
|
|
139
|
+
return structuredClone(s.default);
|
|
140
|
+
const u = unions(s);
|
|
141
|
+
if (u) {
|
|
142
|
+
const by = branches(s, root);
|
|
143
|
+
if (by)
|
|
144
|
+
return blank(by[kindOfBlock ?? Object.keys(by)[0]], root);
|
|
145
|
+
return blank(u.find((b) => !isNull(b)) ?? u[0], root);
|
|
146
|
+
}
|
|
147
|
+
if (s.const !== undefined)
|
|
148
|
+
return s.const;
|
|
149
|
+
if (s.enum)
|
|
150
|
+
return s.enum[0];
|
|
151
|
+
switch (kind(s)) {
|
|
152
|
+
case "object": {
|
|
153
|
+
const out = {};
|
|
154
|
+
for (const [key, sub] of Object.entries(s.properties ?? {})) {
|
|
155
|
+
const r = resolve(sub, root);
|
|
156
|
+
if (s.required?.includes(key) || r.default !== undefined)
|
|
157
|
+
out[key] = blank(sub, root);
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
case "array": {
|
|
162
|
+
const n = s.minItems ?? 0;
|
|
163
|
+
return Array.from({ length: n }, () => blank(s.items ?? {}, root));
|
|
164
|
+
}
|
|
165
|
+
case "boolean":
|
|
166
|
+
return false;
|
|
167
|
+
case "integer":
|
|
168
|
+
case "number":
|
|
169
|
+
return s.minimum ?? 0;
|
|
170
|
+
default:
|
|
171
|
+
return "";
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* What's wrong with a site's content that no field's shape can say: two pages at one path, no home
|
|
176
|
+
* page, a link to a page that isn't there or to a part of a page that isn't. Pure, on the content as
|
|
177
|
+
* data, so CalOS can run the same check on a draft as the build does.
|
|
178
|
+
*/
|
|
179
|
+
export function checkStructure(site) {
|
|
180
|
+
const issues = [];
|
|
181
|
+
const paths = new Set();
|
|
182
|
+
site.pages.forEach((p, i) => {
|
|
183
|
+
if (paths.has(p.path))
|
|
184
|
+
issues.push({ path: ["pages", i, "path"], message: `Two pages at ${p.path}` });
|
|
185
|
+
paths.add(p.path);
|
|
186
|
+
});
|
|
187
|
+
if (!paths.has("/"))
|
|
188
|
+
issues.push({ path: ["pages"], message: "There's no home page: add a page with path /" });
|
|
189
|
+
return [...issues, ...linkIssues(site)];
|
|
190
|
+
}
|
|
191
|
+
/** Every link to a page of the site must land on one, and every link to a part of a page must have that part. */
|
|
192
|
+
function linkIssues(site) {
|
|
193
|
+
const issues = [];
|
|
194
|
+
const ids = new Map(site.pages.map((p) => [p.path, new Set(p.blocks.map((b) => b.id).filter(Boolean))]));
|
|
195
|
+
const visit = (value, path, pagePath) => {
|
|
196
|
+
if (Array.isArray(value))
|
|
197
|
+
value.forEach((v, i) => visit(v, [...path, i], pagePath));
|
|
198
|
+
else if (value && typeof value === "object") {
|
|
199
|
+
for (const [k, v] of Object.entries(value)) {
|
|
200
|
+
if (k === "href" && typeof v === "string" && (v.startsWith("#") || v.startsWith("/"))) {
|
|
201
|
+
const to = v.startsWith("#") && pagePath ? pagePath + v : v;
|
|
202
|
+
const [p, hash] = to.split("#");
|
|
203
|
+
const target = p.split("?")[0];
|
|
204
|
+
if (!ids.has(target))
|
|
205
|
+
issues.push({ path: [...path, k], message: `No page at ${target}` });
|
|
206
|
+
else if (hash && !ids.get(target).has(hash))
|
|
207
|
+
issues.push({ path: [...path, k], message: `${target} has no block with id "${hash}"` });
|
|
208
|
+
}
|
|
209
|
+
else
|
|
210
|
+
visit(v, [...path, k], pagePath);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
site.pages.forEach((p, i) => visit(p.blocks, ["pages", i, "blocks"], p.path));
|
|
215
|
+
for (const [k, v] of Object.entries(site))
|
|
216
|
+
if (k !== "pages" && k !== "redirects")
|
|
217
|
+
visit(v, [k], null);
|
|
218
|
+
return issues;
|
|
219
|
+
}
|
|
220
|
+
/** "pages[2] (/services/) › blocks[1] (services) › items[0].title", readable in a build log or an editor. */
|
|
221
|
+
export function describePath(raw, path) {
|
|
222
|
+
let at = raw;
|
|
223
|
+
const out = [];
|
|
224
|
+
for (const key of path) {
|
|
225
|
+
at = at && typeof at === "object" ? at[key] : undefined;
|
|
226
|
+
if (typeof key === "number") {
|
|
227
|
+
const o = at;
|
|
228
|
+
const label = o && typeof o === "object" ? (o.path ?? o.type ?? o.title ?? o.name ?? o.label) : undefined;
|
|
229
|
+
out[out.length - 1] = `${out[out.length - 1] ?? ""}[${key}]${typeof label === "string" ? ` (${label})` : ""}`;
|
|
230
|
+
}
|
|
231
|
+
else
|
|
232
|
+
out.push(String(key));
|
|
233
|
+
}
|
|
234
|
+
return out.join(" › ") || "site.json";
|
|
235
|
+
}
|
|
236
|
+
/** The last named field in a path, for a short label. */
|
|
237
|
+
export const fieldOf = words;
|
package/dist/seo.d.ts
CHANGED
|
@@ -17,13 +17,15 @@ export declare function ldJson(data: unknown): string;
|
|
|
17
17
|
export declare function robotsTxt(site: Site): string;
|
|
18
18
|
/** Every page search engines should list, at its canonical address. */
|
|
19
19
|
export declare function sitemapXml(site: Site): string;
|
|
20
|
+
/** Where CalOS's preview draws a draft (./next/preview.tsx): framed by CalOS, never indexed. */
|
|
21
|
+
export declare const PREVIEW_PATH = "/calos-preview";
|
|
20
22
|
/**
|
|
21
23
|
* Cloudflare Pages' `_headers`: the security headers our site checks look for (the content
|
|
22
24
|
* security policy itself is in each page, with hashes of the page's own scripts and styles), long
|
|
23
25
|
* caching for the fingerprinted assets, and `noindex` on the preview addresses (<site>.caltho.site
|
|
24
26
|
* and <project>.pages.dev) so only the real domain is in search results.
|
|
25
27
|
*/
|
|
26
|
-
export declare function headersFile(): string;
|
|
28
|
+
export declare function headersFile(editors?: string[]): string;
|
|
27
29
|
/**
|
|
28
30
|
* The site's `_redirects`: CalOS's list for this site when the build is given its address
|
|
29
31
|
* (CALOS_REDIRECTS_URL, the Website › Redirects page's list), otherwise the content's own. A
|
package/dist/seo.js
CHANGED
|
@@ -75,7 +75,7 @@ export function breadcrumbData(site, page) {
|
|
|
75
75
|
const crumbs = [{ name: "Home", path: "/" }];
|
|
76
76
|
const parts = page.path.split("/").filter(Boolean);
|
|
77
77
|
parts.forEach((_, i) => {
|
|
78
|
-
const path = `/${parts.slice(0, i + 1).join("/")}
|
|
78
|
+
const path = `/${parts.slice(0, i + 1).join("/")}${site.trailingSlash === false ? "" : "/"}`;
|
|
79
79
|
const p = site.pages.find((x) => x.path === path);
|
|
80
80
|
if (p)
|
|
81
81
|
crumbs.push({ name: p.menu ?? p.title, path });
|
|
@@ -99,19 +99,25 @@ export function sitemapXml(site) {
|
|
|
99
99
|
const urls = site.pages.filter((p) => !p.noindex).map((p) => ` <url><loc>${canonical(site, p.path)}</loc></url>`);
|
|
100
100
|
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join("\n")}\n</urlset>\n`;
|
|
101
101
|
}
|
|
102
|
+
/** Where CalOS's preview draws a draft (./next/preview.tsx): framed by CalOS, never indexed. */
|
|
103
|
+
export const PREVIEW_PATH = "/calos-preview";
|
|
102
104
|
/**
|
|
103
105
|
* Cloudflare Pages' `_headers`: the security headers our site checks look for (the content
|
|
104
106
|
* security policy itself is in each page, with hashes of the page's own scripts and styles), long
|
|
105
107
|
* caching for the fingerprinted assets, and `noindex` on the preview addresses (<site>.caltho.site
|
|
106
108
|
* and <project>.pages.dev) so only the real domain is in search results.
|
|
107
109
|
*/
|
|
108
|
-
export function headersFile() {
|
|
110
|
+
export function headersFile(editors = []) {
|
|
111
|
+
// Every page refuses to be framed (X-Frame-Options, which every browser honours), but the
|
|
112
|
+
// preview: there, only the CalOS addresses the build was given (./next/preview.tsx), which is
|
|
113
|
+
// why the refusal isn't also a frame-ancestors policy on every page, which the preview's couldn't
|
|
114
|
+
// loosen. With no CalOS addresses, the preview refuses too.
|
|
115
|
+
const preview = editors.length ? ` ! X-Frame-Options\n Content-Security-Policy: frame-ancestors ${editors.join(" ")}\n` : "";
|
|
109
116
|
return `/*
|
|
110
117
|
X-Content-Type-Options: nosniff
|
|
111
118
|
Referrer-Policy: strict-origin-when-cross-origin
|
|
112
119
|
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
|
|
113
120
|
X-Frame-Options: DENY
|
|
114
|
-
Content-Security-Policy: frame-ancestors 'none'
|
|
115
121
|
Strict-Transport-Security: max-age=31536000
|
|
116
122
|
|
|
117
123
|
/_next/static/*
|
|
@@ -123,6 +129,10 @@ export function headersFile() {
|
|
|
123
129
|
/_fonts/*
|
|
124
130
|
Cache-Control: public, max-age=31536000, immutable
|
|
125
131
|
|
|
132
|
+
${PREVIEW_PATH}
|
|
133
|
+
${preview} X-Robots-Tag: noindex
|
|
134
|
+
Cache-Control: no-store
|
|
135
|
+
|
|
126
136
|
https://:project.pages.dev/*
|
|
127
137
|
X-Robots-Tag: noindex
|
|
128
138
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@caltho/site-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
@@ -32,8 +32,18 @@
|
|
|
32
32
|
"default": "./dist/lib.js"
|
|
33
33
|
},
|
|
34
34
|
"./next": {
|
|
35
|
-
"types": "./dist/next/index.d.ts",
|
|
35
|
+
"types": "./dist/next/index.server.d.ts",
|
|
36
|
+
"react-server": "./dist/next/index.server.js",
|
|
37
|
+
"node": "./dist/next/index.server.js",
|
|
36
38
|
"default": "./dist/next/index.js"
|
|
39
|
+
},
|
|
40
|
+
"./schema": {
|
|
41
|
+
"types": "./dist/schema.d.ts",
|
|
42
|
+
"default": "./dist/schema.js"
|
|
43
|
+
},
|
|
44
|
+
"./next/preview": {
|
|
45
|
+
"types": "./dist/next/preview.d.ts",
|
|
46
|
+
"default": "./dist/next/preview.js"
|
|
37
47
|
}
|
|
38
48
|
},
|
|
39
49
|
"engines": {
|