@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.
@@ -0,0 +1,84 @@
1
+ // The policy pages a business's site should have, written from what the business has told us and
2
+ // what the template actually does: no cookies, no analytics, no third-party requests, a contact
3
+ // form that goes to the business's CalOS. So the words stay true as the site changes: add a
4
+ // tracker to the template and this is where it has to be said. Plain language, US-first; a
5
+ // business adds its own paragraphs with `extra`, and should have a lawyer read them once.
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 function policy(site, which, o = {}) {
11
+ const b = site.business;
12
+ const host = new URL(site.url).host.replace(/^www\./, "");
13
+ const reach = [b.email ? `email [${b.email}](mailto:${b.email})` : null, b.phone ? `call ${b.phone}` : null].filter(Boolean).join(" or ");
14
+ const place = b.address ? `${b.address.locality}, ${b.address.region}` : null;
15
+ const hasForm = o.collectsFromForm ?? !!site.form;
16
+ if (which === "privacy") {
17
+ return {
18
+ title: "Privacy policy",
19
+ body: [
20
+ `## Who we are`,
21
+ `${b.name}${place ? ` of ${place}` : ""} runs this website, ${host}. If you have a question about your information, ${reach || "contact us"}.`,
22
+ `## What we collect`,
23
+ [
24
+ hasForm ? `- **When you send us a message** through the form on this site: your name, your email address, your phone number if you give it, and what you write.` : null,
25
+ `- **When you call or email us**: what you choose to tell us.`,
26
+ `- **When you visit**: our hosting provider, Cloudflare, briefly records technical details such as your IP address, browser and the pages asked for, to keep the site secure and working.`,
27
+ ]
28
+ .filter(Boolean)
29
+ .join("\n"),
30
+ `This site doesn't use cookies, advertising or analytics trackers. Its fonts and pictures come from this site itself, so visiting it doesn't tell anyone else you were here.`,
31
+ `## How we use it`,
32
+ `To answer you, to quote for and do the work you ask about, and to keep the records the law requires. We keep enquiries in our customer records system, which Caltho runs for us. We don't sell or rent your information, and we don't share it for advertising. The companies that help us run this site and our business (hosting, email, records) only use it to do that for us.`,
33
+ `## How long we keep it`,
34
+ `As long as we need it to answer you and serve you, and afterwards only as long as the law or our accounts require. Ask us and we'll delete what we don't have to keep.`,
35
+ `## Your choices`,
36
+ `You can ask what information we hold about you, ask us to correct it, or ask us to delete it: ${reach || "contact us"}. Depending on where you live (for example California), the law gives you further rights; we honor them for everyone. We'll answer within 30 days.`,
37
+ `## Children`,
38
+ `This site isn't meant for children under 13, and we don't knowingly collect their information.`,
39
+ `## Changes`,
40
+ `If we change how we handle your information, we'll update this page and the date at the top.`,
41
+ ].join("\n\n"),
42
+ };
43
+ }
44
+ if (which === "accessibility") {
45
+ return {
46
+ title: "Accessibility",
47
+ body: [
48
+ `We want everyone to be able to use ${host}, including people who use a screen reader, a keyboard, magnification or other assistive technology. We aim to meet the Web Content Accessibility Guidelines (WCAG) 2.2 at level AA.`,
49
+ `## What we do`,
50
+ [
51
+ `- Every picture that carries meaning has a description.`,
52
+ `- Every page can be used with a keyboard alone, and shows where you are on it.`,
53
+ `- Text and buttons are checked to stand out clearly from their background.`,
54
+ `- Pages have headings in order and work when text is enlarged to 200%.`,
55
+ `- Forms say what each box is for and what went wrong if something did.`,
56
+ `- The site is checked automatically every time it changes.`,
57
+ ].join("\n"),
58
+ `## If something doesn't work for you`,
59
+ `Please tell us: ${reach || "contact us"}. Say which page and what happened, and we'll get you the information another way and fix it.`,
60
+ `## Compatibility`,
61
+ `The site is built to work with current versions of the major browsers and screen readers, on phones and computers.`,
62
+ ].join("\n\n"),
63
+ };
64
+ }
65
+ return {
66
+ title: "Terms of use",
67
+ body: [
68
+ `These terms cover your use of ${host}, run by ${b.name}.`,
69
+ `## The information here`,
70
+ `We keep this site accurate and up to date, but it's general information. Prices, availability and timings can change; a quote or booking is only confirmed when we confirm it to you directly.`,
71
+ `## Our content`,
72
+ `The words, photos and logo on this site belong to ${b.name} or the people who license them to us. Please don't copy them without asking.`,
73
+ `## Other websites`,
74
+ `Links to other sites are there to help you; we aren't responsible for what's on them.`,
75
+ `## Liability`,
76
+ `The site is provided as it is. As far as the law allows, we aren't liable for any loss from using it or relying on it.`,
77
+ b.address ? `## Law\n\nThese terms are governed by the laws of ${b.address.region}.` : null,
78
+ `## Questions`,
79
+ reach ? `${reach[0].toUpperCase()}${reach.slice(1)}.` : `Contact us.`,
80
+ ]
81
+ .filter(Boolean)
82
+ .join("\n\n"),
83
+ };
84
+ }
@@ -0,0 +1,21 @@
1
+ import type { Site } from "./content/index.ts";
2
+ export type PreparedImage = {
3
+ width: number;
4
+ height: number;
5
+ /** The largest copy, for `src`. */
6
+ src: string;
7
+ /** WebP copies by width, smallest first; empty for an SVG. */
8
+ srcset: {
9
+ w: number;
10
+ url: string;
11
+ }[];
12
+ /** A 1200×630 JPEG, where a page shares it. */
13
+ share?: string;
14
+ };
15
+ export type Prepared = {
16
+ site: Site;
17
+ images: Record<string, PreparedImage>;
18
+ };
19
+ type Log = (line: string) => void;
20
+ export declare function prepare(root: string, env?: Record<string, string | undefined>, log?: Log): Promise<Prepared>;
21
+ export {};
@@ -0,0 +1,159 @@
1
+ // Everything a site's build needs from outside its code, made ready before the framework runs: the
2
+ // content (CalOS's published version when the build is given its address, else the site's own
3
+ // content/site.json), checked against the site's own declaration of its blocks (content/schema.ts);
4
+ // and every picture the content uses, as WebP at the widths screens need, plus a 1200×630 crop for
5
+ // sharing. It writes `.site/` (read at build) and `public/_img/` (served), and the declaration as
6
+ // JSON Schema (`.site/schema.json`), which is what CalOS draws its editing forms from. Nothing here
7
+ // knows which framework reads it: this is the contract between a site and CalOS.
8
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
9
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
10
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
11
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
12
+ });
13
+ }
14
+ return path;
15
+ };
16
+ import { createHash } from "node:crypto";
17
+ import { existsSync } from "node:fs";
18
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
19
+ import { dirname, extname, join, relative } from "node:path";
20
+ import sharp from "sharp";
21
+ import { pathToFileURL } from "node:url";
22
+ import { imagesIn, shareImageOf } from "./images.js";
23
+ const WIDTHS = [400, 640, 800, 1024, 1280, 1600, 2000];
24
+ export async function prepare(root, env = process.env, log = console.log) {
25
+ const out = join(root, ".site");
26
+ const pub = join(root, "public");
27
+ const imagesDir = join(root, "content", "images");
28
+ await mkdir(out, { recursive: true });
29
+ // The content: CalOS's when told where, the site's own otherwise.
30
+ let raw;
31
+ let from;
32
+ if (env.CALOS_CONTENT_URL) {
33
+ const res = await fetch(env.CALOS_CONTENT_URL, {
34
+ headers: env.CALOS_SITE_TOKEN ? { authorization: `Bearer ${env.CALOS_SITE_TOKEN}` } : {},
35
+ signal: AbortSignal.timeout(30_000),
36
+ });
37
+ if (!res.ok)
38
+ throw new Error(`Couldn't get the content from CalOS (${env.CALOS_CONTENT_URL}): ${res.status}`);
39
+ const body = (await res.json());
40
+ raw = body.content;
41
+ from = "CalOS";
42
+ // Pictures uploaded in CalOS, fetched into the images folder once each.
43
+ for (const [name, url] of Object.entries(body.images ?? {})) {
44
+ const file = join(imagesDir, name);
45
+ if (existsSync(file))
46
+ continue;
47
+ const got = await fetch(url, { signal: AbortSignal.timeout(60_000) });
48
+ if (!got.ok)
49
+ throw new Error(`Couldn't get the image ${name} from CalOS: ${got.status}`);
50
+ await mkdir(dirname(file), { recursive: true });
51
+ await writeFile(file, Buffer.from(await got.arrayBuffer()));
52
+ }
53
+ }
54
+ else {
55
+ const file = join(root, "content", "site.json");
56
+ try {
57
+ raw = JSON.parse(await readFile(file, "utf8"));
58
+ }
59
+ catch (err) {
60
+ throw new Error(`Couldn't read ${relative(root, file)}: ${err.message}`);
61
+ }
62
+ from = "content/site.json";
63
+ }
64
+ const definition = await loadDefinition(root);
65
+ const parsed = definition.parse(raw);
66
+ if (parsed.problems)
67
+ throw new Error(`The site's content needs fixing (${from}):\n - ${parsed.problems.join("\n - ")}`);
68
+ const site = parsed.site;
69
+ await writeFile(join(out, "schema.json"), JSON.stringify(definition.jsonSchema(), null, 2) + "\n");
70
+ const images = await pictures(site, imagesDir, pub, log);
71
+ const prepared = { site, images };
72
+ await writeFile(join(out, "prepared.json"), JSON.stringify(prepared));
73
+ log(`site-kit: content from ${from}, ${site.pages.length} pages, ${Object.keys(images).length} images`);
74
+ return prepared;
75
+ }
76
+ // ---- pictures ----
77
+ async function pictures(site, imagesDir, pub, log) {
78
+ const used = imagesIn(site);
79
+ const shared = new Set(site.pages.map((p) => shareImageOf(site, p)?.src).filter((s) => !!s));
80
+ const have = existsSync(imagesDir) ? await list(imagesDir) : [];
81
+ const missing = [...used].filter((src) => !have.includes(src));
82
+ if (missing.length) {
83
+ throw new Error(`The content names images that aren't in content/images: ${missing.join(", ")}.${have.length ? ` There is: ${have.join(", ")}` : " The folder is empty."}`);
84
+ }
85
+ const dir = join(pub, "_img");
86
+ await mkdir(dir, { recursive: true });
87
+ const result = {};
88
+ let made = 0;
89
+ for (const src of [...used].sort()) {
90
+ const bytes = await readFile(join(imagesDir, src));
91
+ const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 10);
92
+ const base = src.replace(/\.[^.]+$/, "").replace(/[^\w-]+/g, "-");
93
+ if (extname(src).toLowerCase() === ".svg") {
94
+ const name = `${base}.${hash}.svg`;
95
+ if (!existsSync(join(dir, name)))
96
+ await writeFile(join(dir, name), bytes);
97
+ const meta = await sharp(bytes).metadata();
98
+ result[src] = { width: meta.width ?? 0, height: meta.height ?? 0, src: `/_img/${name}`, srcset: [] };
99
+ continue;
100
+ }
101
+ const img = sharp(bytes).rotate();
102
+ const meta = await img.metadata();
103
+ const upright = (meta.orientation ?? 1) >= 5;
104
+ const width = (upright ? meta.height : meta.width) ?? 0;
105
+ const height = (upright ? meta.width : meta.height) ?? 0;
106
+ const widths = WIDTHS.filter((w) => w < width).concat(width <= WIDTHS.at(-1) ? [width] : []);
107
+ const srcset = [];
108
+ for (const w of widths.length ? widths : [width]) {
109
+ const name = `${base}.${hash}.${w}.webp`;
110
+ if (!existsSync(join(dir, name))) {
111
+ await sharp(bytes).rotate().resize({ width: w }).webp({ quality: 78 }).toFile(join(dir, name));
112
+ made++;
113
+ }
114
+ srcset.push({ w, url: `/_img/${name}` });
115
+ }
116
+ const entry = { width, height, src: srcset.at(-1).url, srcset };
117
+ if (shared.has(src)) {
118
+ const name = `${base}.${hash}.share.jpg`;
119
+ if (!existsSync(join(dir, name))) {
120
+ await sharp(bytes).rotate().resize({ width: 1200, height: 630, fit: "cover" }).jpeg({ quality: 80, mozjpeg: true }).toFile(join(dir, name));
121
+ made++;
122
+ }
123
+ entry.share = `/_img/${name}`;
124
+ }
125
+ result[src] = entry;
126
+ }
127
+ if (made)
128
+ log(`site-kit: ${made} image copies made`);
129
+ return result;
130
+ }
131
+ async function list(dir, prefix = "") {
132
+ const out = [];
133
+ for (const e of await readdir(dir, { withFileTypes: true })) {
134
+ if (e.name.startsWith("."))
135
+ continue;
136
+ if (e.isDirectory())
137
+ out.push(...(await list(join(dir, e.name), `${prefix}${e.name}/`)));
138
+ else
139
+ out.push(`${prefix}${e.name}`);
140
+ }
141
+ return out;
142
+ }
143
+ /** The site's declaration of its blocks: `content/schema.ts`, the default export of `defineSite(...)`. */
144
+ async function loadDefinition(root) {
145
+ const file = ["schema.ts", "schema.mjs", "schema.js"].map((f) => join(root, "content", f)).find((f) => existsSync(f));
146
+ if (!file)
147
+ throw new Error("There's no content/schema.ts: it declares the site's blocks with defineSite (see the starter)");
148
+ let mod;
149
+ try {
150
+ mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(file).href)));
151
+ }
152
+ catch (err) {
153
+ const message = err.message;
154
+ throw new Error(`Couldn't load ${relative(root, file)}: ${/Unknown file extension|ERR_UNKNOWN_FILE_EXTENSION/.test(message) ? "Node 22.18 or later is needed to read a TypeScript file (the build ran on " + process.version + ")" : message}`);
155
+ }
156
+ if (!mod.default || typeof mod.default.parse !== "function")
157
+ throw new Error(`${relative(root, file)} should export default defineSite({ ...blocks })`);
158
+ return mod.default;
159
+ }
package/dist/seo.d.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { DAYS, type Business, type Page, type Site } from "./content/index.ts";
2
+ /** The page's address as search engines should know it. */
3
+ export declare function canonical(site: Site, path: string): string;
4
+ /** "Services | Northfield Plumbing"; the home page's title stands alone when it already names them. */
5
+ export declare function fullTitle(site: Site, page: Page): string;
6
+ /** The business as schema.org describes it: what Google uses for its knowledge panel and maps. */
7
+ export declare function businessData(site: Site, image?: string): Record<string, unknown>;
8
+ /** FAQPage structured data from a page's questions and answers (`plain` turns an answer's Markdown to words). */
9
+ export declare function faqData(items: {
10
+ q: string;
11
+ a: string;
12
+ }[], plain: (md: string) => string): Record<string, unknown> | null;
13
+ export declare function breadcrumbData(site: Site, page: Page): Record<string, unknown> | null;
14
+ /** JSON for a `<script type="application/ld+json">`, safe to put in a page. */
15
+ export declare function ldJson(data: unknown): string;
16
+ /** Every page may be read; the sitemap says which there are. */
17
+ export declare function robotsTxt(site: Site): string;
18
+ /** Every page search engines should list, at its canonical address. */
19
+ export declare function sitemapXml(site: Site): string;
20
+ /**
21
+ * Cloudflare Pages' `_headers`: the security headers our site checks look for (the content
22
+ * security policy itself is in each page, with hashes of the page's own scripts and styles), long
23
+ * caching for the fingerprinted assets, and `noindex` on the preview addresses (<site>.caltho.site
24
+ * and <project>.pages.dev) so only the real domain is in search results.
25
+ */
26
+ export declare function headersFile(): string;
27
+ /**
28
+ * The site's `_redirects`: CalOS's list for this site when the build is given its address
29
+ * (CALOS_REDIRECTS_URL, the Website › Redirects page's list), otherwise the content's own. A
30
+ * CalOS that can't be reached fails the build rather than publishing without the redirects.
31
+ */
32
+ export declare function loadRedirects(site: Site, url: string | undefined): Promise<{
33
+ file: string;
34
+ from: string;
35
+ }>;
36
+ /** "Mon–Fri 7am–6pm" lines for the hours, in the order given. */
37
+ export declare function hoursLines(b: Business): {
38
+ days: (typeof DAYS)[number][];
39
+ time: string;
40
+ }[];
package/dist/seo.js ADDED
@@ -0,0 +1,153 @@
1
+ // What search engines and browsers read about a site besides its words: the structured data
2
+ // (schema.org) describing the business, robots.txt, and the files Cloudflare Pages reads for
3
+ // response headers and redirects. Everything here is worked out from the site's content.
4
+ import { clock } from "./content/index.js";
5
+ /** The page's address as search engines should know it. */
6
+ export function canonical(site, path) {
7
+ return new URL(path, site.url).href;
8
+ }
9
+ /** "Services | Northfield Plumbing"; the home page's title stands alone when it already names them. */
10
+ export function fullTitle(site, page) {
11
+ const name = site.business.name;
12
+ if (page.path === "/" && page.title.toLowerCase().includes(name.toLowerCase()))
13
+ return page.title;
14
+ const joined = `${page.title} | ${name}`;
15
+ // Search results cut titles at about 60 characters: the business's name only where it fits.
16
+ return joined.length <= 60 ? joined : page.title;
17
+ }
18
+ const DAY_URI = {
19
+ Mo: "Monday",
20
+ Tu: "Tuesday",
21
+ We: "Wednesday",
22
+ Th: "Thursday",
23
+ Fr: "Friday",
24
+ Sa: "Saturday",
25
+ Su: "Sunday",
26
+ };
27
+ /** The business as schema.org describes it: what Google uses for its knowledge panel and maps. */
28
+ export function businessData(site, image) {
29
+ const b = site.business;
30
+ const sameAs = Object.values(b.social).filter(Boolean);
31
+ return clean({
32
+ "@context": "https://schema.org",
33
+ "@type": b.kind,
34
+ "@id": `${site.url.replace(/\/$/, "")}/#business`,
35
+ name: b.name,
36
+ description: b.tagline,
37
+ url: site.url,
38
+ telephone: b.phone,
39
+ email: b.email,
40
+ image,
41
+ priceRange: b.priceRange,
42
+ foundingDate: b.founded ? String(b.founded) : undefined,
43
+ address: b.address && b.showAddress
44
+ ? {
45
+ "@type": "PostalAddress",
46
+ streetAddress: b.address.street,
47
+ addressLocality: b.address.locality,
48
+ addressRegion: b.address.region,
49
+ postalCode: b.address.postcode,
50
+ addressCountry: b.address.country,
51
+ }
52
+ : b.address
53
+ ? { "@type": "PostalAddress", addressLocality: b.address.locality, addressRegion: b.address.region, addressCountry: b.address.country }
54
+ : undefined,
55
+ areaServed: b.areas.length ? b.areas.map((name) => ({ "@type": "Place", name })) : undefined,
56
+ openingHoursSpecification: b.hours.length
57
+ ? b.hours.map((h) => ({ "@type": "OpeningHoursSpecification", dayOfWeek: h.days.map((d) => `https://schema.org/${DAY_URI[d]}`), opens: h.opens, closes: h.closes }))
58
+ : undefined,
59
+ sameAs: sameAs.length ? sameAs : undefined,
60
+ });
61
+ }
62
+ /** FAQPage structured data from a page's questions and answers (`plain` turns an answer's Markdown to words). */
63
+ export function faqData(items, plain) {
64
+ if (!items.length)
65
+ return null;
66
+ return {
67
+ "@context": "https://schema.org",
68
+ "@type": "FAQPage",
69
+ mainEntity: items.map((i) => ({ "@type": "Question", name: i.q, acceptedAnswer: { "@type": "Answer", text: plain(i.a) } })),
70
+ };
71
+ }
72
+ export function breadcrumbData(site, page) {
73
+ if (page.path === "/")
74
+ return null;
75
+ const crumbs = [{ name: "Home", path: "/" }];
76
+ const parts = page.path.split("/").filter(Boolean);
77
+ parts.forEach((_, i) => {
78
+ const path = `/${parts.slice(0, i + 1).join("/")}/`;
79
+ const p = site.pages.find((x) => x.path === path);
80
+ if (p)
81
+ crumbs.push({ name: p.menu ?? p.title, path });
82
+ });
83
+ return {
84
+ "@context": "https://schema.org",
85
+ "@type": "BreadcrumbList",
86
+ itemListElement: crumbs.map((c, i) => ({ "@type": "ListItem", position: i + 1, name: c.name, item: canonical(site, c.path) })),
87
+ };
88
+ }
89
+ /** JSON for a `<script type="application/ld+json">`, safe to put in a page. */
90
+ export function ldJson(data) {
91
+ return JSON.stringify(data).replace(/</g, "\\u003c");
92
+ }
93
+ /** Every page may be read; the sitemap says which there are. */
94
+ export function robotsTxt(site) {
95
+ return `User-agent: *\nAllow: /\n\nSitemap: ${canonical(site, "/sitemap.xml")}\n`;
96
+ }
97
+ /** Every page search engines should list, at its canonical address. */
98
+ export function sitemapXml(site) {
99
+ const urls = site.pages.filter((p) => !p.noindex).map((p) => ` <url><loc>${canonical(site, p.path)}</loc></url>`);
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
+ }
102
+ /**
103
+ * Cloudflare Pages' `_headers`: the security headers our site checks look for (the content
104
+ * security policy itself is in each page, with hashes of the page's own scripts and styles), long
105
+ * caching for the fingerprinted assets, and `noindex` on the preview addresses (<site>.caltho.site
106
+ * and <project>.pages.dev) so only the real domain is in search results.
107
+ */
108
+ export function headersFile() {
109
+ return `/*
110
+ X-Content-Type-Options: nosniff
111
+ Referrer-Policy: strict-origin-when-cross-origin
112
+ Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
113
+ X-Frame-Options: DENY
114
+ Content-Security-Policy: frame-ancestors 'none'
115
+ Strict-Transport-Security: max-age=31536000
116
+
117
+ /_next/static/*
118
+ Cache-Control: public, max-age=31536000, immutable
119
+
120
+ /_img/*
121
+ Cache-Control: public, max-age=31536000, immutable
122
+
123
+ /_fonts/*
124
+ Cache-Control: public, max-age=31536000, immutable
125
+
126
+ https://:project.pages.dev/*
127
+ X-Robots-Tag: noindex
128
+
129
+ https://:site.caltho.site/*
130
+ X-Robots-Tag: noindex
131
+ `;
132
+ }
133
+ /**
134
+ * The site's `_redirects`: CalOS's list for this site when the build is given its address
135
+ * (CALOS_REDIRECTS_URL, the Website › Redirects page's list), otherwise the content's own. A
136
+ * CalOS that can't be reached fails the build rather than publishing without the redirects.
137
+ */
138
+ export async function loadRedirects(site, url) {
139
+ if (url) {
140
+ const res = await fetch(url, { signal: AbortSignal.timeout(15_000) });
141
+ if (!res.ok)
142
+ throw new Error(`Couldn't get the redirects from CalOS (${url}): ${res.status}`);
143
+ return { file: await res.text(), from: "CalOS" };
144
+ }
145
+ return { file: site.redirects.map((r) => `${r.from} ${r.to} ${r.status}`).join("\n") + (site.redirects.length ? "\n" : ""), from: "site.json" };
146
+ }
147
+ /** "Mon–Fri 7am–6pm" lines for the hours, in the order given. */
148
+ export function hoursLines(b) {
149
+ return b.hours.map((h) => ({ days: h.days, time: `${clock(h.opens)}–${clock(h.closes)}` }));
150
+ }
151
+ function clean(o) {
152
+ return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null));
153
+ }
package/package.json CHANGED
@@ -1,6 +1,86 @@
1
1
  {
2
2
  "name": "@caltho/site-kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/trycaltho/site-kit.git"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "LICENSE"
17
+ ],
18
+ "bin": {
19
+ "site-kit": "dist/cli/index.js"
20
+ },
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "default": "./dist/index.js"
25
+ },
26
+ "./content": {
27
+ "types": "./dist/content/index.d.ts",
28
+ "default": "./dist/content/index.js"
29
+ },
30
+ "./lib": {
31
+ "types": "./dist/lib.d.ts",
32
+ "default": "./dist/lib.js"
33
+ },
34
+ "./next": {
35
+ "types": "./dist/next/index.d.ts",
36
+ "default": "./dist/next/index.js"
37
+ }
38
+ },
39
+ "engines": {
40
+ "node": ">=22.18"
41
+ },
42
+ "scripts": {
43
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && chmod +x dist/cli/index.js",
44
+ "typecheck": "tsc -p tsconfig.json --noEmit",
45
+ "test": "bun test src",
46
+ "prepack": "bun run build"
47
+ },
48
+ "dependencies": {
49
+ "marked": "18.1.0",
50
+ "sharp": "0.35.5",
51
+ "zod": "4.6.7"
52
+ },
53
+ "peerDependencies": {
54
+ "axe-core": "^4.14.0",
55
+ "next": "^16.4.1",
56
+ "playwright": "^1.63.0",
57
+ "react": "^19.3.0"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "axe-core": {
61
+ "optional": true
62
+ },
63
+ "next": {
64
+ "optional": true
65
+ },
66
+ "playwright": {
67
+ "optional": true
68
+ },
69
+ "react": {
70
+ "optional": true
71
+ }
72
+ },
73
+ "devDependencies": {
74
+ "@types/bun": "1.4.2",
75
+ "@types/node": "22.20.5",
76
+ "@types/react": "19.3.0",
77
+ "axe-core": "4.14.0",
78
+ "next": "16.4.1",
79
+ "playwright": "1.63.0",
80
+ "react": "19.3.0",
81
+ "typescript": "6.0.3"
82
+ },
83
+ "bugs": {
84
+ "url": "https://github.com/trycaltho/site-kit/issues"
85
+ }
86
+ }