@caltho/site-kit 0.2.1 → 0.2.3

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,22 @@ 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);
122
+ // Another page starts at its top, unless a section on it is to be brought into view.
123
+ if (shown.path !== drawnPath && !(el && shown.scroll))
124
+ window.scrollTo({ top: 0 });
125
+ drawnPath = shown.path;
120
126
  // Scrolled here only: scrollIntoView would scroll CalOS's page around the frame too.
121
127
  if (el && shown.scroll)
122
128
  window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - window.innerHeight / 4, behavior: "smooth" });
123
- const keep = () => outline("selected", blockElement(latest.current), label(latest.current));
129
+ const keep = () => {
130
+ outline("selected", blockElement(latest.current), label(latest.current));
131
+ outline("section", null);
132
+ outline("hover", null);
133
+ };
124
134
  window.addEventListener("scroll", keep, { passive: true });
125
135
  window.addEventListener("resize", keep);
126
136
  return () => {
@@ -135,6 +145,30 @@ export function SitePreview({ origins, render }) {
135
145
  return null;
136
146
  return (_jsx(Fallback, { onError: (message) => post({ type: "calos:error", message }), children: render(page, shown.site) }, round));
137
147
  }
148
+ /** The pictures this build made (finish writes the list into this page), by path. */
149
+ let built = null;
150
+ /**
151
+ * CalOS's pictures, each one the build made unchanged (the same file: its built names carry the
152
+ * start of its SHA-256, CalOS's address all of it) swapped for the built copies, sized as the site
153
+ * serves them; a picture new since the build comes from CalOS.
154
+ */
155
+ function asBuilt(images) {
156
+ if (!built) {
157
+ try {
158
+ built = JSON.parse(document.getElementById(BUILT_IMAGES)?.textContent || "{}");
159
+ }
160
+ catch {
161
+ built = {};
162
+ }
163
+ }
164
+ const out = {};
165
+ for (const [path, img] of Object.entries(images)) {
166
+ const sha = /\/assets\/([0-9a-f]{64})$/.exec(img.src)?.[1];
167
+ const made = built[path];
168
+ out[path] = made?.hash && sha?.startsWith(made.hash) ? made : img;
169
+ }
170
+ return out;
171
+ }
138
172
  /** A path on this site from a link's address, as the content writes it; null for anywhere else. */
139
173
  function internalPath(href, site) {
140
174
  if (!href || href.startsWith("#") || /^(mailto|tel):/.test(href))
@@ -147,13 +181,15 @@ function internalPath(href, site) {
147
181
  return site.pages.some((p) => p.path === bare) ? bare : site.pages.some((p) => p.path === withSlash) ? withSlash : null;
148
182
  }
149
183
  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. */
184
+ /** What's in `main` without being drawn: a page's structured data (JSON-LD) and the like. */
185
+ const UNDRAWN = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "LINK", "META"]);
186
+ /** The page's blocks as drawn: `main`'s children (those drawn), when there's one for each block. */
151
187
  function blockElements(m) {
152
188
  const main = document.querySelector("main");
153
189
  const page = m.site.pages[pageIndex(m)];
154
190
  if (!main || !page)
155
191
  return null;
156
- const kids = [...main.children];
192
+ const kids = [...main.children].filter((e) => !UNDRAWN.has(e.tagName));
157
193
  return kids.length === page.blocks.length ? kids : null;
158
194
  }
159
195
  function blockElement(m) {
@@ -233,6 +269,8 @@ const looks = {
233
269
  hover: { outline: `1px dashed ${BLUE}`, outlineOffset: "3px", borderRadius: "3px" },
234
270
  };
235
271
  let hovered = null;
272
+ /** The page last drawn, so another one starts at its top. */
273
+ let drawnPath = null;
236
274
  const boxes = {};
237
275
  function outline(which, el, label) {
238
276
  let made = boxes[which];
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caltho/site-kit",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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",