@caltho/site-kit 0.1.0 → 0.2.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,277 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ // CalOS's preview of a draft: the site's own components, drawn in the browser with the content
4
+ // CalOS sends. A site gives it a page at PREVIEW_PATH (`app/calos-preview/page.tsx`) and how it draws
5
+ // a page; CalOS frames it, sends the draft as it changes, and hears back which field a click meant.
6
+ // Nothing is shown until CalOS sends something, and only the business's CalOS addresses (all of
7
+ // them: its caltho.app address and its own domain, from the build) are listened to.
8
+ //
9
+ // CalOS → page: { type: "calos:show", site, images, path, selected?, scroll?, labels? }
10
+ // page → CalOS: { type: "calos:ready" }, { type: "calos:pick", path, block }, { type: "calos:go", path },
11
+ // { type: "calos:error", message }
12
+ import { Component, useEffect, useRef, useState } from "react";
13
+ import { fieldForImage, fieldForText } from "../preview-match.js";
14
+ import { setPrepared } from "./data.js";
15
+ export function SitePreview({ origins, render }) {
16
+ const [shown, setShown] = useState(null);
17
+ const [round, setRound] = useState(0);
18
+ const latest = useRef(null);
19
+ // Answers go to the address CalOS is open at; until it's spoken, "ready" goes to each it may be.
20
+ const replyTo = useRef(null);
21
+ const post = (msg) => {
22
+ if (window.parent === window)
23
+ return;
24
+ for (const o of replyTo.current ? [replyTo.current] : origins)
25
+ window.parent.postMessage(msg, o);
26
+ };
27
+ const key = origins.join(" ");
28
+ useEffect(() => {
29
+ if (!origins.length)
30
+ return;
31
+ const onMessage = (e) => {
32
+ if (!origins.includes(e.origin) || e.source !== window.parent)
33
+ return;
34
+ const m = e.data;
35
+ if (m?.type !== "calos:show")
36
+ return;
37
+ replyTo.current = e.origin;
38
+ setPrepared({ site: m.site, images: m.images, csp: {} });
39
+ latest.current = m;
40
+ setShown(m);
41
+ setRound((r) => r + 1);
42
+ };
43
+ window.addEventListener("message", onMessage);
44
+ // Said until CalOS answers: its page may still be starting when this one is ready.
45
+ let tries = 0;
46
+ const hello = () => {
47
+ if (latest.current || tries++ > 60)
48
+ return clearInterval(timer);
49
+ post({ type: "calos:ready" });
50
+ };
51
+ const timer = setInterval(hello, 250);
52
+ hello();
53
+ return () => {
54
+ clearInterval(timer);
55
+ window.removeEventListener("message", onMessage);
56
+ };
57
+ // eslint-disable-next-line react-hooks/exhaustive-deps
58
+ }, [key]);
59
+ // Clicks pick a field, never follow a link or send a form; a link in the menu goes to that page.
60
+ useEffect(() => {
61
+ const onClick = (e) => {
62
+ const m = latest.current;
63
+ const target = e.target;
64
+ if (!m || !target)
65
+ return;
66
+ e.preventDefault();
67
+ e.stopPropagation();
68
+ const main = document.querySelector("main");
69
+ const link = target.closest("a");
70
+ if (link && !main?.contains(link)) {
71
+ const to = internalPath(link.getAttribute("href"), m.site);
72
+ if (to)
73
+ post({ type: "calos:go", path: to });
74
+ return;
75
+ }
76
+ // A picture under the pointer counts, even when something over it (a frame, a caption's box) took the click.
77
+ const under = document.elementsFromPoint(e.clientX, e.clientY).find((el) => el.tagName === "IMG") ?? target;
78
+ post({ type: "calos:pick", ...pick(under, m, wordsAt(e.clientX, e.clientY)) });
79
+ };
80
+ const onSubmit = (e) => e.preventDefault();
81
+ // Under the pointer: the section, named, and the words or picture a click would edit.
82
+ const onMove = (e) => {
83
+ const m = latest.current;
84
+ const target = e.target;
85
+ const blocks = m ? blockElements(m) : null;
86
+ const n = blocks && target ? blocks.findIndex((el) => el.contains(target)) : -1;
87
+ const selected = m ? blockElement(m) : null;
88
+ hovered = n >= 0 ? blocks[n] : null;
89
+ outline("section", hovered && hovered !== selected ? hovered : null, n >= 0 ? m?.labels?.[n] : undefined);
90
+ outline("hover", hoverable(target));
91
+ };
92
+ const leave = () => {
93
+ hovered = null;
94
+ outline("section", null);
95
+ outline("hover", null);
96
+ };
97
+ document.documentElement.style.cursor = "pointer";
98
+ window.addEventListener("click", onClick, true);
99
+ window.addEventListener("submit", onSubmit, true);
100
+ window.addEventListener("pointermove", onMove, { passive: true });
101
+ document.documentElement.addEventListener("pointerleave", leave);
102
+ return () => {
103
+ window.removeEventListener("click", onClick, true);
104
+ window.removeEventListener("submit", onSubmit, true);
105
+ window.removeEventListener("pointermove", onMove);
106
+ document.documentElement.removeEventListener("pointerleave", leave);
107
+ };
108
+ // eslint-disable-next-line react-hooks/exhaustive-deps
109
+ }, [key]);
110
+ // The selected block outlined (and brought into view when CalOS asks), after each drawing.
111
+ useEffect(() => {
112
+ if (!shown)
113
+ return;
114
+ const el = blockElement(shown);
115
+ const label = (m) => (m?.selected && typeof m.selected[3] === "number" ? m.labels?.[m.selected[3]] : undefined);
116
+ 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);
120
+ // Scrolled here only: scrollIntoView would scroll CalOS's page around the frame too.
121
+ if (el && shown.scroll)
122
+ window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - window.innerHeight / 4, behavior: "smooth" });
123
+ const keep = () => outline("selected", blockElement(latest.current), label(latest.current));
124
+ window.addEventListener("scroll", keep, { passive: true });
125
+ window.addEventListener("resize", keep);
126
+ return () => {
127
+ window.removeEventListener("scroll", keep);
128
+ window.removeEventListener("resize", keep);
129
+ };
130
+ }, [shown, round]);
131
+ if (!shown)
132
+ return null;
133
+ const page = shown.site.pages.find((p) => p.path === shown.path) ?? shown.site.pages[0];
134
+ if (!page)
135
+ return null;
136
+ return (_jsx(Fallback, { onError: (message) => post({ type: "calos:error", message }), children: render(page, shown.site) }, round));
137
+ }
138
+ /** A path on this site from a link's address, as the content writes it; null for anywhere else. */
139
+ function internalPath(href, site) {
140
+ if (!href || href.startsWith("#") || /^(mailto|tel):/.test(href))
141
+ return null;
142
+ const url = new URL(href, location.href);
143
+ if (url.origin !== location.origin && url.origin !== new URL(site.url).origin)
144
+ return null;
145
+ const bare = url.pathname.replace(/\/$/, "") || "/";
146
+ const withSlash = bare === "/" ? "/" : `${bare}/`;
147
+ return site.pages.some((p) => p.path === bare) ? bare : site.pages.some((p) => p.path === withSlash) ? withSlash : null;
148
+ }
149
+ 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. */
151
+ function blockElements(m) {
152
+ const main = document.querySelector("main");
153
+ const page = m.site.pages[pageIndex(m)];
154
+ if (!main || !page)
155
+ return null;
156
+ const kids = [...main.children];
157
+ return kids.length === page.blocks.length ? kids : null;
158
+ }
159
+ function blockElement(m) {
160
+ if (!m?.selected)
161
+ return null;
162
+ const [k, p, b, n] = m.selected;
163
+ if (k !== "pages" || p !== pageIndex(m) || b !== "blocks" || typeof n !== "number")
164
+ return null;
165
+ return blockElements(m)?.[n] ?? null;
166
+ }
167
+ /** The words of the text under the pointer: a name set beside other things in one element (a caption with an email and a phone) is still just the name. */
168
+ function wordsAt(x, y) {
169
+ const doc = document;
170
+ const node = doc.caretPositionFromPoint?.(x, y)?.offsetNode ?? doc.caretRangeFromPoint?.(x, y)?.startContainer;
171
+ return node?.nodeType === Node.TEXT_NODE ? (node.textContent?.trim() ?? null) || null : null;
172
+ }
173
+ /** What a click on `target` meant: the field, and the block it's in. `words`: the text right under the pointer, tried first. */
174
+ function pick(target, m, words = null) {
175
+ const pi = pageIndex(m);
176
+ const page = m.site.pages[pi];
177
+ const blocks = blockElements(m);
178
+ const n = blocks ? blocks.findIndex((el) => el.contains(target)) : -1;
179
+ const block = n >= 0 ? ["pages", pi, "blocks", n] : null;
180
+ const scope = n >= 0 ? page.blocks[n] : null;
181
+ // What the whole site shares (its founders, its case studies), which a section may show too; the
182
+ // pages' own fields are found through the section or, outside one, the page.
183
+ const shared = { ...m.site, pages: [] };
184
+ const pageOwn = { ...page, blocks: [] };
185
+ const img = target.closest("img");
186
+ if (img) {
187
+ const url = img.currentSrc || img.src;
188
+ const found = (scope && fieldForImage(scope, block, url, m.images)) ?? fieldForImage(shared, [], url, m.images) ?? fieldForImage(pageOwn, ["pages", pi], url, m.images);
189
+ if (found)
190
+ return { path: found, block: blockOf(found) ?? block };
191
+ }
192
+ if (words) {
193
+ const exact = (scope && fieldForText(scope, block, words, false)) ?? fieldForText(shared, [], words, false) ?? fieldForText(pageOwn, ["pages", pi], words, false);
194
+ if (exact)
195
+ return { path: exact, block: blockOf(exact) ?? block };
196
+ }
197
+ // The clicked element's words, then its parents' until the block's edge: at each, the section's
198
+ // own fields first, then the site's shared ones, so a parent holding more of the section isn't
199
+ // mistaken for what was clicked.
200
+ for (let el = target; el && el !== document.body; el = el.parentElement) {
201
+ const text = el.innerText ?? el.textContent ?? "";
202
+ if (text.trim() && text.length < 2000) {
203
+ // The same words anywhere first (the founder's name in the founders, not inside the intro
204
+ // that mentions them), then a field that holds the words.
205
+ const at = (loose) => (scope && fieldForText(scope, block, text, loose)) ?? fieldForText(shared, [], text, loose) ?? (n < 0 ? fieldForText(pageOwn, ["pages", pi], text, loose) : null);
206
+ const found = at(false) ?? at(true);
207
+ if (found)
208
+ return { path: found, block: blockOf(found) ?? block };
209
+ }
210
+ if (n >= 0 && el === blocks[n])
211
+ break;
212
+ }
213
+ return { path: block, block };
214
+ }
215
+ const blockOf = (path) => (path[0] === "pages" && path[2] === "blocks" && typeof path[3] === "number" ? path.slice(0, 4) : null);
216
+ /** The smallest thing under the pointer that's words or a picture. */
217
+ function hoverable(el) {
218
+ for (let at = el; at && at !== document.body; at = at.parentElement) {
219
+ if (at.tagName === "IMG")
220
+ return at;
221
+ if ([...at.childNodes].some((c) => c.nodeType === Node.TEXT_NODE && c.textContent?.trim()))
222
+ return at;
223
+ }
224
+ return null;
225
+ }
226
+ // Outlines drawn over the page, sized through the style object (allowed by the content security
227
+ // policy, where a style attribute in the markup isn't), never catching the pointer. A section's
228
+ // outline carries its name.
229
+ const BLUE = "#2563eb";
230
+ const looks = {
231
+ selected: { outline: `2px solid ${BLUE}`, outlineOffset: "-2px", borderRadius: "2px" },
232
+ section: { outline: `1px solid ${BLUE}80`, outlineOffset: "-1px", borderRadius: "2px" },
233
+ hover: { outline: `1px dashed ${BLUE}`, outlineOffset: "3px", borderRadius: "3px" },
234
+ };
235
+ let hovered = null;
236
+ const boxes = {};
237
+ function outline(which, el, label) {
238
+ let made = boxes[which];
239
+ if (!made) {
240
+ const box = document.createElement("div");
241
+ Object.assign(box.style, { position: "fixed", pointerEvents: "none", zIndex: "2147483647", ...looks[which] });
242
+ const tag = document.createElement("span");
243
+ Object.assign(tag.style, {
244
+ position: "absolute",
245
+ top: "0",
246
+ left: "0",
247
+ padding: "3px 8px",
248
+ font: "500 12px/1.3 system-ui, -apple-system, sans-serif",
249
+ color: "#fff",
250
+ background: which === "selected" ? BLUE : `${BLUE}cc`,
251
+ borderBottomRightRadius: "4px",
252
+ whiteSpace: "nowrap",
253
+ });
254
+ box.appendChild(tag);
255
+ document.body.appendChild(box);
256
+ made = boxes[which] = { box, tag };
257
+ }
258
+ if (!el)
259
+ return void (made.box.style.display = "none");
260
+ const r = el.getBoundingClientRect();
261
+ Object.assign(made.box.style, { display: "block", top: `${r.top}px`, left: `${r.left}px`, width: `${r.width}px`, height: `${r.height}px` });
262
+ made.tag.textContent = label ?? "";
263
+ made.tag.style.display = label ? "block" : "none";
264
+ }
265
+ /** A draft that can't be drawn yet (a block half filled in) says so instead of a blank page. */
266
+ class Fallback extends Component {
267
+ state = { error: null };
268
+ static getDerivedStateFromError(e) {
269
+ return { error: e instanceof Error ? e.message : String(e) };
270
+ }
271
+ componentDidCatch(e) {
272
+ this.props.onError(e instanceof Error ? e.message : String(e));
273
+ }
274
+ render() {
275
+ return this.state.error ? null : this.props.children;
276
+ }
277
+ }
package/dist/prepare.d.ts CHANGED
@@ -15,6 +15,10 @@ export type PreparedImage = {
15
15
  export type Prepared = {
16
16
  site: Site;
17
17
  images: Record<string, PreparedImage>;
18
+ /** Sources the site's content security policy allows beyond its own, by directive (from defineSite). */
19
+ csp: Record<string, string[]>;
20
+ /** The addresses of the CalOS this content came from (every one the business uses it on): those that may draw drafts in the preview. */
21
+ editors?: string[];
18
22
  };
19
23
  type Log = (line: string) => void;
20
24
  export declare function prepare(root: string, env?: Record<string, string | undefined>, log?: Log): Promise<Prepared>;
package/dist/prepare.js CHANGED
@@ -15,7 +15,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
15
15
  };
