@caltho/site-kit 0.2.5 → 0.2.7

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
@@ -57,8 +57,8 @@ the kit only checks it's a URL.
57
57
  | --- | --- |
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
- | **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. |
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). Where CalOS counts the site's visits (its content answer says where they go), the analytics script in every published page but the preview, with its addresses in the policy, and phone and map links marked so a tap is counted (`phone_tap`, `directions_tap`; a link marked `data-track` already is left as it is). No cookies. |
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]");
@@ -149,7 +149,7 @@ export function defineSite(blocks, o = {}) {
149
149
  lang: z.string().default("en-US").meta({ title: "Language" }),
150
150
  business,
151
151
  /** Where the contact forms post to, if the site has any (a CalOS form's address, or another service's). */
152
- 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(),
153
153
  /** The header's links in order, and its button: edited as one list in CalOS. Left out, the pages' menu labels are used. */
154
154
  menu: z
155
155
  .object({
package/dist/finish.d.ts CHANGED
@@ -1,3 +1,10 @@
1
+ import type { Analytics } from "./prepare.ts";
1
2
  type Log = (line: string) => void;
2
3
  export declare function finish(root: string, outDir: string, env?: Record<string, string | undefined>, log?: Log): Promise<void>;
4
+ /**
5
+ * A page with its visits counted: the analytics script, queued before it loads (its pages, the links
6
+ * out, and what's marked to count), and each phone link and map link marked: a call or directions
7
+ * tapped is what a business wants to know came from the site. No cookies: nothing to consent to.
8
+ */
9
+ export declare function counted(html: string, a: Analytics): string;
3
10
  export {};
package/dist/finish.js CHANGED
@@ -1,7 +1,9 @@
1
1
  // What a site's build output needs after the framework is done: a content security policy in every
2
2
  // page, naming by hash each inline script and style the page carries (Next.js writes its page data
3
3
  // as inline scripts; a static page can't carry a per-request nonce), and Cloudflare Pages' `_headers`
4
- // and `_redirects` beside the pages.
4
+ // and `_redirects` beside the pages. Where CalOS counts its visits, every published page (not the
5
+ // preview) gets the script that sends them, and its phone and map links are marked, so a tap on one
6
+ // is counted too.
5
7
  import { createHash } from "node:crypto";
6
8
  import { readdir, readFile, writeFile } from "node:fs/promises";
7
9
  import { join, relative } from "node:path";
@@ -16,6 +18,12 @@ export async function finish(root, outDir, env = process.env, log = console.log)
16
18
  const attributes = [];
17
19
  for (const file of pages) {
18
20
  let html = await readFile(file, "utf8");
21
+ // The preview shows the draft's pictures from CalOS, before any build has made them; those this
22
+ // build made, it shows as built (./next/preview.tsx), from the list it carries.
23
+ const isPreview = relative(outDir, file).replace(/(\/index)?\.html$/, "") === PREVIEW_PATH.slice(1);
24
+ // Before the hashing: its own little script is named in the policy like any other.
25
+ if (prepared.analytics && !isPreview)
26
+ html = counted(html, prepared.analytics);
19
27
  const scripts = new Set();
20
28
  const styles = new Set();
21
29
  for (const m of html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) {
@@ -31,21 +39,18 @@ export async function finish(root, outDir, env = process.env, log = console.log)
31
39
  attributes.push(relative(outDir, file));
32
40
  const directives = {
33
41
  "default-src": ["'self'"],
34
- "script-src": ["'self'", ...[...scripts].map((h) => `'${h}'`)],
42
+ "script-src": ["'self'", ...[...scripts].map((h) => `'${h}'`), ...(prepared.analytics && !isPreview ? [new URL(prepared.analytics.script).origin] : [])],
35
43
  "style-src": ["'self'", ...[...styles].map((h) => `'${h}'`)],
36
44
  "img-src": ["'self'", "data:"],
37
45
  "font-src": ["'self'"],
38
46
  // A form sends with fetch as often as by posting the page: its address for both.
39
- "connect-src": ["'self'", ...(formOrigin ? [formOrigin] : [])],
47
+ "connect-src": ["'self'", ...(formOrigin ? [formOrigin] : []), ...(prepared.analytics && !isPreview ? [new URL(prepared.analytics.api).origin] : [])],
40
48
  "form-action": ["'self'", ...(formOrigin ? [formOrigin] : [])],
41
49
  "base-uri": ["'self'"],
42
50
  "object-src": ["'none'"],
43
51
  };
44
52
  for (const [directive, sources] of Object.entries(prepared.csp ?? {}))
45
53
  directives[directive] = [...new Set([...(directives[directive] ?? ["'self'"]), ...sources])];
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
54
  if (isPreview) {
50
55
  directives["img-src"].push(...editors);
51
56
  const built = JSON.stringify(prepared.images ?? {}).replace(/</g, "\\u003c");
@@ -62,7 +67,24 @@ export async function finish(root, outDir, env = process.env, log = console.log)
62
67
  await writeFile(join(outDir, "_headers"), headersFile(editors));
63
68
  const redirects = await loadRedirects(site, env.CALOS_REDIRECTS_URL);
64
69
  await writeFile(join(outDir, "_redirects"), redirects.file);
65
- log(`site-kit: a content security policy in ${pages.length} pages; _headers; _redirects from ${redirects.from}`);
70
+ log(`site-kit: a content security policy in ${pages.length} pages; _headers; _redirects from ${redirects.from}${prepared.analytics ? "; visits counted" : ""}`);
71
+ }
72
+ /**
73
+ * A page with its visits counted: the analytics script, queued before it loads (its pages, the links
74
+ * out, and what's marked to count), and each phone link and map link marked: a call or directions
75
+ * tapped is what a business wants to know came from the site. No cookies: nothing to consent to.
76
+ */
77
+ export function counted(html, a) {
78
+ const init = `window.op=window.op||function(){(window.op.q=window.op.q||[]).push(arguments)};window.op("init",${JSON.stringify({ apiUrl: a.api, clientId: a.client, trackScreenViews: true, trackOutgoingLinks: true, trackAttributes: true })});`;
79
+ const tags = `<script>${init}</script><script src="${a.script}" defer async></script>`;
80
+ html = html.replace(/<\/head>/i, () => `${tags}</head>`);
81
+ return html.replace(/<a\b([^>]*)>/gi, (tag, attrs) => {
82
+ if (/\sdata-track\s*=/i.test(attrs))
83
+ return tag;
84
+ const href = /\shref\s*=\s*["']([^"']*)["']/i.exec(attrs)?.[1] ?? "";
85
+ const event = /^tel:/i.test(href) ? "phone_tap" : /^https?:\/\/(www\.)?(google\.[a-z.]+\/maps|maps\.google\.[a-z.]+|goo\.gl\/maps|maps\.app\.goo\.gl|maps\.apple\.com)/i.test(href) ? "directions_tap" : null;
86
+ return event ? `<a${attrs} data-track="${event}">` : tag;
87
+ });
66
88
  }
67
89
  const hash = (body) => `sha256-${createHash("sha256").update(body, "utf8").digest("base64")}`;
68
90
  async function walk(dir) {
package/dist/prepare.d.ts CHANGED
@@ -25,7 +25,19 @@ export type Prepared = {
25
25
  cards?: Record<string, string>;
26
26
  /** Whether a page with pictures but no share picture chosen is shared with its first picture rather than its card. */
27
27
  preferPicture?: boolean;
28
+ /** Where its visits are counted, when CalOS counts them (Caltho's analytics server): finish puts its script in every page. */
29
+ analytics?: Analytics;
28
30
  };
31
+ /** A site's visits counted: the id its pages send them with, where they're sent, and the script that sends them. */
32
+ export type Analytics = {
33
+ client: string;
34
+ api: string;
35
+ script: string;
36
+ };
37
+ /** What CalOS says of the site's analytics, taken only when it's whole and its addresses are https (or a laptop's). */
38
+ export declare function analyticsIn(body: {
39
+ analytics?: unknown;
40
+ } | null): Analytics | undefined;
29
41
  type Log = (line: string) => void;
30
42
  export declare function prepare(root: string, env?: Record<string, string | undefined>, log?: Log): Promise<Prepared>;
31
43
  export {};
package/dist/prepare.js CHANGED
@@ -21,6 +21,15 @@ import sharp from "sharp";
21
21
  import { pathToFileURL } from "node:url";
22
22
  import { imagesIn, shareImageOf } from "./images.js";
23
23
  import { makeCards } from "./share.js";
24
+ /** What CalOS says of the site's analytics, taken only when it's whole and its addresses are https (or a laptop's). */
25
+ export function analyticsIn(body) {
26
+ const a = body?.analytics;
27
+ const address = (v) => typeof v === "string" && /^(https:\/\/|http:\/\/(localhost|127\.0\.0\.1)(:\d+)?\/?)/i.test(v) && URL.canParse(v);
28
+ if (!a || typeof a.client !== "string" || !/^[\w-]{8,64}$/.test(a.client) || !address(a.api) || !address(a.script))
29
+ return undefined;
30
+ // As URL writes them: a quote or an angle bracket escaped, so neither can leave the tag it goes in.
31
+ return { client: a.client, api: new URL(String(a.api)).href.replace(/\/+$/, ""), script: new URL(String(a.script)).href };
32
+ }
24
33
  const WIDTHS = [400, 640, 800, 1024, 1280, 1600, 2000];
25
34
  export async function prepare(root, env = process.env, log = console.log) {
26
35
  const out = join(root, ".site");
@@ -31,6 +40,7 @@ export async function prepare(root, env = process.env, log = console.log) {
31
40
  let raw;
32
41
  let from;
33
42
  let editors = [];
43
+ let analytics;
34
44
  // A site CalOS has nothing published for yet (just made, its content not brought in) builds from
35
45
  // its own until there is: 404 is "nothing published", any other refusal is an error.
36
46
  const res = env.CALOS_CONTENT_URL
@@ -42,7 +52,9 @@ export async function prepare(root, env = process.env, log = console.log) {
42
52
  const editorsIn = (body) => (Array.isArray(body?.editors) ? body.editors.filter((o) => typeof o === "string" && /^https?:\/\/[a-z0-9.-]+(:\d+)?$/i.test(o)) : []);
43
53
  if (res && res.status === 404) {
44
54
  // Who may edit it is known before anything's published: its preview can be drawn from the start.
45
- editors = editorsIn((await res.json().catch(() => null)));
55
+ const body = (await res.json().catch(() => null));
56
+ editors = editorsIn(body);
57
+ analytics = analyticsIn(body);
46
58
  log("site-kit: nothing published in CalOS yet; building from content/site.json");
47
59
  }
48
60
  if (res && res.status !== 404) {
@@ -51,6 +63,7 @@ export async function prepare(root, env = process.env, log = console.log) {
51
63
  const body = (await res.json());
52
64
  raw = body.content;
53
65
  editors = editorsIn(body);
66
+ analytics = analyticsIn(body);
54
67
  from = "CalOS";
55
68
  // Pictures uploaded in CalOS, fetched into the images folder once each.
56
69
  for (const [name, url] of Object.entries(body.images ?? {})) {
@@ -89,9 +102,9 @@ export async function prepare(root, env = process.env, log = console.log) {
89
102
  const images = await pictures(site, imagesDir, pub, log);
90
103
  const cards = await makeCards(site, definition.share, root, pub, log);
91
104
  const preferPicture = !!definition.share && definition.share.prefer === "picture";
92
- const prepared = { site, images, csp: definition.csp, cards, ...(preferPicture ? { preferPicture } : {}), ...(editors.length ? { editors } : {}) };
105
+ const prepared = { site, images, csp: definition.csp, cards, ...(preferPicture ? { preferPicture } : {}), ...(editors.length ? { editors } : {}), ...(analytics ? { analytics } : {}) };
93
106
  await writeFile(join(out, "prepared.json"), JSON.stringify(prepared));
94
- log(`site-kit: content from ${from}, ${site.pages.length} pages, ${Object.keys(images).length} images`);
107
+ log(`site-kit: content from ${from}, ${site.pages.length} pages, ${Object.keys(images).length} images${analytics ? ", visits counted" : ""}`);
95
108
  return prepared;
96
109
  }
97
110
  // ---- pictures ----
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caltho/site-kit",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
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",