@caltho/site-kit 0.2.6 → 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 +1 -1
- package/dist/finish.d.ts +7 -0
- package/dist/finish.js +29 -7
- package/dist/prepare.d.ts +12 -0
- package/dist/prepare.js +16 -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. |
|
|
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/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
|
-
|
|
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.
|
|
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",
|