@caltho/site-kit 0.2.4 → 0.2.6

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/README.md CHANGED
@@ -58,7 +58,7 @@ the kit only checks it's a URL.
58
58
  | **Content** (`@caltho/site-kit/content`) | `defineSite` and the pieces to build blocks from (`text`, `md`, `image`, `link`, `href`, `business`, …); the checks, including that every link lands on a page and every `#part` exists; the JSON Schema. |
59
59
  | **prepare** | Loads the content from CalOS (`CALOS_CONTENT_URL`, `CALOS_SITE_TOKEN`; the site's own while nothing's published there) or `content/site.json`, sends CalOS the schema, checks it, writes the schema, and makes every picture the content names as WebP at the widths screens need plus a 1200×630 share crop, named by content so they cache for a year. |
60
60
  | **finish** | After the framework's build: a content security policy in every page (each inline script named by hash), `_headers` (security headers, long caching, `noindex` on preview addresses), and `_redirects` from CalOS (`CALOS_REDIRECTS_URL`; a build that can't reach CalOS fails rather than lose them). |
61
- | **check** | Every page in a real browser through axe (WCAG 2.0–2.2 A and AA, best practice) on a computer and a phone; fails on any violation, on anything the policy blocks, and on anything that doesn't load. |
61
+ | **check** | Every page in a real browser on a computer and a phone: fails on anything the policy blocks and anything that doesn't load. What axe finds (WCAG 2.0–2.2 A and AA, best practice) is said with where, for the site to weigh (a colour the business chose over the contrast rule is their choice); `--strict` fails on it too, `--allow=rule,rule` leaves named rules out. |
62
62
  | **Search** | Titles that fit, canonical addresses, the business as schema.org data (hours, areas, address), FAQ and breadcrumb data, robots.txt, the sitemap. |
63
63
  | **Policies** | Privacy, accessibility and terms, written from the business's facts and what the site does. Optional: a site calls `policy()` where it wants them. |
64
64
  | **Helpers for components** (`@caltho/site-kit/lib`) | The pieces that touch no files: `md` (Markdown that can't run HTML), `plain`, `policy`, the search data, `imagesIn`, `clock`, `dayRange`, `telHref`. |
@@ -1,4 +1,5 @@
1
1
  export declare function check(dir: string, o?: {
2
- allow?: string[];
2
+ allow?: string[]; /** What axe finds fails the check too. */
3
+ strict?: boolean;
3
4
  log?: (line: string) => void;
4
5
  }): Promise<boolean>;
package/dist/cli/check.js CHANGED
@@ -1,7 +1,9 @@
1
1
  // Checks a built site in a real browser before it's published: every page in its sitemap and the
2
- // 404 page, on a computer screen and a phone, with axe (WCAG 2.2 AA and best practice), and with
3
- // nothing blocked by the site's own content security policy or failing to load. Says what's wrong
4
- // and where; true when nothing is.
2
+ // 404 page, on a computer screen and a phone, with nothing blocked by the site's own content
3
+ // security policy or failing to load, and with axe (WCAG 2.2 AA and best practice). Something
4
+ // broken fails the check. What axe finds is said, with where, but is the site's to weigh (a colour
5
+ // the business chose over the contrast rule is their choice): it fails the check only with
6
+ // `strict`. True when nothing is broken (and, strict, nothing is found).
5
7
  var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
6
8
  if (typeof path === "string" && /^\.\.?\//.test(path)) {
7
9
  return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
@@ -28,6 +30,7 @@ export async function check(dir, o = {}) {
28
30
  paths.push("/404.html");
29
31
  const server = await serve(dir, port);
30
32
  const problems = [];
33
+ const found = [];
31
34
  const browser = await chromium.launch(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}).catch((err) => {
32
35
  server.close();
33
36
  throw err;
@@ -58,7 +61,7 @@ export async function check(dir, o = {}) {
58
61
  window.axe.run(document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "best-practice"] } })));
59
62
  for (const v of result.violations.filter((x) => !allow.has(x.id))) {
60
63
  const targets = v.nodes.slice(0, 3).map((n) => n.target.join(" "));
61
- problems.push(`${path} (${screen}): ${v.id}: ${v.help} [${v.impact}] at ${targets.join(", ")}${v.nodes.length > 3 ? ` and ${v.nodes.length - 3} more` : ""}`);
64
+ found.push(`${path} (${screen}): ${v.id}: ${v.help} [${v.impact}] at ${targets.join(", ")}${v.nodes.length > 3 ? ` and ${v.nodes.length - 3} more` : ""}`);
62
65
  }
