@caltho/site-kit 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +81 -2
- package/dist/cli/check.d.ts +5 -0
- package/dist/cli/check.js +74 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +34 -0
- package/dist/cli/serve.d.ts +3 -0
- package/dist/cli/serve.js +86 -0
- package/dist/content/index.d.ts +289 -0
- package/dist/content/index.js +190 -0
- package/dist/finish.d.ts +3 -0
- package/dist/finish.js +64 -0
- package/dist/images.d.ts +5 -0
- package/dist/images.js +40 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +10 -0
- package/dist/lib.d.ts +5 -0
- package/dist/lib.js +8 -0
- package/dist/markdown.d.ts +6 -0
- package/dist/markdown.js +50 -0
- package/dist/next/JsonLd.d.ts +4 -0
- package/dist/next/JsonLd.js +8 -0
- package/dist/next/data.d.ts +7 -0
- package/dist/next/data.js +27 -0
- package/dist/next/index.d.ts +5 -0
- package/dist/next/index.js +8 -0
- package/dist/next/metadata.d.ts +9 -0
- package/dist/next/metadata.js +40 -0
- package/dist/next/parts/Img.d.ts +9 -0
- package/dist/next/parts/Img.js +14 -0
- package/dist/next/parts/Md.d.ts +6 -0
- package/dist/next/parts/Md.js +6 -0
- package/dist/policies.d.ts +12 -0
- package/dist/policies.js +84 -0
- package/dist/prepare.d.ts +21 -0
- package/dist/prepare.js +159 -0
- package/dist/seo.d.ts +40 -0
- package/dist/seo.js +153 -0
- package/package.json +84 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Caltho
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,82 @@
|
|
|
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/trycaltho/site-starter).
|
|
5
|
+
The kit is not a design system. It's the contract that lets CalOS manage any of those sites, and the
|
|
6
|
+
plumbing every one of them needs.
|
|
7
|
+
|
|
8
|
+
## The idea
|
|
9
|
+
|
|
10
|
+
A site's content (its pages and what's on them) lives in **CalOS**, and the owner edits it there.
|
|
11
|
+
Publishing in CalOS rebuilds the site with that content: no one pushes code to change a page. The
|
|
12
|
+
design, meaning how a block looks and what kinds of block there are, is the site's own code.
|
|
13
|
+
|
|
14
|
+
For CalOS to edit a site it hasn't seen, the site **declares what's editable**. That's all this takes:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
// content/schema.ts
|
|
18
|
+
import { defineSite, image, link, text, z } from "@caltho/site-kit/content";
|
|
19
|
+
|
|
20
|
+
export default defineSite({
|
|
21
|
+
hero: z.object({ heading: text, text: text.optional(), image: image.optional(), actions: z.array(link).default([]) }),
|
|
22
|
+
faq: z.object({ heading: text, items: z.array(z.object({ q: text, a: text })).min(1) }),
|
|
23
|
+
// …whatever this site's design has
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- A page is `path`, `title`, `description`, an optional `menu` label, and a list of **blocks**. A block is
|
|
28
|
+
one of the kinds the site declared, with the fields it declared.
|
|
29
|
+
- `site-kit prepare` checks the content against that, in every build, and says every problem with where it
|
|
30
|
+
is (`pages[0] (/) › blocks[1] (faq) › items[0] › q`). A build never publishes a page with a hole in it.
|
|
31
|
+
- It also writes `.site/schema.json`, the declaration as JSON Schema. **CalOS draws its editing forms
|
|
32
|
+
from that**: text boxes, picture pickers (with required descriptions), lists to add to, reorder and
|
|
33
|
+
remove. Add a kind of block to a site and it appears in CalOS with no work on CalOS's side.
|
|
34
|
+
- A new page is a new entry in the list with a path; the site's catch-all route renders it.
|
|
35
|
+
|
|
36
|
+
Forms are separate and optional. A block can post to any address (a CalOS form, another service, or none);
|
|
37
|
+
the kit only checks it's a URL.
|
|
38
|
+
|
|
39
|
+
## What's in it
|
|
40
|
+
|
|
41
|
+
| part | what it does |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| **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`) or `content/site.json`, 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
|
+
| **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
|
+
| **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
|
+
| **Search** | Titles that fit, canonical addresses, the business as schema.org data (hours, areas, address), FAQ and breadcrumb data, robots.txt, the sitemap. |
|
|
48
|
+
| **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
|
+
| **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`. Server components only. |
|
|
51
|
+
|
|
52
|
+
## A build
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
site-kit prepare # before: content checked, schema written, pictures ready
|
|
56
|
+
next build # the framework, reading .site/prepared.json
|
|
57
|
+
site-kit finish out # after: security policy, _headers, _redirects
|
|
58
|
+
site-kit check out # then: the browser check
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
This is the whole contract between a site and CalOS: content in at build, the declaration out, redirects
|
|
62
|
+
in at build, files out. A site in another framework uses the same `prepare` and `finish` and reads
|
|
63
|
+
`.site/prepared.json` its own way.
|
|
64
|
+
|
|
65
|
+
`prepare` reads `content/schema.ts` directly, so it needs **Node 22.18 or later**.
|
|
66
|
+
|
|
67
|
+
## Working on it
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
bun install
|
|
71
|
+
bun run typecheck && bun test src && bun run build
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
To try a change in a site before releasing it: `bun pm pack`, then in the site
|
|
75
|
+
`bun add file:/path/to/caltho-site-kit-<version>.tgz`.
|
|
76
|
+
|
|
77
|
+
**Releasing**: raise `version` in package.json and merge to main. The Publish workflow sees a version
|
|
78
|
+
that isn't on npm yet, checks and builds it, and **stages** it with npm's trusted publishing (no token is
|
|
79
|
+
stored anywhere). A person then approves it, which is the 2FA step: `npm stage list`, then
|
|
80
|
+
`npm stage approve <id>` (or approve on npmjs.com). Only then is it live, and a site's Dependabot picks it up.
|
|
81
|
+
Set on the package once: Settings › Trusted publisher › GitHub Actions: organization `trycaltho`,
|
|
82
|
+
repository `site-kit`, workflow `publish.yml`, permission "stage publish".
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Checks a built site in a real browser before it's published: every page in its sitemap and the
|
|
2
|
+
// 404 page, on a computer screen and a phone, with axe (WCAG 2.2 AA and best practice), and with
|
|
3
|
+
// nothing blocked by the site's own content security policy or failing to load. Says what's wrong
|
|
4
|
+
// and where; true when nothing is.
|
|
5
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
6
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
7
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
8
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
return path;
|
|
12
|
+
};
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { serve } from "./serve.js";
|
|
17
|
+
export async function check(dir, log = console.log) {
|
|
18
|
+
const require = createRequire(join(process.cwd(), "package.json"));
|
|
19
|
+
const pw = (await import(__rewriteRelativeImportExtension(require.resolve("playwright"))));
|
|
20
|
+
const chromium = pw.chromium ?? pw.default.chromium;
|
|
21
|
+
const axeSource = readFileSync(require.resolve("axe-core/axe.min.js"), "utf8");
|
|
22
|
+
const port = 4800 + Math.floor(Math.random() * 100);
|
|
23
|
+
const base = `http://localhost:${port}`;
|
|
24
|
+
const sitemap = readFileSync(join(dir, "sitemap.xml"), "utf8");
|
|
25
|
+
const paths = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => new URL(m[1]).pathname);
|
|
26
|
+
paths.push("/404.html");
|
|
27
|
+
const server = await serve(dir, port);
|
|
28
|
+
const problems = [];
|
|
29
|
+
const browser = await chromium.launch(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}).catch((err) => {
|
|
30
|
+
server.close();
|
|
31
|
+
throw err;
|
|
32
|
+
});
|
|
33
|
+
try {
|
|
34
|
+
for (const [screen, viewport] of [
|
|
35
|
+
["computer", { width: 1280, height: 800 }],
|
|
36
|
+
["phone", { width: 390, height: 844 }],
|
|
37
|
+
]) {
|
|
38
|
+
const context = await browser.newContext({ viewport });
|
|
39
|
+
const page = await context.newPage();
|
|
40
|
+
let where = "";
|
|
41
|
+
page.on("console", (m) => m.type() === "error" && problems.push(`${where} (${screen}): console: ${m.text()}`));
|
|
42
|
+
page.on("pageerror", (e) => problems.push(`${where} (${screen}): script error: ${e.message}`));
|
|
43
|
+
page.on("requestfailed", (r) => problems.push(`${where} (${screen}): didn't load ${r.url()}`));
|
|
44
|
+
page.on("response", (r) => {
|
|
45
|
+
if (r.status() >= 400 && !r.url().endsWith("/404.html"))
|
|
46
|
+
problems.push(`${where} (${screen}): ${r.status()} for ${r.url()}`);
|
|
47
|
+
});
|
|
48
|
+
for (const path of paths) {
|
|
49
|
+
where = path;
|
|
50
|
+
await page.goto(base + path, { waitUntil: "networkidle" });
|
|
51
|
+
// Through the browser's devtools channel: the page's own policy (rightly) blocks an injected <script>.
|
|
52
|
+
await page.evaluate(axeSource);
|
|
53
|
+
const result = (await page.evaluate(() =>
|
|
54
|
+
// @ts-expect-error axe is defined on the page by the line above
|
|
55
|
+
window.axe.run(document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "best-practice"] } })));
|
|
56
|
+
for (const v of result.violations) {
|
|
57
|
+
const targets = v.nodes.slice(0, 3).map((n) => n.target.join(" "));
|
|
58
|
+
problems.push(`${path} (${screen}): ${v.id}: ${v.help} [${v.impact}] at ${targets.join(", ")}${v.nodes.length > 3 ? ` and ${v.nodes.length - 3} more` : ""}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
await context.close();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
await browser.close();
|
|
66
|
+
server.close();
|
|
67
|
+
}
|
|
68
|
+
if (problems.length) {
|
|
69
|
+
log(`✗ ${dir}: ${problems.length} problem${problems.length === 1 ? "" : "s"}\n - ${[...new Set(problems)].join("\n - ")}`);
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
log(`✓ ${dir}: ${paths.length} pages, on a computer and a phone: no accessibility problems axe can find, nothing blocked or broken`);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// site-kit prepare content checked, schema written, images ready for the build (.site/, public/)
|
|
3
|
+
// site-kit finish [out] after the build: the content security policy, _headers, _redirects
|
|
4
|
+
// site-kit serve [out] [port] the build served as Cloudflare Pages will
|
|
5
|
+
// site-kit check [out] every page in a real browser: axe, blocked or broken loads
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
import { finish } from "../finish.js";
|
|
8
|
+
import { prepare } from "../prepare.js";
|
|
9
|
+
const [command = "", ...args] = process.argv.slice(2);
|
|
10
|
+
const root = process.cwd();
|
|
11
|
+
try {
|
|
12
|
+
if (command === "prepare")
|
|
13
|
+
await prepare(root);
|
|
14
|
+
else if (command === "finish")
|
|
15
|
+
await finish(root, resolve(args[0] ?? "out"));
|
|
16
|
+
else if (command === "serve") {
|
|
17
|
+
const { serve } = await import("./serve.js");
|
|
18
|
+
const port = Number(args[1] ?? 4321);
|
|
19
|
+
await serve(args[0] ?? "out", port);
|
|
20
|
+
console.log(`Serving ${resolve(args[0] ?? "out")} at http://localhost:${port}`);
|
|
21
|
+
}
|
|
22
|
+
else if (command === "check") {
|
|
23
|
+
const { check } = await import("./check.js");
|
|
24
|
+
process.exitCode = (await check(resolve(args[0] ?? "out"))) ? 0 : 1;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
console.error("Usage: site-kit prepare | finish [out] | serve [out] [port] | check [out]");
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
console.error(`✗ ${err.message}`);
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Serves a built site the way Cloudflare Pages will: its `_headers` and `_redirects` applied,
|
|
2
|
+
// `/about` sent on to `/about/`, and 404.html for anything else. For looking at a build and
|
|
3
|
+
// running the checks against it before it's published.
|
|
4
|
+
import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import { extname, join, resolve } from "node:path";
|
|
7
|
+
const TYPES = {
|
|
8
|
+
".html": "text/html; charset=utf-8",
|
|
9
|
+
".css": "text/css; charset=utf-8",
|
|
10
|
+
".js": "text/javascript; charset=utf-8",
|
|
11
|
+
".json": "application/json",
|
|
12
|
+
".xml": "application/xml",
|
|
13
|
+
".txt": "text/plain; charset=utf-8",
|
|
14
|
+
".svg": "image/svg+xml",
|
|
15
|
+
".webp": "image/webp",
|
|
16
|
+
".avif": "image/avif",
|
|
17
|
+
".jpg": "image/jpeg",
|
|
18
|
+
".jpeg": "image/jpeg",
|
|
19
|
+
".png": "image/png",
|
|
20
|
+
".woff2": "font/woff2",
|
|
21
|
+
};
|
|
22
|
+
/** `_headers`: blocks of a path pattern and indented "Name: value" lines. Only path patterns apply here. */
|
|
23
|
+
function readHeaders(root) {
|
|
24
|
+
const file = join(root, "_headers");
|
|
25
|
+
if (!existsSync(file))
|
|
26
|
+
return [];
|
|
27
|
+
const rules = [];
|
|
28
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
29
|
+
if (!line.trim() || line.trim().startsWith("#"))
|
|
30
|
+
continue;
|
|
31
|
+
if (!/^\s/.test(line))
|
|
32
|
+
rules.push({ pattern: line.trim(), headers: [] });
|
|
33
|
+
else {
|
|
34
|
+
const i = line.indexOf(":");
|
|
35
|
+
rules.at(-1)?.headers.push([line.slice(0, i).trim(), line.slice(i + 1).trim()]);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return rules.filter((r) => r.pattern.startsWith("/"));
|
|
39
|
+
}
|
|
40
|
+
function readRedirects(root) {
|
|
41
|
+
const file = join(root, "_redirects");
|
|
42
|
+
if (!existsSync(file))
|
|
43
|
+
return new Map();
|
|
44
|
+
return new Map(readFileSync(file, "utf8")
|
|
45
|
+
.split("\n")
|
|
46
|
+
.map((l) => l.trim().split(/\s+/))
|
|
47
|
+
.filter((p) => p.length >= 2 && p[0].startsWith("/"))
|
|
48
|
+
.map(([from, to, status]) => [from, { to: to, status: Number(status ?? 301) }]));
|
|
49
|
+
}
|
|
50
|
+
const matches = (pattern, path) => (pattern.endsWith("/*") ? path.startsWith(pattern.slice(0, -1)) : pattern === path);
|
|
51
|
+
/** Serves `dir` on `port`; resolves once it's listening. */
|
|
52
|
+
export function serve(dir, port) {
|
|
53
|
+
const root = resolve(dir);
|
|
54
|
+
const server = createServer((req, res) => {
|
|
55
|
+
const path = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
|
|
56
|
+
for (const rule of readHeaders(root))
|
|
57
|
+
if (matches(rule.pattern, path))
|
|
58
|
+
for (const [k, v] of rule.headers)
|
|
59
|
+
res.setHeader(k, v);
|
|
60
|
+
const redirect = readRedirects(root).get(path);
|
|
61
|
+
if (redirect) {
|
|
62
|
+
res.writeHead(redirect.status, { Location: redirect.to });
|
|
63
|
+
return res.end();
|
|
64
|
+
}
|
|
65
|
+
let file = join(root, path);
|
|
66
|
+
if (!file.startsWith(root)) {
|
|
67
|
+
res.writeHead(400);
|
|
68
|
+
return res.end();
|
|
69
|
+
}
|
|
70
|
+
if (existsSync(file) && statSync(file).isDirectory()) {
|
|
71
|
+
if (!path.endsWith("/")) {
|
|
72
|
+
res.writeHead(308, { Location: `${path}/` });
|
|
73
|
+
return res.end();
|
|
74
|
+
}
|
|
75
|
+
file = join(file, "index.html");
|
|
76
|
+
}
|
|
77
|
+
let status = 200;
|
|
78
|
+
if (!existsSync(file) || path.startsWith("/_headers") || path.startsWith("/_redirects")) {
|
|
79
|
+
file = join(root, "404.html");
|
|
80
|
+
status = 404;
|
|
81
|
+
}
|
|
82
|
+
res.writeHead(status, { "Content-Type": TYPES[extname(file)] ?? "application/octet-stream" });
|
|
83
|
+
createReadStream(file).pipe(res);
|
|
84
|
+
});
|
|
85
|
+
return new Promise((ok) => server.listen(port, () => ok(server)));
|
|
86
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export { z };
|
|
3
|
+
export declare const text: z.ZodString;
|
|
4
|
+
/** Markdown: paragraphs, **bold**, *italic*, [links](/contact), lists, ### small headings. No HTML. */
|
|
5
|
+
export declare const md: z.ZodString;
|
|
6
|
+
/** A file in the site's `content/images/` folder. */
|
|
7
|
+
export declare const file: z.ZodString;
|
|
8
|
+
/** Describe what's in the picture for someone who can't see it; "" only when it's decoration. */
|
|
9
|
+
export declare const image: z.ZodObject<{
|
|
10
|
+
src: z.ZodString;
|
|
11
|
+
alt: z.ZodString;
|
|
12
|
+
}, z.core.$strip>;
|
|
13
|
+
export declare const href: z.ZodString;
|
|
14
|
+
export declare const link: z.ZodObject<{
|
|
15
|
+
label: z.ZodString;
|
|
16
|
+
href: z.ZodString;
|
|
17
|
+
style: z.ZodOptional<z.ZodEnum<{
|
|
18
|
+
primary: "primary";
|
|
19
|
+
secondary: "secondary";
|
|
20
|
+
}>>;
|
|
21
|
+
}, z.core.$strip>;
|
|
22
|
+
export declare const time: z.ZodString;
|
|
23
|
+
export declare const DAYS: readonly ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
|
|
24
|
+
/** The business's facts: the header, footer, hours, contact details and search data all come from here. */
|
|
25
|
+
export declare const business: z.ZodObject<{
|
|
26
|
+
name: z.ZodString;
|
|
27
|
+
kind: z.ZodDefault<z.ZodString>;
|
|
28
|
+
tagline: z.ZodOptional<z.ZodString>;
|
|
29
|
+
phone: z.ZodOptional<z.ZodString>;
|
|
30
|
+
email: z.ZodOptional<z.ZodEmail>;
|
|
31
|
+
address: z.ZodOptional<z.ZodObject<{
|
|
32
|
+
street: z.ZodString;
|
|
33
|
+
locality: z.ZodString;
|
|
34
|
+
region: z.ZodString;
|
|
35
|
+
postcode: z.ZodString;
|
|
36
|
+
country: z.ZodDefault<z.ZodString>;
|
|
37
|
+
}, z.core.$strip>>;
|
|
38
|
+
showAddress: z.ZodDefault<z.ZodBoolean>;
|
|
39
|
+
areas: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
40
|
+
hours: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
41
|
+
days: z.ZodArray<z.ZodEnum<{
|
|
42
|
+
Mo: "Mo";
|
|
43
|
+
Tu: "Tu";
|
|
44
|
+
We: "We";
|
|
45
|
+
Th: "Th";
|
|
46
|
+
Fr: "Fr";
|
|
47
|
+
Sa: "Sa";
|
|
48
|
+
Su: "Su";
|
|
49
|
+
}>>;
|
|
50
|
+
opens: z.ZodString;
|
|
51
|
+
closes: z.ZodString;
|
|
52
|
+
}, z.core.$strip>>>;
|
|
53
|
+
hoursNote: z.ZodOptional<z.ZodString>;
|
|
54
|
+
logo: z.ZodOptional<z.ZodObject<{
|
|
55
|
+
src: z.ZodString;
|
|
56
|
+
alt: z.ZodString;
|
|
57
|
+
}, z.core.$strip>>;
|
|
58
|
+
founded: z.ZodOptional<z.ZodNumber>;
|
|
59
|
+
licence: z.ZodOptional<z.ZodString>;
|
|
60
|
+
priceRange: z.ZodOptional<z.ZodString>;
|
|
61
|
+
social: z.ZodDefault<z.ZodObject<{
|
|
62
|
+
facebook: z.ZodOptional<z.ZodURL>;
|
|
63
|
+
instagram: z.ZodOptional<z.ZodURL>;
|
|
64
|
+
google: z.ZodOptional<z.ZodURL>;
|
|
65
|
+
yelp: z.ZodOptional<z.ZodURL>;
|
|
66
|
+
nextdoor: z.ZodOptional<z.ZodURL>;
|
|
67
|
+
linkedin: z.ZodOptional<z.ZodURL>;
|
|
68
|
+
youtube: z.ZodOptional<z.ZodURL>;
|
|
69
|
+
tiktok: z.ZodOptional<z.ZodURL>;
|
|
70
|
+
}, z.core.$strip>>;
|
|
71
|
+
}, z.core.$strip>;
|
|
72
|
+
/** A block as the kit sees it: its kind, its place on the page, and whatever fields the site gave it. */
|
|
73
|
+
export type Block = {
|
|
74
|
+
type: string;
|
|
75
|
+
id?: string;
|
|
76
|
+
[field: string]: unknown;
|
|
77
|
+
};
|
|
78
|
+
export type Page = {
|
|
79
|
+
path: string;
|
|
80
|
+
title: string;
|
|
81
|
+
description: string;
|
|
82
|
+
menu?: string;
|
|
83
|
+
share?: {
|
|
84
|
+
src: string;
|
|
85
|
+
alt: string;
|
|
86
|
+
};
|
|
87
|
+
noindex: boolean;
|
|
88
|
+
blocks: Block[];
|
|
89
|
+
};
|
|
90
|
+
export type Business = z.output<typeof business>;
|
|
91
|
+
export type Image = z.output<typeof image>;
|
|
92
|
+
export type Link = z.output<typeof link>;
|
|
93
|
+
export type Redirect = {
|
|
94
|
+
from: string;
|
|
95
|
+
to: string;
|
|
96
|
+
status: 301 | 302;
|
|
97
|
+
};
|
|
98
|
+
/** What every site has, whatever its blocks. */
|
|
99
|
+
export type SiteBase = {
|
|
100
|
+
url: string;
|
|
101
|
+
lang: string;
|
|
102
|
+
business: Business;
|
|
103
|
+
form?: string;
|
|
104
|
+
redirects: Redirect[];
|
|
105
|
+
};
|
|
106
|
+
/** 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
|
+
export type Site = SiteBase & {
|
|
108
|
+
pages: Page[];
|
|
109
|
+
[extra: string]: unknown;
|
|
110
|
+
};
|
|
111
|
+
/** The blocks a site declared, as a union by `type`: `BlocksOf<typeof blocks>`. */
|
|
112
|
+
export type BlocksOf<B extends Record<string, z.ZodObject>> = {
|
|
113
|
+
[K in keyof B & string]: z.output<B[K]> & {
|
|
114
|
+
type: K;
|
|
115
|
+
id?: string;
|
|
116
|
+
};
|
|
117
|
+
}[keyof B & string];
|
|
118
|
+
/** A site's content with its own blocks typed: `ContentOf<typeof blocks> & { headerAction?: Link }`. */
|
|
119
|
+
export type ContentOf<B extends Record<string, z.ZodObject>> = SiteBase & {
|
|
120
|
+
pages: (Omit<Page, "blocks"> & {
|
|
121
|
+
blocks: BlocksOf<B>[];
|
|
122
|
+
})[];
|
|
123
|
+
};
|
|
124
|
+
export type DefineOptions = {
|
|
125
|
+
/** Fields of the site beyond the kit's own (a theme, a header button, a form address). */
|
|
126
|
+
extend?: z.ZodRawShape;
|
|
127
|
+
/** Checks only the site can make (a contact block needs somewhere to send), added to the kit's own. */
|
|
128
|
+
refine?: (site: Site, ctx: z.RefinementCtx) => void;
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* A site's content shape from its blocks: each a name and the fields it has. The site's own block
|
|
132
|
+
* type is `z.output<typeof content.schema>["pages"][number]["blocks"][number]`.
|
|
133
|
+
*/
|
|
134
|
+
export declare function defineSite<const B extends Record<string, z.ZodObject>>(blocks: B, o?: DefineOptions): {
|
|
135
|
+
schema: z.ZodObject<{
|
|
136
|
+
pages: z.ZodArray<z.ZodObject<{
|
|
137
|
+
blocks: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<z.core.$ZodLooseShape, z.core.$strip>, ...z.ZodObject<z.core.$ZodLooseShape, z.core.$strip>[]], "type">>;
|
|
138
|
+
path: z.ZodString;
|
|
139
|
+
title: z.ZodString;
|
|
140
|
+
description: z.ZodString;
|
|
141
|
+
menu: z.ZodOptional<z.ZodString>;
|
|
142
|
+
share: z.ZodOptional<z.ZodObject<{
|
|
143
|
+
src: z.ZodString;
|
|
144
|
+
alt: z.ZodString;
|
|
145
|
+
}, z.core.$strip>>;
|
|
146
|
+
noindex: z.ZodDefault<z.ZodBoolean>;
|
|
147
|
+
}, z.core.$strip>>;
|
|
148
|
+
url: z.ZodURL;
|
|
149
|
+
lang: z.ZodDefault<z.ZodString>;
|
|
150
|
+
business: z.ZodObject<{
|
|
151
|
+
name: z.ZodString;
|
|
152
|
+
kind: z.ZodDefault<z.ZodString>;
|
|
153
|
+
tagline: z.ZodOptional<z.ZodString>;
|
|
154
|
+
phone: z.ZodOptional<z.ZodString>;
|
|
155
|
+
email: z.ZodOptional<z.ZodEmail>;
|
|
156
|
+
address: z.ZodOptional<z.ZodObject<{
|
|
157
|
+
street: z.ZodString;
|
|
158
|
+
locality: z.ZodString;
|
|
159
|
+
region: z.ZodString;
|
|
160
|
+
postcode: z.ZodString;
|
|
161
|
+
country: z.ZodDefault<z.ZodString>;
|
|
162
|
+
}, z.core.$strip>>;
|
|
163
|
+
showAddress: z.ZodDefault<z.ZodBoolean>;
|
|
164
|
+
areas: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
165
|
+
hours: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
166
|
+
days: z.ZodArray<z.ZodEnum<{
|
|
167
|
+
Mo: "Mo";
|
|
168
|
+
Tu: "Tu";
|
|
169
|
+
We: "We";
|
|
170
|
+
Th: "Th";
|
|
171
|
+
Fr: "Fr";
|
|
172
|
+
Sa: "Sa";
|
|
173
|
+
Su: "Su";
|
|
174
|
+
}>>;
|
|
175
|
+
opens: z.ZodString;
|
|
176
|
+
closes: z.ZodString;
|
|
177
|
+
}, z.core.$strip>>>;
|
|
178
|
+
hoursNote: z.ZodOptional<z.ZodString>;
|
|
179
|
+
logo: z.ZodOptional<z.ZodObject<{
|
|
180
|
+
src: z.ZodString;
|
|
181
|
+
alt: z.ZodString;
|
|
182
|
+
}, z.core.$strip>>;
|
|
183
|
+
founded: z.ZodOptional<z.ZodNumber>;
|
|
184
|
+
licence: z.ZodOptional<z.ZodString>;
|
|
185
|
+
priceRange: z.ZodOptional<z.ZodString>;
|
|
186
|
+
social: z.ZodDefault<z.ZodObject<{
|
|
187
|
+
facebook: z.ZodOptional<z.ZodURL>;
|
|
188
|
+
instagram: z.ZodOptional<z.ZodURL>;
|
|
189
|
+
google: z.ZodOptional<z.ZodURL>;
|
|
190
|
+
yelp: z.ZodOptional<z.ZodURL>;
|
|
191
|
+
nextdoor: z.ZodOptional<z.ZodURL>;
|
|
192
|
+
linkedin: z.ZodOptional<z.ZodURL>;
|
|
193
|
+
youtube: z.ZodOptional<z.ZodURL>;
|
|
194
|
+
tiktok: z.ZodOptional<z.ZodURL>;
|
|
195
|
+
}, z.core.$strip>>;
|
|
196
|
+
}, z.core.$strip>;
|
|
197
|
+
form: z.ZodOptional<z.ZodURL>;
|
|
198
|
+
redirects: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
199
|
+
from: z.ZodString;
|
|
200
|
+
to: z.ZodString;
|
|
201
|
+
status: z.ZodDefault<z.ZodUnion<readonly [z.ZodLiteral<301>, z.ZodLiteral<302>]>>;
|
|
202
|
+
}, z.core.$strip>>>;
|
|
203
|
+
}, z.core.$strip>;
|
|
204
|
+
/** The content checked, or every problem in it as "where: what" lines. */
|
|
205
|
+
parse(raw: unknown): {
|
|
206
|
+
site: z.output<z.ZodObject<{
|
|
207
|
+
pages: z.ZodArray<z.ZodObject<{
|
|
208
|
+
blocks: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<z.core.$ZodLooseShape, z.core.$strip>, ...z.ZodObject<z.core.$ZodLooseShape, z.core.$strip>[]], "type">>;
|
|
209
|
+
path: z.ZodString;
|
|
210
|
+
title: z.ZodString;
|
|
211
|
+
description: z.ZodString;
|
|
212
|
+
menu: z.ZodOptional<z.ZodString>;
|
|
213
|
+
share: z.ZodOptional<z.ZodObject<{
|
|
214
|
+
src: z.ZodString;
|
|
215
|
+
alt: z.ZodString;
|
|
216
|
+
}, z.core.$strip>>;
|
|
217
|
+
noindex: z.ZodDefault<z.ZodBoolean>;
|
|
218
|
+
}, z.core.$strip>>;
|
|
219
|
+
url: z.ZodURL;
|
|
220
|
+
lang: z.ZodDefault<z.ZodString>;
|
|
221
|
+
business: z.ZodObject<{
|
|
222
|
+
name: z.ZodString;
|
|
223
|
+
kind: z.ZodDefault<z.ZodString>;
|
|
224
|
+
tagline: z.ZodOptional<z.ZodString>;
|
|
225
|
+
phone: z.ZodOptional<z.ZodString>;
|
|
226
|
+
email: z.ZodOptional<z.ZodEmail>;
|
|
227
|
+
address: z.ZodOptional<z.ZodObject<{
|
|
228
|
+
street: z.ZodString;
|
|
229
|
+
locality: z.ZodString;
|
|
230
|
+
region: z.ZodString;
|
|
231
|
+
postcode: z.ZodString;
|
|
232
|
+
country: z.ZodDefault<z.ZodString>;
|
|
233
|
+
}, z.core.$strip>>;
|
|
234
|
+
showAddress: z.ZodDefault<z.ZodBoolean>;
|
|
235
|
+
areas: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
236
|
+
hours: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
237
|
+
days: z.ZodArray<z.ZodEnum<{
|
|
238
|
+
Mo: "Mo";
|
|
239
|
+
Tu: "Tu";
|
|
240
|
+
We: "We";
|
|
241
|
+
Th: "Th";
|
|
242
|
+
Fr: "Fr";
|
|
243
|
+
Sa: "Sa";
|
|
244
|
+
Su: "Su";
|
|
245
|
+
}>>;
|
|
246
|
+
opens: z.ZodString;
|
|
247
|
+
closes: z.ZodString;
|
|
248
|
+
}, z.core.$strip>>>;
|
|
249
|
+
hoursNote: z.ZodOptional<z.ZodString>;
|
|
250
|
+
logo: z.ZodOptional<z.ZodObject<{
|
|
251
|
+
src: z.ZodString;
|
|
252
|
+
alt: z.ZodString;
|
|
253
|
+
}, z.core.$strip>>;
|
|
254
|
+
founded: z.ZodOptional<z.ZodNumber>;
|
|
255
|
+
licence: z.ZodOptional<z.ZodString>;
|
|
256
|
+
priceRange: z.ZodOptional<z.ZodString>;
|
|
257
|
+
social: z.ZodDefault<z.ZodObject<{
|
|
258
|
+
facebook: z.ZodOptional<z.ZodURL>;
|
|
259
|
+
instagram: z.ZodOptional<z.ZodURL>;
|
|
260
|
+
google: z.ZodOptional<z.ZodURL>;
|
|
261
|
+
yelp: z.ZodOptional<z.ZodURL>;
|
|
262
|
+
nextdoor: z.ZodOptional<z.ZodURL>;
|
|
263
|
+
linkedin: z.ZodOptional<z.ZodURL>;
|
|
264
|
+
youtube: z.ZodOptional<z.ZodURL>;
|
|
265
|
+
tiktok: z.ZodOptional<z.ZodURL>;
|
|
266
|
+
}, z.core.$strip>>;
|
|
267
|
+
}, z.core.$strip>;
|
|
268
|
+
form: z.ZodOptional<z.ZodURL>;
|
|
269
|
+
redirects: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
270
|
+
from: z.ZodString;
|
|
271
|
+
to: z.ZodString;
|
|
272
|
+
status: z.ZodDefault<z.ZodUnion<readonly [z.ZodLiteral<301>, z.ZodLiteral<302>]>>;
|
|
273
|
+
}, z.core.$strip>>>;
|
|
274
|
+
}, z.core.$strip>>;
|
|
275
|
+
problems?: never;
|
|
276
|
+
} | {
|
|
277
|
+
site?: never;
|
|
278
|
+
problems: string[];
|
|
279
|
+
};
|
|
280
|
+
/** The declaration CalOS draws its editing forms from. */
|
|
281
|
+
jsonSchema: () => Record<string, unknown>;
|
|
282
|
+
};
|
|
283
|
+
export type SiteDefinition = ReturnType<typeof defineSite>;
|
|
284
|
+
/** "Mon–Fri", "Sat", "Mon, Wed, Fri" for a set of days. */
|
|
285
|
+
export declare function dayRange(days: readonly (typeof DAYS)[number][]): string;
|
|
286
|
+
/** "8am", "5:30pm". */
|
|
287
|
+
export declare function clock(t: string): string;
|
|
288
|
+
/** "tel:+15550102030" from however the number is written. */
|
|
289
|
+
export declare function telHref(phone: string, country?: string): string;
|