@caltho/site-kit 0.2.6 → 0.2.8
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 +1 -1
- package/dist/content/index.d.ts +2 -1
- package/dist/finish.d.ts +7 -0
- package/dist/finish.js +29 -7
- package/dist/policies.js +9 -3
- package/dist/prepare.d.ts +12 -0
- package/dist/prepare.js +21 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -57,7 +57,7 @@ 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). |
|
|
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. The standard privacy policy then leaves out its claim of no analytics, saying nothing in its place: what to say of the counting is the business's (`extra`). |
|
|
61
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. |
|
package/dist/content/index.d.ts
CHANGED
|
@@ -170,7 +170,8 @@ export type SiteBase = {
|
|
|
170
170
|
footer?: Footer;
|
|
171
171
|
look?: Look;
|
|
172
172
|
redirects: Redirect[]; /** Whether page paths end in a slash (the default). */
|
|
173
|
-
trailingSlash?: boolean;
|
|
173
|
+
trailingSlash?: boolean; /** Set by prepare, never in content: CalOS counts this site's visits (its pages carry the script), which its privacy policy says. */
|
|
174
|
+
countsVisits?: boolean;
|
|
174
175
|
};
|
|
175
176
|
/**
|
|
176
177
|
* The site's menu, as its header draws it: the one edited in CalOS (its links in order, one picked out,
|
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/policies.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// The policy pages a business's site should have, written from what the business has told us and
|
|
2
2
|
// what the template actually does: no cookies, no analytics, no third-party requests, a contact
|
|
3
3
|
// form that goes to the business's CalOS. So the words stay true as the site changes: add a
|
|
4
|
-
// tracker to the template and this is where it has to be said.
|
|
5
|
-
//
|
|
4
|
+
// tracker to the template and this is where it has to be said. Where the business has CalOS count
|
|
5
|
+
// its visits (prepare marks it: site.countsVisits), the claim of no analytics is left out and
|
|
6
|
+
// nothing said in its place: what to say of the counting is the business's, in `extra` (CalOS
|
|
7
|
+
// suggests a paragraph as they turn it on). Plain language, US-first; a business adds its own
|
|
8
|
+
// paragraphs with `extra`, and should have a lawyer read them once.
|
|
6
9
|
/**
|
|
7
10
|
* `collectsFromForm`: the site has a form that takes a visitor's name, email and message, which the
|
|
8
11
|
* privacy policy says (it defaults to the site having a form address).
|
|
@@ -13,6 +16,7 @@ export function policy(site, which, o = {}) {
|
|
|
13
16
|
const reach = [b.email ? `email [${b.email}](mailto:${b.email})` : null, b.phone ? `call ${b.phone}` : null].filter(Boolean).join(" or ");
|
|
14
17
|
const place = b.address ? `${b.address.locality}, ${b.address.region}` : null;
|
|
15
18
|
const hasForm = o.collectsFromForm ?? !!site.form;
|
|
19
|
+
const counts = !!site.countsVisits;
|
|
16
20
|
if (which === "privacy") {
|
|
17
21
|
return {
|
|
18
22
|
title: "Privacy policy",
|
|
@@ -27,7 +31,9 @@ export function policy(site, which, o = {}) {
|
|
|
27
31
|
]
|
|
28
32
|
.filter(Boolean)
|
|
29
33
|
.join("\n"),
|
|
30
|
-
|
|
34
|
+
counts
|
|
35
|
+
? `This site doesn't use cookies or advertising trackers. Its fonts and pictures come from this site itself.`
|
|
36
|
+
: `This site doesn't use cookies, advertising or analytics trackers. Its fonts and pictures come from this site itself, so visiting it doesn't tell anyone else you were here.`,
|
|
31
37
|
`## How we use it`,
|
|
32
38
|
`To answer you, to quote for and do the work you ask about, and to keep the records the law requires. We keep enquiries in our customer records system, which Caltho runs for us. We don't sell or rent your information, and we don't share it for advertising. The companies that help us run this site and our business (hosting, email, records) only use it to do that for us.`,
|
|
33
39
|
`## How long we keep it`,
|
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
|
-
|
|
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 ?? {})) {
|
|
@@ -79,6 +92,11 @@ export async function prepare(root, env = process.env, log = console.log) {
|
|
|
79
92
|
if (parsed.problems)
|
|
80
93
|
throw new Error(`The site's content needs fixing (${from}):\n - ${parsed.problems.join("\n - ")}`);
|
|
81
94
|
const site = parsed.site;
|
|
95
|
+
// Its pages will count visits (finish puts the script in): what its privacy policy says follows.
|
|
96
|
+
if (analytics)
|
|
97
|
+
site.countsVisits = true;
|
|
98
|
+
else
|
|
99
|
+
delete site.countsVisits;
|
|
82
100
|
for (const w of parsed.warnings)
|
|
83
101
|
log(`site-kit: warning: ${w}`);
|
|
84
102
|
const schema = definition.jsonSchema();
|
|
@@ -89,9 +107,9 @@ export async function prepare(root, env = process.env, log = console.log) {
|
|
|
89
107
|
const images = await pictures(site, imagesDir, pub, log);
|
|
90
108
|
const cards = await makeCards(site, definition.share, root, pub, log);
|
|
91
109
|
const preferPicture = !!definition.share && definition.share.prefer === "picture";
|
|
92
|
-
const prepared = { site, images, csp: definition.csp, cards, ...(preferPicture ? { preferPicture } : {}), ...(editors.length ? { editors } : {}) };
|
|
110
|
+
const prepared = { site, images, csp: definition.csp, cards, ...(preferPicture ? { preferPicture } : {}), ...(editors.length ? { editors } : {}), ...(analytics ? { analytics } : {}) };
|
|
93
111
|
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`);
|
|
112
|
+
log(`site-kit: content from ${from}, ${site.pages.length} pages, ${Object.keys(images).length} images${analytics ? ", visits counted" : ""}`);
|
|
95
113
|
return prepared;
|
|
96
114
|
}
|
|
97
115
|
// ---- pictures ----
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@caltho/site-kit",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
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",
|