63
66
  }
64
67
  await context.close();
@@ -68,10 +71,14 @@ export async function check(dir, o = {}) {
68
71
  await browser.close();
69
72
  server.close();
70
73
  }
71
- if (problems.length) {
72
- log(`✗ ${dir}: ${problems.length} problem${problems.length === 1 ? "" : "s"}\n - ${[...new Set(problems)].join("\n - ")}`);
73
- return false;
74
- }
75
- log(`✓ ${dir}: ${paths.length} pages, on a computer and a phone: no accessibility problems axe can find${allow.size ? ` (except the ones allowed: ${[...allow].join(", ")})` : ""}, nothing blocked or broken`);
76
- return true;
74
+ const list = (lines) => `\n - ${[...new Set(lines)].join("\n - ")}`;
75
+ if (problems.length)
76
+ log(`✗ ${dir}: ${problems.length} problem${problems.length === 1 ? "" : "s"}${list(problems)}`);
77
+ else
78
+ log(`✓ ${dir}: ${paths.length} pages, on a computer and a phone: nothing blocked or broken`);
79
+ if (found.length)
80
+ log(`${o.strict ? "✗" : "•"} ${dir}: accessibility, ${found.length} thing${found.length === 1 ? "" : "s"} to look at${o.strict ? "" : " (the site's to weigh; --strict fails on them)"}${list(found)}`);
81
+ else
82
+ log(`✓ ${dir}: no accessibility problems axe can find${allow.size ? ` (except the ones allowed: ${[...allow].join(", ")})` : ""}`);
83
+ return !problems.length && (!o.strict || !found.length);
77
84
  }
package/dist/cli/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // site-kit prepare content checked, schema written, images ready for the build (.site/, public/)
3
3
  // site-kit finish [out] after the build: the content security policy, _headers, _redirects
4
4
  // site-kit serve [out] [port] the build served as Cloudflare Pages will
5
- // site-kit check [out] [--allow=rule,rule] every page in a real browser: axe, blocked or broken loads
5
+ // site-kit check [out] [--allow=rule,rule] [--strict] every page in a real browser: blocked or broken loads fail; what axe finds is said (fails only with --strict)
6
6
  import { resolve } from "node:path";
7
7
  import { finish } from "../finish.js";
8
8
  import { prepare } from "../prepare.js";
@@ -23,7 +23,7 @@ try {
23
23
  const { check } = await import("./check.js");
24
24
  const allow = args.filter((a) => a.startsWith("--allow=")).flatMap((a) => a.slice(8).split(","));
25
25
  const dir = args.find((a) => !a.startsWith("--")) ?? "out";
26
- process.exitCode = (await check(resolve(dir), { allow })) ? 0 : 1;
26
+ process.exitCode = (await check(resolve(dir), { allow, strict: args.includes("--strict") })) ? 0 : 1;
27
27
  }