16
16
  import { createHash } from "node:crypto";
17
17
  import { existsSync } from "node:fs";
18
- import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
18
+ import { copyFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
19
19
  import { dirname, extname, join, relative } from "node:path";
20
20
  import sharp from "sharp";
21
21
  import { pathToFileURL } from "node:url";
@@ -29,15 +29,27 @@ export async function prepare(root, env = process.env, log = console.log) {
29
29
  // The content: CalOS's when told where, the site's own otherwise.
30
30
  let raw;
31
31
  let from;
32
- if (env.CALOS_CONTENT_URL) {
33
- const res = await fetch(env.CALOS_CONTENT_URL, {
32
+ let editors = [];
33
+ // A site CalOS has nothing published for yet (just made, its content not brought in) builds from
34
+ // its own until there is: 404 is "nothing published", any other refusal is an error.
35
+ const res = env.CALOS_CONTENT_URL
36
+ ? await fetch(env.CALOS_CONTENT_URL, {
34
37
  headers: env.CALOS_SITE_TOKEN ? { authorization: `Bearer ${env.CALOS_SITE_TOKEN}` } : {},
35
38
  signal: AbortSignal.timeout(30_000),
36
- });
39
+ })
40
+ : null;
41
+ const editorsIn = (body) => (Array.isArray(body?.editors) ? body.editors.filter((o) => typeof o === "string" && /^https?:\/\/[a-z0-9.-]+(:\d+)?$/i.test(o)) : []);
42
+ if (res && res.status === 404) {
43
+ // Who may edit it is known before anything's published: its preview can be drawn from the start.
44
+ editors = editorsIn((await res.json().catch(() => null)));
45
+ log("site-kit: nothing published in CalOS yet; building from content/site.json");
46
+ }
47
+ if (res && res.status !== 404) {
37
48
  if (!res.ok)
38
49
  throw new Error(`Couldn't get the content from CalOS (${env.CALOS_CONTENT_URL}): ${res.status}`);
39
50
  const body = (await res.json());
40
51
  raw = body.content;
52
+ editors = editorsIn(body);
41
53
  from = "CalOS";
42
54
  // Pictures uploaded in CalOS, fetched into the images folder once each.
43
55
  for (const [name, url] of Object.entries(body.images ?? {})) {
@@ -66,9 +78,15 @@ export async function prepare(root, env = process.env, log = console.log) {
66
78
  if (parsed.problems)
67
79
  throw new Error(`The site's content needs fixing (${from}):\n - ${parsed.problems.join("\n - ")}`);
68
80
  const site = parsed.site;
69
- await writeFile(join(out, "schema.json"), JSON.stringify(definition.jsonSchema(), null, 2) + "\n");
81
+ for (const w of parsed.warnings)
82
+ log(`site-kit: warning: ${w}`);
83
+ const schema = definition.jsonSchema();
84
+ await writeFile(join(out, "schema.json"), JSON.stringify(schema, null, 2) + "\n");
85
+ // Content from CalOS: tell it what this build says is editable, so its forms match the site's code.
86
+ if (env.CALOS_CONTENT_URL)
87
+ await pushSchema(env.CALOS_CONTENT_URL, env.CALOS_SCHEMA_URL, env.CALOS_SITE_TOKEN, schema, log);
70
88
  const images = await pictures(site, imagesDir, pub, log);
71
- const prepared = { site, images };
89
+ const prepared = { site, images, csp: definition.csp, ...(editors.length ? { editors } : {}) };
72
90
  await writeFile(join(out, "prepared.json"), JSON.stringify(prepared));
73
91
  log(`site-kit: content from ${from}, ${site.pages.length} pages, ${Object.keys(images).length} images`);
74
92
  return prepared;
@@ -108,7 +126,11 @@ async function pictures(site, imagesDir, pub, log) {
108
126
  for (const w of widths.length ? widths : [width]) {
109
127
  const name = `${base}.${hash}.${w}.webp`;
110
128
  if (!existsSync(join(dir, name))) {
111
- await sharp(bytes).rotate().resize({ width: w }).webp({ quality: 78 }).toFile(join(dir, name));
129
+ // A WebP that's already the size wanted is kept as it is: encoding it again only loses quality.
130
+ if (meta.format === "webp" && w === width && !meta.orientation)
131
+ await copyFile(join(imagesDir, src), join(dir, name));
132
+ else
133
+ await sharp(bytes).rotate().resize({ width: w }).webp({ quality: 78 }).toFile(join(dir, name));
112
134
  made++;
113
135
  }
114
136
  srcset.push({ w, url: `/_img/${name}` });
@@ -157,3 +179,20 @@ async function loadDefinition(root) {
157
179
  throw new Error(`${relative(root, file)} should export default defineSite({ ...blocks })`);
158
180
  return mod.default;
159
181
  }
182
+ /** Tells CalOS the declaration (every build; it keeps the latest). A refusal is said, not fatal: the build has its content. */
183
+ async function pushSchema(contentUrl, schemaUrl, token, schema, log) {
184
+ const url = schemaUrl ?? contentUrl.replace(/\/content(\?.*)?$/, "/schema");
185
+ try {
186
+ const res = await fetch(url, {
187
+ method: "POST",
188
+ headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) },
189
+ body: JSON.stringify(schema),
190
+ signal: AbortSignal.timeout(30_000),
191
+ });
192
+ if (!res.ok)
193
+ log(`site-kit: warning: CalOS didn't take the site's schema (${url}): ${res.status}`);
194
+ }
195
+ catch (err) {
196
+ log(`site-kit: warning: couldn't tell CalOS the site's schema (${url}): ${err.message}`);
197
+ }
198
+ }
@@ -0,0 +1,23 @@
1
+ export type Path = (string | number)[];
2
+ /** Words as they read on the page: Markdown's marks gone, a link's address gone, spacing and case evened. */
3
+ export declare function readable(text: string): string;
4
+ /** Every string in `value`, with where it is. */
5
+ export declare function textsIn(value: unknown, base?: Path): {
6
+ path: Path;
7
+ key: string;
8
+ text: string;
9
+ }[];
10
+ /**
11
+ * The field whose words best match what was clicked: the same words first; then the longest field
12
+ * the clicked element's words contain, if it's a fair share of them (a card holding a title and a
13
+ * line picks the longer); then the field that contains the clicked words (a bold phrase inside a
14
+ * paragraph).
15
+ */
16
+ export declare function fieldForText(value: unknown, base: Path, clicked: string, loose?: boolean): Path | null;
17
+ /** The field naming the picture shown at `url`: its content path, by the prepared images' addresses. */
18
+ export declare function fieldForImage(value: unknown, base: Path, url: string, images: Record<string, {
19
+ src: string;
20
+ srcset?: {
21
+ url: string;
22
+ }[];
23
+ }>): Path | null;
@@ -0,0 +1,78 @@
1
+ // Which field of the content a click in CalOS's preview landed on, with no markers in the site's
2
+ // components: the words clicked are matched against the content's text, a picture by its address.
3
+ // Pure, so it's tested here and runs in the preview in the browser.
4
+ /** Keys that hold no words a visitor reads: never what a click on words means. */
5
+ const SKIP = new Set(["type", "id", "href", "path", "style", "menuStyle", "footer", "ratio", "tone"]);
6
+ /** Words as they read on the page: Markdown's marks gone, a link's address gone, spacing and case evened. */
7
+ export function readable(text) {
8
+ return text
9
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
10
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
11
+ .replace(/^#{1,6}\s+/gm, "")
12
+ .replace(/^\s*(?:[-*+]|\d+\.)\s+/gm, "")
13
+ .replace(/[*_`>~]/g, "")
14
+ .replace(/[‘’]/g, "'")
15
+ .replace(/[“”]/g, '"')
16
+ .replace(/\s+/g, " ")
17
+ .trim()
18
+ .toLowerCase();
19
+ }
20
+ /** Every string in `value`, with where it is. */
21
+ export function textsIn(value, base = []) {
22
+ const out = [];
23
+ const visit = (v, path) => {
24
+ const key = String(path.filter((p) => typeof p === "string").at(-1) ?? "");
25
+ if (typeof v === "string") {
26
+ if (!SKIP.has(key) && v.trim())
27
+ out.push({ path, key, text: v });
28
+ }
29
+ else if (Array.isArray(v))
30
+ v.forEach((x, i) => visit(x, [...path, i]));
31
+ else if (v && typeof v === "object")
32
+ for (const [k, x] of Object.entries(v))
33
+ visit(x, [...path, k]);
34
+ };
35
+ visit(value, base);
36
+ return out;
37
+ }
38
+ /**
39
+ * The field whose words best match what was clicked: the same words first; then the longest field
40
+ * the clicked element's words contain, if it's a fair share of them (a card holding a title and a
41
+ * line picks the longer); then the field that contains the clicked words (a bold phrase inside a
42
+ * paragraph).
43
+ */
44
+ export function fieldForText(value, base, clicked, loose = true) {
45
+ const want = readable(clicked);
46
+ if (!want)
47
+ return null;
48
+ const all = textsIn(value, base)
49
+ .filter((t) => !t.key.endsWith("src") && !/^https?:|^\//.test(t.text))
50
+ .map((t) => ({ ...t, words: readable(t.text) }))
51
+ .filter((t) => t.words);
52
+ const same = all.find((t) => t.words === want);
53
+ if (same)
54
+ return same.path;
55
+ // Only a field that's a fair share of what was clicked: a container holding a whole section's
56
+ // words would otherwise match whichever field is longest in it.
57
+ const inside = all.filter((t) => want.includes(t.words) && t.words.length >= want.length * 0.4).sort((a, b) => b.words.length - a.words.length)[0];
58
+ if (inside)
59
+ return inside.path;
60
+ // Without `loose`, only those: a field elsewhere with exactly these words is a better match than one here that merely holds them.
61
+ if (!loose)
62
+ return null;
63
+ const around = all.filter((t) => t.words.includes(want)).sort((a, b) => a.words.length - b.words.length)[0];
64
+ return around?.path ?? null;
65
+ }
66
+ /** The field naming the picture shown at `url`: its content path, by the prepared images' addresses. */
67
+ export function fieldForImage(value, base, url, images) {
68
+ const clean = (u) => u.replace(/^https?:\/\/[^/]+/, "").split("?")[0];
69
+ const shown = clean(url);
70
+ const names = Object.entries(images).filter(([, m]) => clean(m.src) === shown || m.srcset?.some((s) => clean(s.url) === shown)).map(([name]) => name);
71
+ if (!names.length)
72
+ return null;
73
+ const hit = textsIn(value, base).find((t) => t.key.endsWith("src") && names.includes(t.text));
74
+ if (!hit)
75
+ return null;
76
+ // The picture's group (its src and alt together) is what's edited.
77
+ return hit.path.slice(0, -1).length > base.length ? hit.path.slice(0, -1) : hit.path;
78
+ }
@@ -0,0 +1,60 @@
1
+ export type Schema = {
2
+ type?: string | string[];
3
+ properties?: Record<string, Schema>;
4
+ required?: string[];
5
+ items?: Schema;
6
+ anyOf?: Schema[];
7
+ oneOf?: Schema[];
8
+ enum?: unknown[];
9
+ const?: unknown;
10
+ default?: unknown;
11
+ pattern?: string;
12
+ format?: string;
13
+ minLength?: number;
14
+ maxLength?: number;
15
+ minimum?: number;
16
+ maximum?: number;
17
+ minItems?: number;
18
+ maxItems?: number;
19
+ title?: string;
20
+ description?: string;
21
+ /** How an editor draws it: "textarea", "markdown", "image", "image-file". */
22
+ "x-ui"?: string;
23
+ /** What to say when it's wrong, where the pattern's own words wouldn't help. */
24
+ "x-error"?: string;
25
+ $ref?: string;
26
+ $defs?: Record<string, Schema>;
27
+ };
28
+ export type Issue = {
29
+ path: (string | number)[];
30
+ message: string;
31
+ };
32
+ /** Follows a `$ref` to its definition in the same document. */
33
+ export declare function resolve(s: Schema, root: Schema): Schema;
34
+ /** A union of objects told apart by a `type` field: its branches by that field's value; else null. */
35
+ export declare function branches(s: Schema, root: Schema): Record<string, Schema> | null;
36
+ /** The kinds of block (or any discriminated list item) a list's items can be, in the order the site gave them. */
37
+ export declare function kindsOf(items: Schema, root: Schema): string[];
38
+ /** What's wrong with `value` against `schema`, as places and plain messages. */
39
+ export declare function validate(schema: Schema, value: unknown, root?: Schema, path?: (string | number)[]): Issue[];
40
+ /** A blank value for a schema: what a new list entry or block starts as, with what's needed in place. */
41
+ export declare function blank(schema: Schema, root?: Schema, kindOfBlock?: string): unknown;
42
+ export type StructureIssue = Issue;
43
+ /**
44
+ * What's wrong with a site's content that no field's shape can say: two pages at one path, no home
45
+ * page, a link to a page that isn't there or to a part of a page that isn't. Pure, on the content as
46
+ * data, so CalOS can run the same check on a draft as the build does.
47
+ */
48
+ export declare function checkStructure(site: {
49
+ pages: {
50
+ path: string;
51
+ blocks: {
52
+ id?: unknown;
53
+ }[];
54
+ }[];
55
+ [extra: string]: unknown;
56
+ }): StructureIssue[];
57
+ /** "pages[2] (/services/) › blocks[1] (services) › items[0].title", readable in a build log or an editor. */
58
+ export declare function describePath(raw: unknown, path: PropertyKey[]): string;
59
+ /** The last named field in a path, for a short label. */
60
+ export declare const fieldOf: (path: (string | number)[]) => string | undefined;