@caltho/site-kit 0.0.0-stage → 0.1.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/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
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// The contract between a site and CalOS: what a site's content is, whatever the site looks like.
|
|
2
|
+
//
|
|
3
|
+
// A site (its `content/site.json`, or CalOS's published version of it) is the business's facts and a
|
|
4
|
+
// list of pages, each a list of blocks. The site decides what blocks there are: it names them and
|
|
5
|
+
// says what fields each has, in `content/schema.ts`, with `defineSite`. This module checks the
|
|
6
|
+
// content against that, says every problem and where it is, checks that links land, and writes the
|
|
7
|
+
// declaration as JSON Schema, which is what CalOS draws its editing forms from.
|
|
8
|
+
//
|
|
9
|
+
// Nothing here knows how a block looks. A custom design adds its own kinds of block and CalOS edits
|
|
10
|
+
// them with no further work.
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
export { z };
|
|
13
|
+
export const text = z.string().trim().min(1);
|
|
14
|
+
/** Markdown: paragraphs, **bold**, *italic*, [links](/contact), lists, ### small headings. No HTML. */
|
|
15
|
+
export const md = text;
|
|
16
|
+
/** A file in the site's `content/images/` folder. */
|
|
17
|
+
export const file = z.string().regex(/^[\w.\-/]+\.(jpe?g|png|webp|avif|svg)$/i, { error: "An image file in content/images, like team/anna.jpg" });
|
|
18
|
+
/** 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
|
+
export const href = z.string().regex(/^(\/[\w\-./#?=&%]*|#[\w-]+|https?:\/\/\S+|tel:\+?[\d\s()-]+|mailto:\S+@\S+)$/, {
|
|
21
|
+
error: "A link: a page on the site (/services), a part of a page (#contact), a full address (https://…), tel: or mailto:",
|
|
22
|
+
});
|
|
23
|
+
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" });
|
|
25
|
+
export const DAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
|
|
26
|
+
/** What every block has: a place on the page, for links like /#contact. */
|
|
27
|
+
const blockBase = { id: z.string().regex(/^[a-z][\w-]*$/).optional() };
|
|
28
|
+
const address = z.object({
|
|
29
|
+
street: text,
|
|
30
|
+
locality: text,
|
|
31
|
+
region: text,
|
|
32
|
+
postcode: text,
|
|
33
|
+
country: z.string().length(2).default("US"),
|
|
34
|
+
});
|
|
35
|
+
/** The business's facts: the header, footer, hours, contact details and search data all come from here. */
|
|
36
|
+
export const business = z.object({
|
|
37
|
+
name: text,
|
|
38
|
+
/** The schema.org kind: Plumber, Electrician, HVACBusiness, Dentist, HairSalon, Restaurant, LocalBusiness… */
|
|
39
|
+
kind: z.string().regex(/^[A-Z]\w+$/).default("LocalBusiness"),
|
|
40
|
+
tagline: text.optional(),
|
|
41
|
+
phone: z.string().regex(/^\+?[\d\s().-]{7,}$/, { error: "A phone number like (555) 010-2030" }).optional(),
|
|
42
|
+
email: z.email().optional(),
|
|
43
|
+
address: address.optional(),
|
|
44
|
+
/** Off for a business that goes to its customers and doesn't take visitors at its address. */
|
|
45
|
+
showAddress: z.boolean().default(true),
|
|
46
|
+
areas: z.array(text).default([]),
|
|
47
|
+
hours: z.array(z.object({ days: z.array(z.enum(DAYS)).min(1), opens: time, closes: time })).default([]),
|
|
48
|
+
/** A line under the hours: "24/7 emergency line". */
|
|
49
|
+
hoursNote: text.optional(),
|
|
50
|
+
logo: image.optional(),
|
|
51
|
+
founded: z.number().int().min(1800).max(2100).optional(),
|
|
52
|
+
licence: text.optional(),
|
|
53
|
+
priceRange: z.string().max(10).optional(),
|
|
54
|
+
social: z
|
|
55
|
+
.object({ facebook: z.url(), instagram: z.url(), google: z.url(), yelp: z.url(), nextdoor: z.url(), linkedin: z.url(), youtube: z.url(), tiktok: z.url() })
|
|
56
|
+
.partial()
|
|
57
|
+
.default({}),
|
|
58
|
+
});
|
|
59
|
+
const pageBase = {
|
|
60
|
+
path: z.string().regex(/^\/([a-z0-9-]+\/)*$/, { error: "A path like / or /services/ or /areas/springfield/ (lowercase, ending in /)" }),
|
|
61
|
+
/** The page's title in search results and the browser tab. The business's name is added after it. */
|
|
62
|
+
title: text.max(60, { error: "Keep a title under 60 characters so search results show it whole" }),
|
|
63
|
+
/** What search results show under the title. */
|
|
64
|
+
description: text.max(160, { error: "Keep a description under 160 characters so search results show it whole" }),
|
|
65
|
+
/** Its label in the menu; leave out to keep it off the menu. */
|
|
66
|
+
menu: text.optional(),
|
|
67
|
+
/** The picture shown when the page is shared; defaults to the first image on it. */
|
|
68
|
+
share: image.optional(),
|
|
69
|
+
noindex: z.boolean().default(false),
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* A site's content shape from its blocks: each a name and the fields it has. The site's own block
|
|
73
|
+
* type is `z.output<typeof content.schema>["pages"][number]["blocks"][number]`.
|
|
74
|
+
*/
|
|
75
|
+
export function defineSite(blocks, o = {}) {
|
|
76
|
+
const names = Object.keys(blocks);
|
|
77
|
+
if (!names.length)
|
|
78
|
+
throw new Error("A site needs at least one kind of block");
|
|
79
|
+
const union = z.discriminatedUnion("type", names.map((type) => z.object({ type: z.literal(type), ...blockBase }).extend(blocks[type].shape)));
|
|
80
|
+
const page = z.object({ ...pageBase, blocks: z.array(union).min(1) });
|
|
81
|
+
const schema = z
|
|
82
|
+
.object({
|
|
83
|
+
/** The address it's served at once live (its main domain). */
|
|
84
|
+
url: z.url({ protocol: /^https$/, error: "The site's address, starting https://" }),
|
|
85
|
+
lang: z.string().default("en-US"),
|
|
86
|
+
business,
|
|
87
|
+
/** Where the contact forms post to, if the site has any (a CalOS form's address, or another service's). */
|
|
88
|
+
form: z.url({ protocol: /^https?$/ }).optional(),
|
|
89
|
+
/** Old addresses kept answering, when CalOS doesn't supply them at build. */
|
|
90
|
+
redirects: z.array(z.object({ from: z.string().startsWith("/"), to: href, status: z.union([z.literal(301), z.literal(302)]).default(301) })).default([]),
|
|
91
|
+
...o.extend,
|
|
92
|
+
pages: z.array(page).min(1),
|
|
93
|
+
})
|
|
94
|
+
.superRefine((site, ctx) => {
|
|
95
|
+
const s = site;
|
|
96
|
+
const paths = new Set();
|
|
97
|
+
s.pages.forEach((p, i) => {
|
|
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);
|
|
105
|
+
o.refine?.(s, ctx);
|
|
106
|
+
});
|
|
107
|
+
return {
|
|
108
|
+
schema,
|
|
109
|
+
/** The content checked, or every problem in it as "where: what" lines. */
|
|
110
|
+
parse(raw) {
|
|
111
|
+
const r = schema.safeParse(raw);
|
|
112
|
+
return r.success ? { site: r.data } : { problems: r.error.issues.map((i) => `${where(raw, i.path)}: ${i.message}`) };
|
|
113
|
+
},
|
|
114
|
+
/** The declaration CalOS draws its editing forms from. */
|
|
115
|
+
jsonSchema: () => z.toJSONSchema(schema, { io: "input", unrepresentable: "any" }),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/** 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. */
|
|
119
|
+
function checkLinks(site, ctx) {
|
|
120
|
+
const ids = new Map(site.pages.map((p) => [p.path, new Set(p.blocks.map((b) => b.id).filter(Boolean))]));
|
|
121
|
+
const visit = (value, path, page) => {
|
|
122
|
+
if (Array.isArray(value))
|
|
123
|
+
value.forEach((v, i) => visit(v, [...path, i], page));
|
|
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";
|
|
160
|
+
}
|
|
161
|
+
/** "Mon–Fri", "Sat", "Mon, Wed, Fri" for a set of days. */
|
|
162
|
+
export function dayRange(days) {
|
|
163
|
+
const NAMES = { Mo: "Mon", Tu: "Tue", We: "Wed", Th: "Thu", Fr: "Fri", Sa: "Sat", Su: "Sun" };
|
|
164
|
+
const idx = [...new Set(days.map((d) => DAYS.indexOf(d)))].sort((a, b) => a - b);
|
|
165
|
+
const runs = [];
|
|
166
|
+
for (const i of idx) {
|
|
167
|
+
const last = runs.at(-1);
|
|
168
|
+
if (last && last.at(-1) === i - 1)
|
|
169
|
+
last.push(i);
|
|
170
|
+
else
|
|
171
|
+
runs.push([i]);
|
|
172
|
+
}
|
|
173
|
+
return runs.map((r) => (r.length >= 3 ? `${NAMES[DAYS[r[0]]]}–${NAMES[DAYS[r.at(-1)]]}` : r.map((i) => NAMES[DAYS[i]]).join(", "))).join(", ");
|
|
174
|
+
}
|
|
175
|
+
/** "8am", "5:30pm". */
|
|
176
|
+
export function clock(t) {
|
|
177
|
+
const [h, m] = t.split(":").map(Number);
|
|
178
|
+
const suffix = h >= 12 ? "pm" : "am";
|
|
179
|
+
const hour = h % 12 || 12;
|
|
180
|
+
return m ? `${hour}:${String(m).padStart(2, "0")}${suffix}` : `${hour}${suffix}`;
|
|
181
|
+
}
|
|
182
|
+
/** "tel:+15550102030" from however the number is written. */
|
|
183
|
+
export function telHref(phone, country = "US") {
|
|
184
|
+
const digits = phone.replace(/[^\d+]/g, "");
|
|
185
|
+
if (digits.startsWith("+"))
|
|
186
|
+
return `tel:${digits}`;
|
|
187
|
+
if (country === "US" && digits.length === 10)
|
|
188
|
+
return `tel:+1${digits}`;
|
|
189
|
+
return `tel:${digits}`;
|
|
190
|
+
}
|
package/dist/finish.d.ts
ADDED
package/dist/finish.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// What a site's build output needs after the framework is done: a content security policy in every
|
|
2
|
+
// page, naming by hash each inline script and style the page carries (Next.js writes its page data
|
|
3
|
+
// as inline scripts; a static page can't carry a per-request nonce), and Cloudflare Pages' `_headers`
|
|
4
|
+
// and `_redirects` beside the pages.
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
7
|
+
import { join, relative } from "node:path";
|
|
8
|
+
import { headersFile, loadRedirects } from "./seo.js";
|
|
9
|
+
export async function finish(root, outDir, env = process.env, log = console.log) {
|
|
10
|
+
const prepared = JSON.parse(await readFile(join(root, ".site", "prepared.json"), "utf8"));
|
|
11
|
+
const site = prepared.site;
|
|
12
|
+
const formOrigin = site.form ? new URL(site.form).origin : null;
|
|
13
|
+
const pages = (await walk(outDir)).filter((f) => f.endsWith(".html"));
|
|
14
|
+
const attributes = [];
|
|
15
|
+
for (const file of pages) {
|
|
16
|
+
let html = await readFile(file, "utf8");
|
|
17
|
+
const scripts = new Set();
|
|
18
|
+
const styles = new Set();
|
|
19
|
+
for (const m of html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) {
|
|
20
|
+
const [, attrs, body] = m;
|
|
21
|
+
if (/\bsrc\s*=/.test(attrs) || /type\s*=\s*["']?application\/ld\+json/i.test(attrs) || !body)
|
|
22
|
+
continue;
|
|
23
|
+
scripts.add(hash(body));
|
|
24
|
+
}
|
|
25
|
+
for (const m of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi))
|
|
26
|
+
styles.add(hash(m[1]));
|
|
27
|
+
// A style attribute can't be allowed by hash without allowing every one: the template uses classes.
|
|
28
|
+
if (/<[a-z][^>]*\sstyle\s*=/i.test(html))
|
|
29
|
+
attributes.push(relative(outDir, file));
|
|
30
|
+
const policy = [
|
|
31
|
+
"default-src 'self'",
|
|
32
|
+
`script-src 'self'${[...scripts].map((h) => ` '${h}'`).join("")}`,
|
|
33
|
+
`style-src 'self'${[...styles].map((h) => ` '${h}'`).join("")}`,
|
|
34
|
+
"img-src 'self' data:",
|
|
35
|
+
"font-src 'self'",
|
|
36
|
+
"connect-src 'self'",
|
|
37
|
+
`form-action 'self'${formOrigin ? ` ${formOrigin}` : ""}`,
|
|
38
|
+
"base-uri 'self'",
|
|
39
|
+
"object-src 'none'",
|
|
40
|
+
].join("; ");
|
|
41
|
+
html = html.replace(/<meta http-equiv="content-security-policy"[^>]*>/i, "");
|
|
42
|
+
html = html.replace(/<head([^>]*)>/i, `<head$1><meta http-equiv="content-security-policy" content="${policy}">`);
|
|
43
|
+
await writeFile(file, html);
|
|
44
|
+
}
|
|
45
|
+
if (attributes.length) {
|
|
46
|
+
throw new Error(`These pages have inline style attributes, which the content security policy blocks: ${attributes.join(", ")}. Use a class instead.`);
|
|
47
|
+
}
|
|
48
|
+
await writeFile(join(outDir, "_headers"), headersFile());
|
|
49
|
+
const redirects = await loadRedirects(site, env.CALOS_REDIRECTS_URL);
|
|
50
|
+
await writeFile(join(outDir, "_redirects"), redirects.file);
|
|
51
|
+
log(`site-kit: a content security policy in ${pages.length} pages; _headers; _redirects from ${redirects.from}`);
|
|
52
|
+
}
|
|
53
|
+
const hash = (body) => `sha256-${createHash("sha256").update(body, "utf8").digest("base64")}`;
|
|
54
|
+
async function walk(dir) {
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const e of await readdir(dir, { withFileTypes: true })) {
|
|
57
|
+
const p = join(dir, e.name);
|
|
58
|
+
if (e.isDirectory())
|
|
59
|
+
out.push(...(await walk(p)));
|
|
60
|
+
else
|
|
61
|
+
out.push(p);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
package/dist/images.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Image, Page, Site } from "./content/index.ts";
|
|
2
|
+
/** Every image the content names: the shape of a value with a `src` that's an image file. */
|
|
3
|
+
export declare function imagesIn(value: unknown, found?: Set<string>): Set<string>;
|
|
4
|
+
/** The picture a page shows when it's shared: its own, else its first, else the home page's first. */
|
|
5
|
+
export declare function shareImageOf(site: Site, page: Page): Image | undefined;
|
package/dist/images.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Which pictures a site's content names, and which one a page is shared with. Pure: no files, no
|
|
2
|
+
// sharp, so a site's components can use it as well as the build step.
|
|
3
|
+
/** Every image the content names: the shape of a value with a `src` that's an image file. */
|
|
4
|
+
export function imagesIn(value, found = new Set()) {
|
|
5
|
+
if (Array.isArray(value))
|
|
6
|
+
value.forEach((v) => imagesIn(v, found));
|
|
7
|
+
else if (value && typeof value === "object") {
|
|
8
|
+
const o = value;
|
|
9
|
+
if (typeof o.src === "string" && "alt" in o && /\.(jpe?g|png|webp|avif|svg)$/i.test(o.src))
|
|
10
|
+
found.add(o.src);
|
|
11
|
+
Object.values(o).forEach((v) => imagesIn(v, found));
|
|
12
|
+
}
|
|
13
|
+
return found;
|
|
14
|
+
}
|
|
15
|
+
/** The picture a page shows when it's shared: its own, else its first, else the home page's first. */
|
|
16
|
+
export function shareImageOf(site, page) {
|
|
17
|
+
const first = (p) => [...imagesIn(p.blocks)].map((src) => findImage(p.blocks, src)).find(Boolean);
|
|
18
|
+
const pick = page.share ?? first(page) ?? first(site.pages.find((p) => p.path === "/"));
|
|
19
|
+
return pick && !pick.src.endsWith(".svg") ? pick : undefined;
|
|
20
|
+
}
|
|
21
|
+
function findImage(value, src) {
|
|
22
|
+
if (Array.isArray(value)) {
|
|
23
|
+
for (const v of value) {
|
|
24
|
+
const f = findImage(v, src);
|
|
25
|
+
if (f)
|
|
26
|
+
return f;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
else if (value && typeof value === "object") {
|
|
30
|
+
const o = value;
|
|
31
|
+
if (o.src === src && typeof o.alt === "string")
|
|
32
|
+
return { src, alt: o.alt };
|
|
33
|
+
for (const v of Object.values(o)) {
|
|
34
|
+
const f = findImage(v, src);
|
|
35
|
+
if (f)
|
|
36
|
+
return f;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from "./content/index.ts";
|
|
2
|
+
export { finish } from "./finish.ts";
|
|
3
|
+
export { md, mdInline, plain } from "./markdown.ts";
|
|
4
|
+
export { policy, type Policy } from "./policies.ts";
|
|
5
|
+
export * from "./images.ts";
|
|
6
|
+
export { prepare, type Prepared, type PreparedImage } from "./prepare.ts";
|
|
7
|
+
export * from "./seo.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// What every Caltho site shares, whatever it looks like or is built with: the contract with CalOS (what
|
|
2
|
+
// content is and how a site declares its blocks), search and security, the policies, and the steps
|
|
3
|
+
// around a build (prepare, finish). Designs are the sites' own.
|
|
4
|
+
export * from "./content/index.js";
|
|
5
|
+
export { finish } from "./finish.js";
|
|
6
|
+
export { md, mdInline, plain } from "./markdown.js";
|
|
7
|
+
export { policy } from "./policies.js";
|
|
8
|
+
export * from "./images.js";
|
|
9
|
+
export { prepare } from "./prepare.js";
|
|
10
|
+
export * from "./seo.js";
|
package/dist/lib.d.ts
ADDED
package/dist/lib.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The kit's helpers that touch no files and no sharp, for a site's own components: Markdown that can't
|
|
2
|
+
// run HTML, the generated policies, search data, which pictures a page uses, and the content's words
|
|
3
|
+
// for hours and phone numbers.
|
|
4
|
+
export * from "./content/index.js";
|
|
5
|
+
export * from "./images.js";
|
|
6
|
+
export { md, mdInline, plain } from "./markdown.js";
|
|
7
|
+
export { policy } from "./policies.js";
|
|
8
|
+
export * from "./seo.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** `top`: the level a "##" heading in it becomes. */
|
|
2
|
+
export declare function md(source: string, top?: number): string;
|
|
3
|
+
/** One line of Markdown without the paragraph around it. */
|
|
4
|
+
export declare function mdInline(source: string): string;
|
|
5
|
+
/** The words alone, for structured data. */
|
|
6
|
+
export declare function plain(source: string): string;
|
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The little Markdown site.json allows: paragraphs, emphasis, links, lists and small headings.
|
|
2
|
+
// HTML written into it is shown as text, never run, so content from an owner can't put a script
|
|
3
|
+
// on their page. Headings start a level under the section's own heading (h3, or h2 on a page
|
|
4
|
+
// whose section heading is its h1).
|
|
5
|
+
import { Marked } from "marked";
|
|
6
|
+
const escape = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
7
|
+
const make = (top) => new Marked({
|
|
8
|
+
gfm: true,
|
|
9
|
+
breaks: false,
|
|
10
|
+
renderer: {
|
|
11
|
+
html({ text }) {
|
|
12
|
+
return escape(text);
|
|
13
|
+
},
|
|
14
|
+
heading({ tokens, depth }) {
|
|
15
|
+
const level = Math.min(6, Math.max(top, depth + top - 2));
|
|
16
|
+
return `<h${level}>${this.parser.parseInline(tokens)}</h${level}>\n`;
|
|
17
|
+
},
|
|
18
|
+
link({ href, title, tokens }) {
|
|
19
|
+
const text = this.parser.parseInline(tokens);
|
|
20
|
+
const safe = /^(https?:|mailto:|tel:|\/|#)/i.test(href) ? href : "#";
|
|
21
|
+
const external = /^https?:/i.test(safe);
|
|
22
|
+
return `<a href="${escape(safe)}"${title ? ` title="${escape(title)}"` : ""}${external ? ' rel="noopener"' : ""}>${text}</a>`;
|
|
23
|
+
},
|
|
24
|
+
image({ text }) {
|
|
25
|
+
return escape(text);
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
const parsers = new Map();
|
|
30
|
+
const marked = (top = 3) => parsers.get(top) ?? parsers.set(top, make(top)).get(top);
|
|
31
|
+
/** `top`: the level a "##" heading in it becomes. */
|
|
32
|
+
export function md(source, top = 3) {
|
|
33
|
+
return marked(top).parse(source, { async: false });
|
|
34
|
+
}
|
|
35
|
+
/** One line of Markdown without the paragraph around it. */
|
|
36
|
+
export function mdInline(source) {
|
|
37
|
+
return marked().parseInline(source, { async: false });
|
|
38
|
+
}
|
|
39
|
+
/** The words alone, for structured data. */
|
|
40
|
+
export function plain(source) {
|
|
41
|
+
return md(source)
|
|
42
|
+
.replace(/<[^>]+>/g, " ")
|
|
43
|
+
.replace(/&/g, "&")
|
|
44
|
+
.replace(/</g, "<")
|
|
45
|
+
.replace(/>/g, ">")
|
|
46
|
+
.replace(/"/g, '"')
|
|
47
|
+
.replace(/'/g, "'")
|
|
48
|
+
.replace(/\s+/g, " ")
|
|
49
|
+
.trim();
|
|
50
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { ldJson } from "../seo.js";
|
|
3
|
+
/** Structured data for search engines, in a script browsers don't run. */
|
|
4
|
+
export function JsonLd({ data }) {
|
|
5
|
+
if (!data)
|
|
6
|
+
return null;
|
|
7
|
+
return _jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: ldJson(data) } });
|
|
8
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Page } from "../content/index.ts";
|
|
2
|
+
import type { Prepared, PreparedImage } from "../prepare.ts";
|
|
3
|
+
export declare function prepared(): Prepared;
|
|
4
|
+
export declare const site: () => import("../content/index.ts").Site;
|
|
5
|
+
export declare function image(src: string): PreparedImage;
|
|
6
|
+
/** The page at a catch-all route's segments (`[[...path]]`), or null. */
|
|
7
|
+
export declare function pageAt(path: string[] | undefined): Page | null;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// What `site-kit prepare` made ready, read once per build from `.site/prepared.json`.
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
let cached = null;
|
|
5
|
+
export function prepared() {
|
|
6
|
+
if (!cached || process.env.NODE_ENV === "development") {
|
|
7
|
+
try {
|
|
8
|
+
cached = 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
|
+
}
|
|
14
|
+
return cached;
|
|
15
|
+
}
|
|
16
|
+
export const site = () => prepared().site;
|
|
17
|
+
export function image(src) {
|
|
18
|
+
const found = prepared().images[src];
|
|
19
|
+
if (!found)
|
|
20
|
+
throw new Error(`The image "${src}" wasn't prepared: run \`site-kit prepare\` again`);
|
|
21
|
+
return found;
|
|
22
|
+
}
|
|
23
|
+
/** The page at a catch-all route's segments (`[[...path]]`), or null. */
|
|
24
|
+
export function pageAt(path) {
|
|
25
|
+
const at = path?.length ? `/${path.join("/")}/` : "/";
|
|
26
|
+
return site().pages.find((p) => p.path === at) ?? null;
|
|
27
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The plumbing a Next.js site needs from the kit, whatever its design: the prepared content, a page by
|
|
2
|
+
// its path, metadata, picture and Markdown parts, structured data. A site's own components, in its own
|
|
3
|
+
// repository, do the looks. Server components only: nothing here runs in the visitor's browser.
|
|
4
|
+
export { image, pageAt, prepared, site } from "./data.js";
|
|
5
|
+
export { JsonLd } from "./JsonLd.js";
|
|
6
|
+
export { pageMetadata, robots, sitemap, staticParams } from "./metadata.js";
|
|
7
|
+
export { Img } from "./parts/Img.js";
|
|
8
|
+
export { Md } from "./parts/Md.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Metadata, MetadataRoute } from "next";
|
|
2
|
+
import type { Page } from "../content/index.ts";
|
|
3
|
+
export declare function pageMetadata(page: Page): Metadata;
|
|
4
|
+
/** For `generateStaticParams` on `app/[[...path]]/page.tsx`. */
|
|
5
|
+
export declare function staticParams(): {
|
|
6
|
+
path: string[];
|
|
7
|
+
}[];
|
|
8
|
+
export declare function robots(): MetadataRoute.Robots;
|
|
9
|
+
export declare function sitemap(): MetadataRoute.Sitemap;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The Next.js side of search: each page's metadata, the static routes, robots.txt and the sitemap,
|
|
2
|
+
// all from the prepared content.
|
|
3
|
+
import { shareImageOf } from "../images.js";
|
|
4
|
+
import { canonical, fullTitle } from "../seo.js";
|
|
5
|
+
import { image, site } from "./data.js";
|
|
6
|
+
export function pageMetadata(page) {
|
|
7
|
+
const s = site();
|
|
8
|
+
const title = fullTitle(s, page);
|
|
9
|
+
const url = canonical(s, page.path);
|
|
10
|
+
const share = shareImageOf(s, page);
|
|
11
|
+
const shareUrl = share ? image(share.src).share : undefined;
|
|
12
|
+
return {
|
|
13
|
+
metadataBase: new URL(s.url),
|
|
14
|
+
title: { absolute: title },
|
|
15
|
+
description: page.description,
|
|
16
|
+
alternates: { canonical: url },
|
|
17
|
+
robots: page.noindex ? { index: false, follow: true } : undefined,
|
|
18
|
+
openGraph: {
|
|
19
|
+
type: "website",
|
|
20
|
+
siteName: s.business.name,
|
|
21
|
+
title,
|
|
22
|
+
description: page.description,
|
|
23
|
+
url,
|
|
24
|
+
locale: s.lang.replace("-", "_"),
|
|
25
|
+
images: shareUrl && share ? [{ url: new URL(shareUrl, s.url).href, width: 1200, height: 630, alt: share.alt }] : undefined,
|
|
26
|
+
},
|
|
27
|
+
twitter: { card: shareUrl ? "summary_large_image" : "summary" },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** For `generateStaticParams` on `app/[[...path]]/page.tsx`. */
|
|
31
|
+
export function staticParams() {
|
|
32
|
+
return site().pages.map((p) => ({ path: p.path.split("/").filter(Boolean) }));
|
|
33
|
+
}
|
|
34
|
+
export function robots() {
|
|
35
|
+
return { rules: { userAgent: "*", allow: "/" }, sitemap: canonical(site(), "/sitemap.xml") };
|
|
36
|
+
}
|
|
37
|
+
export function sitemap() {
|
|
38
|
+
const s = site();
|
|
39
|
+
return s.pages.filter((p) => !p.noindex).map((p) => ({ url: canonical(s, p.path) }));
|
|
40
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type Ratio = "1/1" | "3/2" | "4/3" | "4/5" | "16/9";
|
|
2
|
+
export declare function Img({ src, alt, sizes, ratio, eager, className }: {
|
|
3
|
+
src: string;
|
|
4
|
+
alt: string;
|
|
5
|
+
sizes: string;
|
|
6
|
+
ratio?: Ratio;
|
|
7
|
+
eager?: boolean;
|
|
8
|
+
className?: string;
|
|
9
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// A picture from the site's images, at the widths `site-kit prepare` made: `sizes` says how wide
|
|
3
|
+
// it shows, the browser picks the copy. Cropped to a shape by class (`ratio-4-3`), not by a
|
|
4
|
+
// style attribute, which the content security policy would block. Above the fold (`eager`) it
|
|
5
|
+
// loads first; everywhere else it waits until it's near.
|
|
6
|
+
import { image } from "../data.js";
|
|
7
|
+
export function Img({ src, alt, sizes, ratio, eager = false, className }) {
|
|
8
|
+
const m = image(src);
|
|
9
|
+
const [rw, rh] = ratio ? ratio.split("/").map(Number) : [m.width, m.height];
|
|
10
|
+
const width = m.width;
|
|
11
|
+
const height = Math.round((width * rh) / rw);
|
|
12
|
+
const classes = [ratio ? `ratio-${ratio.replace("/", "-")}` : null, className].filter(Boolean).join(" ") || undefined;
|
|
13
|
+
return (_jsx("img", { src: m.src, srcSet: m.srcset.length ? m.srcset.map((s) => `${s.url} ${s.w}w`).join(", ") : undefined, sizes: m.srcset.length ? sizes : undefined, width: width, height: height, alt: alt, loading: eager ? "eager" : "lazy", decoding: eager ? "sync" : "async", fetchPriority: eager ? "high" : undefined, className: classes }));
|
|
14
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { md } from "../../markdown.js";
|
|
3
|
+
/** Markdown from the content: HTML in it is shown as text (lib markdown.ts). `top` is the level "##" becomes. */
|
|
4
|
+
export function Md({ text, top = 3, className }) {
|
|
5
|
+
return _jsx("div", { className: className ? `prose ${className}` : "prose", dangerouslySetInnerHTML: { __html: md(text, top) } });
|
|
6
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Site } from "./content/index.ts";
|
|
2
|
+
export type Policy = {
|
|
3
|
+
title: string;
|
|
4
|
+
body: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* `collectsFromForm`: the site has a form that takes a visitor's name, email and message, which the
|
|
8
|
+
* privacy policy says (it defaults to the site having a form address).
|
|
9
|
+
*/
|
|
10
|
+
export declare function policy(site: Site, which: "privacy" | "accessibility" | "terms", o?: {
|
|
11
|
+
collectsFromForm?: boolean;
|
|
12
|
+
}): Policy;
|