28
28
  else {
29
29
  console.error("Usage: site-kit prepare | finish [out] | serve [out] [port] | check [out]");
@@ -1,6 +1,8 @@
1
1
  import type { ShareOptions } from "../share.ts";
2
2
  export type { CardNode, ShareCard, ShareFont, ShareOptions } from "../share.ts";
3
3
  import { z } from "zod";
4
+ import { type Look, type LookOptions } from "./look.ts";
5
+ export { contrast, lookCss, lookProblems, lookVars, type Look, type LookOptions } from "./look.ts";
4
6
  export { checkStructure, describePath, type StructureIssue } from "../schema.ts";
5
7
  export { z };
6
8
  export declare const text: z.ZodString;
@@ -26,6 +28,38 @@ export declare const link: z.ZodObject<{
26
28
  }, z.core.$strip>;
27
29
  export declare const time: z.ZodString;
28
30
  export declare const DAYS: readonly ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
31
+ /**
32
+ * How a section sits on the page, for a site that offers it (defineSite's `placement`): its alignment,
33
+ * width, spacing and the screens it shows on. The site's design says what each means at every width,
34
+ * so a choice here stays sensible on a phone. CalOS shows it as the section's Layout. (A block's own
35
+ * `layout`, a site's choice of how it's drawn, is a different thing and keeps its name.)
36
+ */
37
+ export declare const placement: z.ZodObject<{
38
+ align: z.ZodDefault<z.ZodEnum<{
39
+ left: "left";
40
+ center: "center";
41
+ }>>;
42
+ width: z.ZodDefault<z.ZodEnum<{
43
+ normal: "normal";
44
+ narrow: "narrow";
45
+ wide: "wide";
46
+ }>>;
47
+ spacing: z.ZodDefault<z.ZodEnum<{
48
+ normal: "normal";
49
+ compact: "compact";
50
+ roomy: "roomy";
51
+ }>>;
52
+ show: z.ZodDefault<z.ZodEnum<{
53
+ phone: "phone";
54
+ all: "all";
55
+ desktop: "desktop";
56
+ }>>;
57
+ }, z.core.$strip>;
58
+ export type Placement = z.output<typeof placement>;
59
+ /** A section's placement as class names, only what differs from the usual: "place-align-center place-width-narrow", or "". */
60
+ export declare function placementClass(block: {
61
+ placement?: Partial<Placement>;
62
+ }): string;
29
63
  /** The business's facts: the header, footer, hours, contact details and search data all come from here. */
30
64
  export declare const business: z.ZodObject<{
31
65
  name: z.ZodString;
@@ -134,6 +168,7 @@ export type SiteBase = {
134
168
  form?: string;
135
169
  menu?: Menu;
136
170
  footer?: Footer;
171
+ look?: Look;
137
172
  redirects: Redirect[]; /** Whether page paths end in a slash (the default). */
138
173
  trailingSlash?: boolean;
139
174
  };
@@ -159,6 +194,7 @@ export type BlocksOf<B extends Record<string, z.ZodObject>> = {
159
194
  [K in keyof B & string]: z.output<B[K]> & {
160
195
  type: K;
161
196
  id?: string;
197
+ placement?: Placement;
162
198
  };
163
199
  }[keyof B & string];
164
200
  /** A site's content with its own blocks typed: `ContentOf<typeof blocks> & { headerAction?: Link }`. */
@@ -180,6 +216,10 @@ export type DefineOptions = {
180
216
  refine?: (site: Site, ctx: z.RefinementCtx) => void;
181
217
  /** How each page's share card looks (colours, fonts, a logo, or a card of the site's own); `false` for none (../share.ts). */
182
218
  share?: ShareOptions;
219
+ /** Every section gets a Layout (placement: alignment, width, spacing, screens), drawn by the site through placementClass. */
220
+ placement?: boolean;
221
+ /** The site's Look, chosen in CalOS (fonts from the ones it carries, colours, corners, buttons), drawn through CSS variables (./look.ts). */
222
+ look?: LookOptions;
183
223
  };
184
224
  /**
185
225
  * A site's content shape from its blocks: each a name and the fields it has. The site's own block
@@ -201,6 +241,25 @@ export declare function defineSite<const B extends Record<string, z.ZodObject>>(
201
241
  shareText: z.ZodOptional<z.ZodString>;
202
242
  noindex: z.ZodDefault<z.ZodBoolean>;
203
243
  }, z.core.$strip>>;
244
+ look?: z.ZodOptional<z.ZodObject<{
245
+ brand: z.ZodDefault<z.ZodString>;
246
+ accent: z.ZodDefault<z.ZodString>;
247
+ corners: z.ZodDefault<z.ZodEnum<{
248
+ square: "square";
249
+ soft: "soft";
250
+ round: "round";
251
+ }>>;
252
+ buttons: z.ZodDefault<z.ZodEnum<{
253
+ solid: "solid";
254
+ outline: "outline";
255
+ }>>;
256
+ headingFont?: z.ZodDefault<z.ZodEnum<{
257
+ [x: string]: string;
258
+ }>> | undefined;
259
+ bodyFont?: z.ZodDefault<z.ZodEnum<{
260
+ [x: string]: string;
261
+ }>> | undefined;
262
+ }, z.core.$strip>> | undefined;
204
263
  url: z.ZodURL;
205
264
  lang: z.ZodDefault<z.ZodString>;
206
265
  business: z.ZodObject<{
@@ -299,6 +358,25 @@ export declare function defineSite<const B extends Record<string, z.ZodObject>>(
299
358
  shareText: z.ZodOptional<z.ZodString>;
300
359
  noindex: z.ZodDefault<z.ZodBoolean>;
301
360
  }, z.core.$strip>>;
361
+ look?: z.ZodOptional<z.ZodObject<{
362
+ brand: z.ZodDefault<z.ZodString>;
363
+ accent: z.ZodDefault<z.ZodString>;
364
+ corners: z.ZodDefault<z.ZodEnum<{
365
+ square: "square";
366
+ soft: "soft";
367
+ round: "round";
368
+ }>>;
369
+ buttons: z.ZodDefault<z.ZodEnum<{
370
+ solid: "solid";
371
+ outline: "outline";
372
+ }>>;
373
+ headingFont?: z.ZodDefault<z.ZodEnum<{
374
+ [x: string]: string;
375
+ }>> | undefined;
376
+ bodyFont?: z.ZodDefault<z.ZodEnum<{
377
+ [x: string]: string;
378
+ }>> | undefined;
379
+ }, z.core.$strip>> | undefined;
302
380
  url: z.ZodURL;
303
381
  lang: z.ZodDefault<z.ZodString>;
304
382
  business: z.ZodObject<{
@@ -392,6 +470,7 @@ export declare function defineSite<const B extends Record<string, z.ZodObject>>(
392
470
  trailingSlash: boolean;
393
471
  csp: Record<string, string[]>;
394
472
  share: ShareOptions | undefined;
473
+ look: LookOptions | undefined;
395
474
  /** The declaration CalOS draws its editing forms from. */
396
475
  jsonSchema: () => Record<string, unknown>;
397
476
  };
@@ -10,6 +10,8 @@
10
10
  // them with no further work.
11
11
  import { z } from "zod";
12
12
  import { checkStructure, describePath } from "../schema.js";
13
+ import { lookProblems, lookSchema } from "./look.js";
14
+ export { contrast, lookCss, lookProblems, lookVars } from "./look.js";
13
15
  export { checkStructure, describePath } from "../schema.js";
14
16
  export { z };
15
17
  // Fields carry an "x-ui" hint in the JSON Schema, so CalOS draws the right control: a text area, a
@@ -36,6 +38,29 @@ export const time = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, { error: "A ti
36
38
  export const DAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
37
39
  /** What every block has: a place on the page, for links like /#contact. */
38
40
  const blockBase = { id: z.string().regex(/^[a-z][\w-]*$/).optional() };
41
+ /**
42
+ * How a section sits on the page, for a site that offers it (defineSite's `placement`): its alignment,
43
+ * width, spacing and the screens it shows on. The site's design says what each means at every width,
44
+ * so a choice here stays sensible on a phone. CalOS shows it as the section's Layout. (A block's own
45
+ * `layout`, a site's choice of how it's drawn, is a different thing and keeps its name.)
46
+ */
47
+ export const placement = z
48
+ .object({
49
+ align: z.enum(["left", "center"]).default("left").meta({ title: "Alignment" }),
50
+ width: z.enum(["narrow", "normal", "wide"]).default("normal").meta({ title: "Width" }),
51
+ spacing: z.enum(["compact", "normal", "roomy"]).default("normal").meta({ title: "Spacing" }),
52
+ show: z.enum(["all", "desktop", "phone"]).default("all").meta({ title: "Show on" }),
53
+ })
54
+ .meta({ title: "Layout" });
55
+ /** A section's placement as class names, only what differs from the usual: "place-align-center place-width-narrow", or "". */
56
+ export function placementClass(block) {
57
+ const l = block.placement ?? {};
58
+ const usual = { align: "left", width: "normal", spacing: "normal", show: "all" };
59
+ return Object.keys(usual)
60
+ .filter((k) => l[k] && l[k] !== usual[k])
61
+ .map((k) => `place-${k}-${l[k]}`)
62
+ .join(" ");
63
+ }
39
64
  const address = z.object({
40
65
  street: text,
41
66
  locality: text,
@@ -111,7 +136,7 @@ export function defineSite(blocks, o = {}) {
111
136
  const union = z.discriminatedUnion("type",
112
137
  // Each kind keeps what the site said of it (its title, for CalOS to call it by).
113
138
  names.map((type) => {
114
- const kind = z.object({ type: z.literal(type), ...blockBase }).extend(blocks[type].shape);
139
+ const kind = z.object({ type: z.literal(type), ...blockBase, ...(o.placement ? { placement: placement.optional() } : {}) }).extend(blocks[type].shape);
115
140
  const meta = blocks[type].meta();
116
141
  return meta ? kind.meta(meta) : kind;
117
142
  }));
@@ -124,7 +149,7 @@ export function defineSite(blocks, o = {}) {
124
149
  lang: z.string().default("en-US").meta({ title: "Language" }),
125
150
  business,
126
151
  /** Where the contact forms post to, if the site has any (a CalOS form's address, or another service's). */
127
- form: z.url({ protocol: /^https?$/ }).meta({ title: "Where its forms send" }).optional(),
152
+ form: z.url({ protocol: /^https?$/ }).meta({ title: "Where its forms send", "x-ui": "form" }).optional(),
128
153
  /** The header's links in order, and its button: edited as one list in CalOS. Left out, the pages' menu labels are used. */
129
154
  menu: z
130
155
  .object({
@@ -144,6 +169,7 @@ export function defineSite(blocks, o = {}) {
144
169
  .meta({ title: "Footer" }),
145
170
  /** Old addresses kept answering, when CalOS doesn't supply them at build. */
146
171
  redirects: z.array(z.object({ from: z.string().startsWith("/"), to: href, status: z.union([z.literal(301), z.literal(302)]).default(301) })).default([]),
172
+ ...(o.look ? { look: lookSchema(o.look) } : {}),
147
173
  ...o.extend,
148
174
  pages: z.array(page).min(1),
149
175
  })
@@ -151,6 +177,9 @@ export function defineSite(blocks, o = {}) {
151
177
  const s = site;
152
178
  for (const i of checkStructure(s))
153
179
  ctx.addIssue({ code: "custom", path: i.path, message: i.message });
180
+ if (o.look)
181
+ for (const i of lookProblems(s.look, o.look))
182
+ ctx.addIssue({ code: "custom", path: i.path, message: i.message });
154
183
  o.refine?.(s, ctx);
155
184
  });
156
185
  return {
@@ -165,6 +194,7 @@ export function defineSite(blocks, o = {}) {
165
194
  trailingSlash,
166
195
  csp: o.csp ?? {},
167
196
  share: o.share,
197
+ look: o.look,
168
198
  /** The declaration CalOS draws its editing forms from. */
169
199
  jsonSchema: () => z.toJSONSchema(schema, { io: "input", unrepresentable: "any" }),
170
200
  };
@@ -0,0 +1,59 @@
1
+ import { z } from "zod";
2
+ export type LookOptions = {
3
+ /** The fonts the site carries, by the name CalOS shows → the font-family it's drawn with. */
4
+ fonts?: Record<string, string>;
5
+ /** Which of them the site's headings and text are in, until others are chosen; else the first. */
6
+ headingFont?: string;
7
+ bodyFont?: string;
8
+ /** The site's own colours, used until others are chosen. */
9
+ brand: string;
10
+ accent?: string;
11
+ /** The page's background, which a brand colour must read against. */
12
+ background?: string;
13
+ corners?: Corners;
14
+ buttons?: Buttons;
15
+ };
16
+ declare const CORNERS: readonly ["square", "soft", "round"];
17
+ declare const BUTTONS: readonly ["solid", "outline"];
18
+ type Corners = (typeof CORNERS)[number];
19
+ type Buttons = (typeof BUTTONS)[number];
20
+ /** The Look's fields, for the site's content. */
21
+ export declare function lookSchema(o: LookOptions): z.ZodOptional<z.ZodObject<{
22
+ brand: z.ZodDefault<z.ZodString>;
23
+ accent: z.ZodDefault<z.ZodString>;
24
+ corners: z.ZodDefault<z.ZodEnum<{
25
+ square: "square";
26
+ soft: "soft";
27
+ round: "round";
28
+ }>>;
29
+ buttons: z.ZodDefault<z.ZodEnum<{
30
+ solid: "solid";
31
+ outline: "outline";
32
+ }>>;
33
+ headingFont?: z.ZodDefault<z.ZodEnum<{
34
+ [x: string]: string;
35
+ }>> | undefined;
36
+ bodyFont?: z.ZodDefault<z.ZodEnum<{
37
+ [x: string]: string;
38
+ }>> | undefined;
39
+ }, z.core.$strip>>;
40
+ export type Look = {
41
+ headingFont?: string;
42
+ bodyFont?: string;
43
+ brand?: string;
44
+ accent?: string;
45
+ corners?: Corners;
46
+ buttons?: Buttons;
47
+ };
48
+ /** How readable one colour is on another (WCAG): 1 to 21. */
49
+ export declare function contrast(a: string, b: string): number;
50
+ /** The colours chosen that won't read: the brand colour is used for text and buttons on the page, so it must stand out from it. */
51
+ export declare function lookProblems(look: Look | undefined, o: LookOptions): {
52
+ path: string[];
53
+ message: string;
54
+ }[];
55
+ /** The Look as CSS variables: what's chosen, else the site's own. */
56
+ export declare function lookVars(look: Look | undefined, o: LookOptions): Record<string, string>;
57
+ /** The Look as a stylesheet for the page's head: `:root { … }`. */
58
+ export declare function lookCss(look: Look | undefined, o: LookOptions): string;
59
+ export {};
@@ -0,0 +1,87 @@
1
+ // A site's Look, chosen in CalOS: its fonts (from the ones the site carries), its brand and accent
2
+ // colours, its corners and its buttons. The site says what may be chosen and its own defaults
3
+ // (defineSite's `look`); what's chosen reaches its CSS as variables, which its design reads:
4
+ //
5
+ // --look-heading-font, --look-body-font font-family stacks
6
+ // --look-brand, --look-on-brand the brand colour, and the text that reads on it
7
+ // --look-accent the accent colour
8
+ // --look-radius, --look-button-radius corners: of cards and pictures, and of buttons
9
+ // --look-button-bg, --look-button-fg, --look-button-border
10
+ // --look-button-fill 1 filled, 0 outlined
11
+ //
12
+ // Published, they're a <style> in the page (LookStyle, allowed by its hash); in CalOS's preview they're
13
+ // set on the page as the draft changes (allowed: a style set from script isn't a style attribute).
14
+ import { z } from "zod";
15
+ const CORNERS = ["square", "soft", "round"];
16
+ const BUTTONS = ["solid", "outline"];
17
+ const HEX = /^#[0-9a-f]{6}$/i;
18
+ const color = z.string().regex(HEX, { error: "A colour like #2b5f8a" }).meta({ "x-ui": "color", "x-error": "A colour like #2b5f8a" });
19
+ /** The Look's fields, for the site's content. */
20
+ export function lookSchema(o) {
21
+ const fonts = Object.keys(o.fonts ?? {});
22
+ // Each starts as the site's own, so CalOS shows what the site looks like now.
23
+ const font = (title, own) => z.enum(fonts).default(own && fonts.includes(own) ? own : fonts[0]).meta({ title });
24
+ return z
25
+ .object({
26
+ ...(fonts.length ? { headingFont: font("Heading font", o.headingFont), bodyFont: font("Text font", o.bodyFont) } : {}),
27
+ brand: color.default(o.brand).meta({ title: "Brand colour" }),
28
+ accent: color.default(o.accent ?? o.brand).meta({ title: "Accent colour" }),
29
+ corners: z.enum(CORNERS).default(o.corners ?? "soft").meta({ title: "Corners" }),
30
+ buttons: z.enum(BUTTONS).default(o.buttons ?? "solid").meta({ title: "Buttons" }),
31
+ })
32
+ .optional()
33
+ .meta({ title: "Look" });
34
+ }
35
+ /** How readable one colour is on another (WCAG): 1 to 21. */
36
+ export function contrast(a, b) {
37
+ const lum = (hex) => {
38
+ const [r, g, bl] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
39
+ return 0.2126 * r + 0.7152 * g + 0.0722 * bl;
40
+ };
41
+ const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m);
42
+ return (x + 0.05) / (y + 0.05);
43
+ }
44
+ /** The colours chosen that won't read: the brand colour is used for text and buttons on the page, so it must stand out from it. */
45
+ export function lookProblems(look, o) {
46
+ const bg = o.background ?? "#ffffff";
47
+ if (look?.brand && HEX.test(look.brand) && HEX.test(bg) && contrast(look.brand, bg) < 3)
48
+ return [{ path: ["look", "brand"], message: "Too close to the page's background to read: choose a darker or stronger colour" }];
49
+ return [];
50
+ }
51
+ const RADIUS = { square: ["0", "0"], soft: ["8px", "8px"], round: ["16px", "999px"] };
52
+ /** The Look as CSS variables: what's chosen, else the site's own. */
53
+ export function lookVars(look, o) {
54
+ const l = look ?? {};
55
+ const fonts = o.fonts ?? {};
56
+ const first = Object.values(fonts)[0];
57
+ const own = (name) => (name && fonts[name]) || first;
58
+ const brand = l.brand && HEX.test(l.brand) ? l.brand : o.brand;
59
+ const onBrand = HEX.test(brand) && contrast(brand, "#ffffff") >= contrast(brand, "#111111") ? "#ffffff" : "#111111";
60
+ const [radius, buttonRadius] = RADIUS[l.corners ?? o.corners ?? "soft"];
61
+ const outline = (l.buttons ?? o.buttons ?? "solid") === "outline";
62
+ const vars = {
63
+ "--look-brand": brand,
64
+ "--look-on-brand": onBrand,
65
+ "--look-accent": (l.accent && HEX.test(l.accent) ? l.accent : o.accent) ?? brand,
66
+ "--look-radius": radius,
67
+ "--look-button-radius": buttonRadius,
68
+ "--look-button-bg": outline ? "transparent" : brand,
69
+ "--look-button-fg": outline ? brand : onBrand,
70
+ "--look-button-border": brand,
71
+ /** 1 for a filled button, 0 for an outlined one: for a design that mixes its own colours by it. */
72
+ "--look-button-fill": outline ? "0" : "1",
73
+ };
74
+ const heading = (l.headingFont && fonts[l.headingFont]) || own(o.headingFont);
75
+ const body = (l.bodyFont && fonts[l.bodyFont]) || own(o.bodyFont);
76
+ if (heading)
77
+ vars["--look-heading-font"] = heading;
78
+ if (body)
79
+ vars["--look-body-font"] = body;
80
+ return vars;
81
+ }
82
+ /** The Look as a stylesheet for the page's head: `:root { … }`. */
83
+ export function lookCss(look, o) {
84
+ return `:root{${Object.entries(lookVars(look, o))
85
+ .map(([k, v]) => `${k}:${v.replace(/[<>{};]/g, "")}`)
86
+ .join(";")}}`;
87
+ }
@@ -2,4 +2,5 @@ export { image, pageAt, prepared, site } from "./data.ts";
2
2
  export { JsonLd } from "./JsonLd.tsx";
3
3
  export { pageMetadata, robots, sitemap, staticParams } from "./metadata.ts";
4
4
  export { Img, type Ratio } from "./parts/Img.tsx";
5
+ export { LookStyle } from "./parts/LookStyle.tsx";
5
6
  export { Md } from "./parts/Md.tsx";
@@ -5,4 +5,5 @@ export { image, pageAt, prepared, site } from "./data.js";
5
5
  export { JsonLd } from "./JsonLd.js";
6
6
  export { pageMetadata, robots, sitemap, staticParams } from "./metadata.js";
7
7
  export { Img } from "./parts/Img.js";
8
+ export { LookStyle } from "./parts/LookStyle.js";
8
9
  export { Md } from "./parts/Md.js";
@@ -0,0 +1,5 @@
1
+ import { type LookOptions } from "../../content/look.ts";
2
+ /** The site's Look as CSS variables, for its layout's <head> (content/look.ts). */
3
+ export declare function LookStyle({ look }: {
4
+ look: LookOptions;
5
+ }): import("react").JSX.Element;
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { lookCss } from "../../content/look.js";
3
+ import { prepared } from "../data.js";
4
+ /** The site's Look as CSS variables, for its layout's <head> (content/look.ts). */
5
+ export function LookStyle({ look }) {
6
+ return _jsx("style", { dangerouslySetInnerHTML: { __html: lookCss(prepared().site.look, look) } });
7
+ }
@@ -1,6 +1,9 @@
1
1
  import { type ReactNode } from "react";
2
2
  import type { Page, Site } from "../content/index.ts";
3
- export declare function SitePreview({ origins, render }: {
3
+ import { type LookOptions } from "../content/look.ts";
4
+ /** `look`: the same Look options the site's defineSite has, for the draft's Look to be drawn as it's chosen. */
5
+ export declare function SitePreview({ origins, render, look }: {
4
6
  origins: string[];
5
7
  render: (page: Page, site: Site) => ReactNode;
8
+ look?: LookOptions;
6
9
  }): import("react").JSX.Element | null;
@@ -7,12 +7,14 @@ import { jsx as _jsx } from "react/jsx-runtime";
7
7
  // them: its caltho.app address and its own domain, from the build) are listened to.
8
8
  //
9
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 },
10
+ // page → CalOS: { type: "calos:ready" }, { type: "calos:pick", path, block },
11
11
  // { type: "calos:error", message }
12
12
  import { Component, useEffect, useRef, useState } from "react";
13
+ import { lookVars } from "../content/look.js";
13
14
  import { BUILT_IMAGES, fieldForImage, fieldForText } from "../preview-match.js";
14
15
  import { setPrepared } from "./data.js";
15
- export function SitePreview({ origins, render }) {
16
+ /** `look`: the same Look options the site's defineSite has, for the draft's Look to be drawn as it's chosen. */
17
+ export function SitePreview({ origins, render, look }) {
16
18
  const [shown, setShown] = useState(null);
17
19
  const [round, setRound] = useState(0);
18
20
  const latest = useRef(null);
@@ -66,14 +68,7 @@ export function SitePreview({ origins, render }) {
66
68
  return;
67
69
  e.preventDefault();
68
70
  e.stopPropagation();
69
- const main = document.querySelector("main");
70
- const link = target.closest("a");
71
- if (link && !main?.contains(link)) {
72
- const to = internalPath(link.getAttribute("href"), m.site);
73
- if (to)
74
- post({ type: "calos:go", path: to });
75
- return;
76
- }
71
+ // A link anywhere (a section's, the menu's, the footer's) is edited, never followed: CalOS's page picker moves between pages.
77
72
  // A picture under the pointer counts, even when something over it (a frame, a caption's box) took the click.
78
73
  const under = document.elementsFromPoint(e.clientX, e.clientY).find((el) => el.tagName === "IMG") ?? target;
79
74
  post({ type: "calos:pick", ...pick(under, m, wordsAt(e.clientX, e.clientY)) });
@@ -138,6 +133,13 @@ export function SitePreview({ origins, render }) {
138
133
  window.removeEventListener("resize", keep);
139
134
  };
140
135
  }, [shown, round]);
136
+ // The draft's Look, set on the page (LookStyle drew the published one into the build).
137
+ useEffect(() => {
138
+ if (!shown || !look)
139
+ return;
140
+ for (const [k, v] of Object.entries(lookVars(shown.site.look, look)))
141
+ document.documentElement.style.setProperty(k, v);
142
+ }, [shown, look]);
141
143
  if (!shown)
142
144
  return null;
143
145
  const page = shown.site.pages.find((p) => p.path === shown.path) ?? shown.site.pages[0];
@@ -169,17 +171,6 @@ function asBuilt(images) {
169
171
  }
170
172
  return out;
171
173
  }
172
- /** A path on this site from a link's address, as the content writes it; null for anywhere else. */
173
- function internalPath(href, site) {
174
- if (!href || href.startsWith("#") || /^(mailto|tel):/.test(href))
175
- return null;
176
- const url = new URL(href, location.href);
177
- if (url.origin !== location.origin && url.origin !== new URL(site.url).origin)
178
- return null;
179
- const bare = url.pathname.replace(/\/$/, "") || "/";
180
- const withSlash = bare === "/" ? "/" : `${bare}/`;
181
- return site.pages.some((p) => p.path === bare) ? bare : site.pages.some((p) => p.path === withSlash) ? withSlash : null;
182
- }
183
174
  const pageIndex = (m) => m.site.pages.findIndex((p) => p.path === m.path);
184
175
  /** What's in `main` without being drawn: a page's structured data (JSON-LD) and the like. */
185
176
  const UNDRAWN = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "LINK", "META"]);
@@ -226,7 +217,9 @@ function pick(target, m, words = null) {
226
217
  return { path: found, block: blockOf(found) ?? block };
227
218
  }
228
219
  if (words) {
229
- const exact = (scope && fieldForText(scope, block, words, false)) ?? fieldForText(shared, [], words, false) ?? fieldForText(pageOwn, ["pages", pi], words, false);
220
+ // Outside a section, a menu made from the pages' menu labels is any page's label.
221
+ const labels = { pages: m.site.pages.map((p) => ({ menu: p.menu })) };
222
+ const exact = (scope && fieldForText(scope, block, words, false)) ?? fieldForText(shared, [], words, false) ?? fieldForText(pageOwn, ["pages", pi], words, false) ?? (n < 0 ? fieldForText(labels, [], words, false) : null);
230
223
  if (exact)
231
224
  return { path: exact, block: blockOf(exact) ?? block };
232
225
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caltho/site-kit",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
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",