@caltho/site-kit 0.2.0 → 0.2.2

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.
@@ -101,11 +101,11 @@ export function defineSite(blocks, o = {}) {
101
101
  const schema = z
102
102
  .object({
103
103
  /** The address it's served at once live (its main domain). */
104
- url: z.url({ protocol: /^https$/, error: "The site's address, starting https://" }),
105
- lang: z.string().default("en-US"),
104
+ url: z.url({ protocol: /^https$/, error: "The site's address, starting https://" }).meta({ title: "Web address" }),
105
+ lang: z.string().default("en-US").meta({ title: "Language" }),
106
106
  business,
107
107
  /** Where the contact forms post to, if the site has any (a CalOS form's address, or another service's). */
108
- form: z.url({ protocol: /^https?$/ }).optional(),
108
+ form: z.url({ protocol: /^https?$/ }).meta({ title: "Where its forms send" }).optional(),
109
109
  /** Old addresses kept answering, when CalOS doesn't supply them at build. */
110
110
  redirects: z.array(z.object({ from: z.string().startsWith("/"), to: href, status: z.union([z.literal(301), z.literal(302)]).default(301) })).default([]),
111
111
  ...o.extend,
package/dist/finish.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import { createHash } from "node:crypto";
6
6
  import { readdir, readFile, writeFile } from "node:fs/promises";
7
7
  import { join, relative } from "node:path";
8
+ import { BUILT_IMAGES } from "./preview-match.js";
8
9
  import { headersFile, loadRedirects, PREVIEW_PATH } from "./seo.js";
9
10
  export async function finish(root, outDir, env = process.env, log = console.log) {
10
11
  const prepared = JSON.parse(await readFile(join(root, ".site", "prepared.json"), "utf8"));
@@ -42,9 +43,14 @@ export async function finish(root, outDir, env = process.env, log = console.log)
42
43
  };
43
44
  for (const [directive, sources] of Object.entries(prepared.csp ?? {}))
44
45
  directives[directive] = [...new Set([...(directives[directive] ?? ["'self'"]), ...sources])];
45
- // The preview shows the draft's pictures from CalOS, before any build has made them.
46
- if (relative(outDir, file).replace(/(\/index)?\.html$/, "") === PREVIEW_PATH.slice(1))
46
+ // The preview shows the draft's pictures from CalOS, before any build has made them; those this
47
+ // build made, it shows as built (./next/preview.tsx), from the list it carries.
48
+ const isPreview = relative(outDir, file).replace(/(\/index)?\.html$/, "") === PREVIEW_PATH.slice(1);
49
+ if (isPreview) {
47
50
  directives["img-src"].push(...editors);
51
+ const built = JSON.stringify(prepared.images ?? {}).replace(/</g, "\\u003c");
52
+ html = html.replace(/<\/head>/i, () => `<script type="application/json" id="${BUILT_IMAGES}">${built}</script></head>`);
53
+ }
48
54
  const policy = Object.entries(directives).map(([d, v]) => `${d} ${v.join(" ")}`).join("; ");
49
55
  html = html.replace(/<meta http-equiv="content-security-policy"[^>]*>/i, "");
50
56
  html = html.replace(/<head([^>]*)>/i, `<head$1><meta http-equiv="content-security-policy" content="${policy}">`);
package/dist/next/data.js CHANGED
@@ -8,10 +8,20 @@ let loader = null;
8
8
  export function loadPreparedWith(load) {
9
9
  loader = load;
10
10
  }
11
+ /** Whether the store holds a draft from CalOS's preview, rather than a build's content. */
12
+ let drafting = false;
11
13
  /** Puts content in the store: CalOS's preview, with the draft it's editing. */
12
14
  export function setPrepared(p) {
13
15
  current = p;
16
+ drafting = true;
14
17
  }
18
+ /** A picture not chosen yet, in a draft: a grey box where it will go. */
19
+ const NOT_CHOSEN = {
20
+ width: 1600,
21
+ height: 1000,
22
+ src: `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000"><rect width="100%" height="100%" fill="#e5e5e5"/></svg>')}`,
23
+ srcset: [],
24
+ };
15
25
  export function prepared() {
16
26
  if (loader && (!current || process.env.NODE_ENV === "development"))
17
27
  current = loader();
@@ -22,6 +32,10 @@ export function prepared() {
22
32
  export const site = () => prepared().site;
23
33
  export function image(src) {
24
34
  const found = prepared().images[src];
35
+ // A section being filled in, in the preview, is drawn with its picture still to choose; a build
36
+ // with a picture it didn't prepare stops.
37
+ if (!found && drafting)
38
+ return NOT_CHOSEN;
25
39
  if (!found)
26
40
  throw new Error(`The image "${src}" wasn't prepared: run \`site-kit prepare\` again`);
27
41
  return found;
@@ -10,7 +10,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
10
10
  // page → CalOS: { type: "calos:ready" }, { type: "calos:pick", path, block }, { type: "calos:go", path },
11
11
  // { type: "calos:error", message }
12
12
  import { Component, useEffect, useRef, useState } from "react";
13
- import { fieldForImage, fieldForText } from "../preview-match.js";
13
+ import { BUILT_IMAGES, fieldForImage, fieldForText } from "../preview-match.js";
14
14
  import { setPrepared } from "./data.js";
15
15
  export function SitePreview({ origins, render }) {
16
16
  const [shown, setShown] = useState(null);
@@ -31,9 +31,10 @@ export function SitePreview({ origins, render }) {
31
31
  const onMessage = (e) => {
32
32
  if (!origins.includes(e.origin) || e.source !== window.parent)
33
33
  return;
34
- const m = e.data;
35
- if (m?.type !== "calos:show")
34
+ const sent = e.data;
35
+ if (sent?.type !== "calos:show")
36
36
  return;
37
+ const m = { ...sent, images: asBuilt(sent.images) };
37
38
  replyTo.current = e.origin;
38
39
  setPrepared({ site: m.site, images: m.images, csp: {} });
39
40
  latest.current = m;
@@ -114,13 +115,18 @@ export function SitePreview({ origins, render }) {
114
115
  const el = blockElement(shown);
115
116
  const label = (m) => (m?.selected && typeof m.selected[3] === "number" ? m.labels?.[m.selected[3]] : undefined);
116
117
  outline("selected", el, label(shown));
117
- // The section just chosen is outlined as chosen, not as under the pointer too.
118
- if (el && hovered === el)
119
- outline("section", null);
118
+ // What was under the pointer has moved with the drawing: outlined again on the next move.
119
+ hovered = null;
120
+ outline("section", null);
121
+ outline("hover", null);
120
122
  // Scrolled here only: scrollIntoView would scroll CalOS's page around the frame too.
121
123
  if (el && shown.scroll)
122
124
  window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - window.innerHeight / 4, behavior: "smooth" });
123
- const keep = () => outline("selected", blockElement(latest.current), label(latest.current));
125
+ const keep = () => {
126
+ outline("selected", blockElement(latest.current), label(latest.current));
127
+ outline("section", null);
128
+ outline("hover", null);
129
+ };
124
130
  window.addEventListener("scroll", keep, { passive: true });
125
131
  window.addEventListener("resize", keep);
126
132
  return () => {
@@ -135,6 +141,30 @@ export function SitePreview({ origins, render }) {
135
141
  return null;
136
142
  return (_jsx(Fallback, { onError: (message) => post({ type: "calos:error", message }), children: render(page, shown.site) }, round));
137
143
  }
144
+ /** The pictures this build made (finish writes the list into this page), by path. */
145
+ let built = null;
146
+ /**
147
+ * CalOS's pictures, each one the build made unchanged (the same file: its built names carry the
148
+ * start of its SHA-256, CalOS's address all of it) swapped for the built copies, sized as the site
149
+ * serves them; a picture new since the build comes from CalOS.
150
+ */
151
+ function asBuilt(images) {
152
+ if (!built) {
153
+ try {
154
+ built = JSON.parse(document.getElementById(BUILT_IMAGES)?.textContent || "{}");
155
+ }
156
+ catch {
157
+ built = {};
158
+ }
159
+ }
160
+ const out = {};
161
+ for (const [path, img] of Object.entries(images)) {
162
+ const sha = /\/assets\/([0-9a-f]{64})$/.exec(img.src)?.[1];
163
+ const made = built[path];
164
+ out[path] = made?.hash && sha?.startsWith(made.hash) ? made : img;
165
+ }
166
+ return out;
167
+ }
138
168
  /** A path on this site from a link's address, as the content writes it; null for anywhere else. */
139
169
  function internalPath(href, site) {
140
170
  if (!href || href.startsWith("#") || /^(mailto|tel):/.test(href))
@@ -147,13 +177,15 @@ function internalPath(href, site) {
147
177
  return site.pages.some((p) => p.path === bare) ? bare : site.pages.some((p) => p.path === withSlash) ? withSlash : null;
148
178
  }
149
179
  const pageIndex = (m) => m.site.pages.findIndex((p) => p.path === m.path);
150
- /** The page's blocks as drawn: `main`'s children, when there's one for each block. */
180
+ /** What's in `main` without being drawn: a page's structured data (JSON-LD) and the like. */
181
+ const UNDRAWN = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "LINK", "META"]);
182
+ /** The page's blocks as drawn: `main`'s children (those drawn), when there's one for each block. */
151
183
  function blockElements(m) {
152
184
  const main = document.querySelector("main");
153
185
  const page = m.site.pages[pageIndex(m)];
154
186
  if (!main || !page)
155
187
  return null;
156
- const kids = [...main.children];
188
+ const kids = [...main.children].filter((e) => !UNDRAWN.has(e.tagName));
157
189
  return kids.length === page.blocks.length ? kids : null;
158
190
  }
159
191
  function blockElement(m) {
package/dist/prepare.d.ts CHANGED
@@ -11,6 +11,8 @@ export type PreparedImage = {
11
11
  }[];
12
12
  /** A 1200×630 JPEG, where a page shares it. */
13
13
  share?: string;
14
+ /** The start of the file's SHA-256, as in its built names: CalOS's preview knows a picture unchanged since the build by it. */
15
+ hash?: string;
14
16
  };
15
17
  export type Prepared = {
16
18
  site: Site;
package/dist/prepare.js CHANGED
@@ -113,7 +113,7 @@ async function pictures(site, imagesDir, pub, log) {
113
113
  if (!existsSync(join(dir, name)))
114
114
  await writeFile(join(dir, name), bytes);
115
115
  const meta = await sharp(bytes).metadata();
116
- result[src] = { width: meta.width ?? 0, height: meta.height ?? 0, src: `/_img/${name}`, srcset: [] };
116
+ result[src] = { width: meta.width ?? 0, height: meta.height ?? 0, src: `/_img/${name}`, srcset: [], hash };
117
117
  continue;
118
118
  }
119
119
  const img = sharp(bytes).rotate();
@@ -135,7 +135,7 @@ async function pictures(site, imagesDir, pub, log) {
135
135
  }
136
136
  srcset.push({ w, url: `/_img/${name}` });
137
137
  }
138
- const entry = { width, height, src: srcset.at(-1).url, srcset };
138
+ const entry = { width, height, src: srcset.at(-1).url, srcset, hash };
139
139
  if (shared.has(src)) {
140
140
  const name = `${base}.${hash}.share.jpg`;
141
141
  if (!existsSync(join(dir, name))) {
@@ -1,3 +1,5 @@
1
+ /** The preview page's list of the pictures its build made (by path), as a JSON script with this id (../finish.ts). */
2
+ export declare const BUILT_IMAGES = "calos-built-images";
1
3
  export type Path = (string | number)[];
2
4
  /** Words as they read on the page: Markdown's marks gone, a link's address gone, spacing and case evened. */
3
5
  export declare function readable(text: string): string;
@@ -1,6 +1,8 @@
1
1
  // Which field of the content a click in CalOS's preview landed on, with no markers in the site's
2
2
  // components: the words clicked are matched against the content's text, a picture by its address.
3
3
  // Pure, so it's tested here and runs in the preview in the browser.
4
+ /** The preview page's list of the pictures its build made (by path), as a JSON script with this id (../finish.ts). */
5
+ export const BUILT_IMAGES = "calos-built-images";
4
6
  /** Keys that hold no words a visitor reads: never what a click on words means. */
5
7
  const SKIP = new Set(["type", "id", "href", "path", "style", "menuStyle", "footer", "ratio", "tone"]);
6
8
  /** Words as they read on the page: Markdown's marks gone, a link's address gone, spacing and case evened. */
package/dist/seo.js CHANGED
@@ -111,7 +111,8 @@ export function headersFile(editors = []) {
111
111
  // Every page refuses to be framed (X-Frame-Options, which every browser honours), but the
112
112
  // preview: there, only the CalOS addresses the build was given (./next/preview.tsx), which is
113
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.
114
+ // loosen. With no CalOS addresses, the preview refuses too. Both of its addresses: Cloudflare
115
+ // serves a page built as a folder at the one with the slash, and sends the other there.
115
116
  const preview = editors.length ? ` ! X-Frame-Options\n Content-Security-Policy: frame-ancestors ${editors.join(" ")}\n` : "";
116
117
  return `/*
117
118
  X-Content-Type-Options: nosniff
@@ -133,6 +134,10 @@ ${PREVIEW_PATH}
133
134
  ${preview} X-Robots-Tag: noindex
134
135
  Cache-Control: no-store
135
136
 
137
+ ${PREVIEW_PATH}/
138
+ ${preview} X-Robots-Tag: noindex
139
+ Cache-Control: no-store
140
+
136
141
  https://:project.pages.dev/*
137
142
  X-Robots-Tag: noindex
138
143
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caltho/site-kit",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",