@casomoltd/tooling 0.31.3 → 0.32.1

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
@@ -15,6 +15,7 @@ Shared linting, formatting, commit config, and CLI tools for Casomo Ltd's repos.
15
15
  | `@casomoltd/tooling/knip` | Knip config for unused exports/dependencies |
16
16
  | `@casomoltd/tooling/jscpd` | Copy-paste detection config |
17
17
  | `@casomoltd/tooling/readability` | `scoreText()` — readability scoring for page content |
18
+ | `@casomoltd/tooling/buttondown` | For a repo using the `buttondown` command: `tokenReader()` for its email templates, `loadSettings()` and `buildPayload()` for its tests, `EMAIL_CHROME_HIDES` for its config, and the `Check` type for its own gates |
18
19
 
19
20
  ### Bin commands
20
21
 
@@ -32,6 +33,7 @@ Shared linting, formatting, commit config, and CLI tools for Casomo Ltd's repos.
32
33
  | `spec-check` | Check a design spec's anchors, contents, figures and numbering |
33
34
  | `check-private-refs` | In a public repo, refuse a private tracker link or a name listed under `privateRefs` in `casomo.config.mjs` |
34
35
  | `check-personal-data` | Refuse an email, National Insurance number, UK mobile number, date of birth or locally denylisted name; per-repo allowances under `personalData` in `casomo.config.mjs` |
36
+ | `buttondown` | Keep a Buttondown newsletter as code: `diff` and `apply` its fields (settings, skins and email templates) and tags, `check` the skins, sync their tokens, and create drafts. Settings under `buttondown` in `casomo.config.mjs`; the API key from the file `BUTTONDOWN_KEY_FILE` names |
35
37
 
36
38
  ## Install
37
39
 
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Keep a Buttondown newsletter as code: apply it, gate it, and work with
4
+ * its drafts. Settings are under `buttondown` in the repo's
5
+ * casomo.config.mjs (see buttondown/engine.mjs); the API key is read from
6
+ * the file `BUTTONDOWN_KEY_FILE` names.
7
+ *
8
+ * buttondown [diff] what live differs in: fields and tags
9
+ * buttondown apply patch what differs
10
+ * buttondown check gate the skins and settings (no key)
11
+ * buttondown tokens rewrite the web skin's TOKENS block
12
+ * buttondown emit <dir> write each long field out as rendered
13
+ * buttondown draft <file.md> create or replace a draft
14
+ * buttondown upload <image> upload an image, print its URL
15
+ * buttondown api <METHOD> <path> [json]
16
+ * any call, for setting up a manual test
17
+ *
18
+ * `diff`, `apply` and `check` exit 1 on drift, a refusal or a problem, so a
19
+ * script can gate on them.
20
+ */
21
+ import {
22
+ apply, assertApiPath, client, draft, drift, emit, loadSettings, readKey,
23
+ runCheck, upload, writeTokens,
24
+ } from "../buttondown/engine.mjs";
25
+
26
+ const USAGE = "usage: buttondown [diff|apply|check|tokens|emit <dir>|"
27
+ + "draft <file.md>|upload <image>|api <METHOD> <path> [json]]";
28
+
29
+ /** Each command with the fewest and most arguments it takes. */
30
+ const ARGS = {
31
+ diff: [0, 0], apply: [0, 0], check: [0, 0], tokens: [0, 0],
32
+ emit: [1, 1], draft: [1, 1], upload: [1, 1], api: [2, 3],
33
+ };
34
+
35
+ const [command = "diff", ...args] = process.argv.slice(2);
36
+ const [fewest, most] = ARGS[command] ?? [];
37
+ if (fewest === undefined || args.length < fewest || args.length > most) {
38
+ console.error(USAGE);
39
+ process.exit(2);
40
+ }
41
+
42
+ const settings = await loadSettings();
43
+ const api = async () => client(settings, await readKey());
44
+
45
+ switch (command) {
46
+ case "diff": {
47
+ const {differs, tags} = await drift(settings, await api());
48
+ const all = [...differs, ...tags.map((t) => `tag ${t.body.name}`)];
49
+ console.log(all.length
50
+ ? `buttondown: live differs in ${all.join(", ")}`
51
+ : "buttondown: live matches");
52
+ process.exit(all.length ? 1 : 0);
53
+ break;
54
+ }
55
+ case "apply": {
56
+ const {refused} = await apply(settings, await api());
57
+ if (refused.length) {
58
+ console.error(`buttondown: refused ${refused.join(", ")}`);
59
+ process.exit(1);
60
+ }
61
+ break;
62
+ }
63
+ case "check": {
64
+ const problems = await runCheck(settings);
65
+ if (problems.length) {
66
+ console.error(`buttondown check:\n ${problems.join("\n ")}`);
67
+ process.exit(1);
68
+ }
69
+ console.log("buttondown check: clean");
70
+ break;
71
+ }
72
+ case "tokens":
73
+ await writeTokens(settings);
74
+ console.log("buttondown: TOKENS block written");
75
+ break;
76
+ case "emit":
77
+ for (const field of await emit(settings, args[0])) console.log(field);
78
+ break;
79
+ case "draft":
80
+ await draft(settings, await api(), args[0]);
81
+ break;
82
+ case "upload":
83
+ console.log(await upload(await api(), args[0]));
84
+ break;
85
+ case "api": {
86
+ const [method, url, body] = args;
87
+ assertApiPath(settings, method, url);
88
+ const res = await (await api()).call(
89
+ method.toUpperCase(), url, body && JSON.parse(body),
90
+ );
91
+ console.log(res.status, res.data ? JSON.stringify(res.data, null, 2) : "");
92
+ process.exit(res.ok ? 0 : 1);
93
+ }
94
+ }
package/bin/utils.mjs CHANGED
@@ -52,6 +52,7 @@ export async function loadConfig(filename, dir = process.cwd()) {
52
52
 
53
53
  /** The keys a repo's `casomo.config.mjs` may hold, one per gate. */
54
54
  export const CASOMO_CONFIG_KEYS = {
55
+ buttondown: "buttondown",
55
56
  personalData: "personalData",
56
57
  privateRefs: "privateRefs",
57
58
  };
@@ -19,6 +19,7 @@ const ALLOWED_PREFIXES = [
19
19
  "readability/",
20
20
  "report/",
21
21
  "styles/",
22
+ "buttondown/",
22
23
  ];
23
24
  const ALLOWED_FILES = ["README.md", "package.json", "LICENSE", "LICENSE.md"];
24
25
 
@@ -0,0 +1,749 @@
1
+ /**
2
+ * A Buttondown newsletter kept as code: its settings, skins and emails live
3
+ * in the repo, and this applies them to the live newsletter and gates them.
4
+ * The `buttondown` command drives it; `index.mjs` is the narrow public entry
5
+ * a repo's own files import.
6
+ *
7
+ * A repo configures it under `buttondown` in its `casomo.config.mjs`:
8
+ *
9
+ * import {EMAIL_CHROME_HIDES} from "@casomoltd/tooling/buttondown";
10
+ *
11
+ * export default {
12
+ * buttondown: {
13
+ * newsletter: {id, publicId, username}, // the one newsletter written
14
+ * globals: "src/app/globals.css", // the site's design tokens
15
+ * dir: "buttondown", // newsletter.json and fields/
16
+ * tokens: [["--color-accent", "--color-brand-accent"]], // target, source
17
+ * extra: [["--radius", "0"]], // uncommented lines after them
18
+ * asserts: {"--radius-lg": "0"}, // tokens the skins depend on
19
+ * allowedHides: {...EMAIL_CHROME_HIDES, ".selector": "why"},
20
+ * linkClasses: {onDark: "on-dark"}, // each needs an `a.<class>` rule
21
+ * ownLinks: ["/rss"], // hrefs that are not socials
22
+ * paletteFiles: ["buttondown/assets/icon.svg"],
23
+ * checks: ["buttondown/checks.mjs"], // the repo's own gates
24
+ * },
25
+ * };
26
+ *
27
+ * `newsletter.json` holds the newsletter's short fields, `tags.json` its
28
+ * tags, and each file in `fields/` is the field it is named after: a
29
+ * stylesheet is read verbatim, and an `.mjs` file's default export is the
30
+ * field's value.
31
+ */
32
+ import {existsSync} from "node:fs";
33
+ import {mkdir, readdir, readFile, writeFile} from "node:fs/promises";
34
+ import os from "node:os";
35
+ import path from "node:path";
36
+ import {pathToFileURL} from "node:url";
37
+ import {loadCasomoConfig} from "../bin/utils.mjs";
38
+
39
+ export const START = "/* TOKENS: generated by `buttondown tokens` */";
40
+ export const END = "/* END TOKENS */";
41
+
42
+ /**
43
+ * Buttondown's email chrome, for a repo whose emails draw their own header
44
+ * and footer bands. Not allowed by default: a repo opts in by spreading it
45
+ * into `allowedHides`.
46
+ */
47
+ export const EMAIL_CHROME_HIDES = {
48
+ ".social-links": "the web footer's links, duplicated in an email",
49
+ ".newsletter-masthead": "replaced by the repo's own header band",
50
+ ".newsletter-icon": "the icon slot above the repo's own mark",
51
+ ".email-preamble": "the forwarding prompt between title and prose",
52
+ ".colophon": "the sender-and-date line under the hidden masthead",
53
+ ".email-actions--unsubscribe": "duplicated by the repo's own footer",
54
+ ".powered-by-icon": "the logo tile above the kept attribution line",
55
+ };
56
+
57
+ const API = "https://api.buttondown.com/v1";
58
+
59
+ /** The API's own path prefix, which every request path sits under. */
60
+ const API_PATH = new URL(API).pathname;
61
+
62
+ /** The status of an email not yet sent. */
63
+ const DRAFT = "draft";
64
+
65
+ const FRONT_MATTER = /^---\n([\s\S]*?)\n---\n/;
66
+
67
+ /** The two skins, by the Buttondown field each file fills. */
68
+ const SKIN = {web: "web_css.css", email: "css.css"};
69
+
70
+ /** Fields Buttondown stores behind its image proxy. */
71
+ const IMAGE_FIELDS = new Set(["icon", "image"]);
72
+
73
+ /** The newsletter's identity, which only casomo.config.mjs may carry: a
74
+ * field of that name in newsletter.json would be sent as a setting. */
75
+ const IDENTITY = ["id", "publicId", "username"];
76
+
77
+ /** Hrefs Buttondown draws itself, which no socials entry names. */
78
+ const BUTTONDOWN_LINKS = ["buttondown.com"];
79
+
80
+ /** Values of `content:` that remove text rather than inject it. */
81
+ const NO_CONTENT = new Set(["none", "normal", '""', "''"]);
82
+
83
+ /** Shorter fields are settings, not documents, and `emit` leaves them to
84
+ * newsletter.json, where they already read plainly. */
85
+ const EMIT_MIN_LENGTH = 200;
86
+
87
+ /** The repo's settings, every path absolute. Throws where the newsletter
88
+ * or the tokens file is not named, since either would otherwise be
89
+ * guessed. */
90
+ export async function loadSettings(root = process.cwd()) {
91
+ const config = await loadCasomoConfig("buttondown", root);
92
+ const {id, username} = config.newsletter ?? {};
93
+ if (!id || !username) {
94
+ throw new Error(
95
+ "casomo.config.mjs needs buttondown.newsletter with an id and a "
96
+ + "username: it is the one newsletter this repo may write to",
97
+ );
98
+ }
99
+ if (!config.globals) {
100
+ throw new Error("casomo.config.mjs needs buttondown.globals: the "
101
+ + "stylesheet the site's design tokens are declared in");
102
+ }
103
+ const at = (p) => path.resolve(root, p);
104
+ return {
105
+ root,
106
+ newsletter: config.newsletter,
107
+ dir: at(config.dir ?? "buttondown"),
108
+ globals: at(config.globals),
109
+ tokens: config.tokens ?? [],
110
+ extra: config.extra ?? [],
111
+ asserts: config.asserts ?? {},
112
+ allowedHides: config.allowedHides ?? {},
113
+ linkClasses: Object.values(config.linkClasses ?? {}),
114
+ ownLinks: [...BUTTONDOWN_LINKS, ...(config.ownLinks ?? [])],
115
+ paletteFiles: (config.paletteFiles ?? []).map(at),
116
+ checks: (config.checks ?? []).map(at),
117
+ };
118
+ }
119
+
120
+ /**
121
+ * The value of one custom property, declared in a `:root` or `@theme`
122
+ * block: a `.dark {}` or `@media` override elsewhere in the file is a
123
+ * variant, not the token. Throws if absent, since a renamed token would
124
+ * otherwise leave a stale copy behind.
125
+ */
126
+ export function readToken(css, name) {
127
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
128
+ for (const m of css.matchAll(
129
+ new RegExp(`^\\s*${escaped}:\\s*([^;]+);`, "gm"),
130
+ )) {
131
+ if (isBaseBlock(css, m.index)) {
132
+ return m[1].trim();
133
+ }
134
+ }
135
+ throw new Error(`${name} not found in a :root or @theme block`);
136
+ }
137
+
138
+ /** A reader for one stylesheet's tokens, for a repo's email blocks. */
139
+ export async function tokenReader(file) {
140
+ const css = await readFile(file, "utf8");
141
+ return (name) => readToken(css, name);
142
+ }
143
+
144
+ /** Every field the newsletter is set to: newsletter.json, then fields/. */
145
+ export async function buildPayload(settings) {
146
+ const payload = JSON.parse(
147
+ await readFile(path.join(settings.dir, "newsletter.json"), "utf8"),
148
+ );
149
+ for (const field of IMAGE_FIELDS) {
150
+ if (payload[field] && !URL.canParse(payload[field])) {
151
+ throw new Error(`${field} must be an absolute URL, got `
152
+ + JSON.stringify(payload[field]));
153
+ }
154
+ }
155
+ const identity = IDENTITY.filter((key) => key in payload);
156
+ if (identity.length) {
157
+ throw new Error(
158
+ `newsletter.json holds ${identity.join(", ")}: the newsletter's `
159
+ + "identity belongs in casomo.config.mjs, not among its fields",
160
+ );
161
+ }
162
+ const dir = path.join(settings.dir, "fields");
163
+ for (const file of (await readdir(dir)).sort()) {
164
+ const field = file.replace(/\.[a-z]+$/, "");
165
+ if (field in payload) {
166
+ throw new Error(
167
+ `${field} is set in both newsletter.json and fields/${file}`,
168
+ );
169
+ }
170
+ const full = path.join(dir, file);
171
+ if (!file.endsWith(".mjs")) {
172
+ payload[field] = await readFile(full, "utf8");
173
+ continue;
174
+ }
175
+ // A module may build a structured field too, such as `socials` from
176
+ // the site's own footer links, so the two cannot drift apart.
177
+ const value = (await import(pathToFileURL(full).href)).default;
178
+ if (typeof value !== "string" && (!value || typeof value !== "object")) {
179
+ throw new Error(`fields/${file} must default-export a string, an `
180
+ + `array or an object, got ${value === null ? "null" : typeof value}`);
181
+ }
182
+ payload[field] = value;
183
+ }
184
+ return payload;
185
+ }
186
+
187
+ /** The generated block of the web skin, from the site's tokens. */
188
+ export function tokensBlock(settings, css) {
189
+ return [
190
+ START,
191
+ ...settings.tokens.map(
192
+ ([target, source]) =>
193
+ ` ${target}: ${readToken(css, source)}; /* ${source} */`,
194
+ ),
195
+ // A value starting `--` is read from the tokens; any other is literal.
196
+ ...settings.extra.map(
197
+ ([target, value]) =>
198
+ ` ${target}: ${
199
+ value.startsWith("--") ? readToken(css, value) : value
200
+ };`,
201
+ ),
202
+ END,
203
+ ].join("\n");
204
+ }
205
+
206
+ /** Rewrites the web skin's TOKENS block from the site's tokens. */
207
+ export async function writeTokens(settings) {
208
+ const css = await readFile(settings.globals, "utf8");
209
+ const file = skinPath(settings, "web");
210
+ const web = await readFile(file, "utf8");
211
+ const {s, e} = markers(web);
212
+ await writeFile(
213
+ file, web.slice(0, s) + tokensBlock(settings, css) + web.slice(e),
214
+ );
215
+ }
216
+
217
+ /** Every problem with the repo's skins and settings; empty when clean. */
218
+ export async function runCheck(settings) {
219
+ const css = await readFile(settings.globals, "utf8");
220
+ const web = await readIf(skinPath(settings, "web"));
221
+ const email = await readIf(skinPath(settings, "email"));
222
+ const payload = await buildPayload(settings);
223
+ const problems = [];
224
+
225
+ if (settings.tokens.length || settings.extra.length) {
226
+ const {s, e} = markers(web);
227
+ if (web.slice(s, e) !== tokensBlock(settings, css)) {
228
+ problems.push("the TOKENS block is stale: run `buttondown tokens`");
229
+ }
230
+ }
231
+ for (const [name, want] of Object.entries(settings.asserts)) {
232
+ const got = readToken(css, name);
233
+ if (got !== want) {
234
+ problems.push(`${name} is ${got} in the site's tokens; the skins `
235
+ + `depend on it being ${want}`);
236
+ }
237
+ }
238
+
239
+ // The palette is every hex the tokens file declares. Only hex is
240
+ // checked: an rgb() or named colour in a skin is not caught.
241
+ const palette = new Set(
242
+ [...css.matchAll(/#[0-9a-f]{3,8}\b/gi)].map((m) => longHex(m[0])),
243
+ );
244
+ const sources = [
245
+ ["web skin", cssValues(web)],
246
+ ["email skin", cssValues(email)],
247
+ ...await Promise.all(settings.paletteFiles.map(async (file) => [
248
+ path.relative(settings.root, file),
249
+ attributeValues(await readFile(file, "utf8")),
250
+ ])),
251
+ ];
252
+ for (const [where, values] of sources) {
253
+ for (const hex of offPalette(values, palette)) {
254
+ problems.push(`off-palette colour ${hex} in ${where}`);
255
+ }
256
+ }
257
+
258
+ for (const [where, text] of [["web skin", web], ["email skin", email]]) {
259
+ for (const selector of hides(text)) {
260
+ if (!(selector in settings.allowedHides)) {
261
+ problems.push(`${where} hides ${selector}, which allowedHides `
262
+ + "does not list: restyle Buttondown's markup, don't hide it");
263
+ }
264
+ }
265
+ for (const selector of injections(text)) {
266
+ problems.push(`${where} injects text with content: in ${selector}`);
267
+ }
268
+ }
269
+
270
+ const urls = [
271
+ ...(payload.socials ?? []).map((s) => s.url),
272
+ ...settings.ownLinks,
273
+ ];
274
+ for (const selector of orphanHrefs(web, urls)) {
275
+ problems.push(`${selector} in the web skin matches no socials URL`);
276
+ }
277
+
278
+ const emailRules = stripComments(email);
279
+ for (const name of settings.linkClasses) {
280
+ if (!new RegExp(`a\\.${name}(?![\\w-])`).test(emailRules)) {
281
+ problems.push(`email class "${name}" has no a.${name} rule, so `
282
+ + "the template's link colour would override it");
283
+ }
284
+ }
285
+
286
+ for (const file of settings.checks) {
287
+ const check = (await import(pathToFileURL(file).href)).default;
288
+ const found = await check({
289
+ root: settings.root, payload, css, web, email, readToken,
290
+ });
291
+ if (!Array.isArray(found)) {
292
+ throw new Error(`${path.relative(settings.root, file)} must resolve `
293
+ + "to an array of problems");
294
+ }
295
+ problems.push(...found);
296
+ }
297
+ return problems;
298
+ }
299
+
300
+ /** The API key, from the file `BUTTONDOWN_KEY_FILE` names: a path can be
301
+ * passed around freely, while a key on a command line sits in the shell
302
+ * history and in `ps`. */
303
+ export async function readKey(env = process.env) {
304
+ const file = env.BUTTONDOWN_KEY_FILE;
305
+ if (!file) {
306
+ throw new Error("set BUTTONDOWN_KEY_FILE to the file holding the key");
307
+ }
308
+ const key = (await readFile(file.replace(/^~(?=\/)/, os.homedir()),
309
+ "utf8")).trim();
310
+ if (!key) throw new Error(`${file} is empty`);
311
+ return key;
312
+ }
313
+
314
+ /**
315
+ * A client for the one newsletter. The key reaches every newsletter on the
316
+ * account, so each request carries Buttondown-Context with this id, and
317
+ * Buttondown refuses an unknown id rather than falling back to the
318
+ * account's default newsletter.
319
+ */
320
+ export function client(settings, key) {
321
+ const {newsletter} = settings;
322
+ async function call(method, url, body, {form} = {}) {
323
+ const res = await fetch(new URL(API + url), {
324
+ method,
325
+ headers: {
326
+ Authorization: `Token ${key}`,
327
+ "Buttondown-Context": newsletter.id,
328
+ ...(form ? {} : {"Content-Type": "application/json"}),
329
+ },
330
+ body: form ?? (body === undefined ? undefined : JSON.stringify(body)),
331
+ });
332
+ const text = await res.text();
333
+ let data = null;
334
+ try {
335
+ data = text ? JSON.parse(text) : null;
336
+ } catch {
337
+ // Reported as the detail, so a non-JSON refusal still says why.
338
+ data = {detail: text};
339
+ }
340
+ return {ok: res.ok, status: res.status, data};
341
+ }
342
+
343
+ /** Writing to the wrong newsletter must not happen, so the newsletter
344
+ * read before a write and the one a write returns are both checked.
345
+ * Buttondown names a newsletter by its UUID and, on some endpoints, a
346
+ * prefixed `publicId`; only those two are accepted. */
347
+ function assertNewsletter(got, when) {
348
+ const ids = [newsletter.id, newsletter.publicId].filter(Boolean);
349
+ if (!ids.includes(got?.id) || got?.username !== newsletter.username) {
350
+ throw new Error(`${when}: got ${got?.username} (${got?.id}), `
351
+ + `expected ${newsletter.username} (${newsletter.id})`);
352
+ }
353
+ }
354
+
355
+ async function live() {
356
+ const res = await call("GET", newsletterPath(newsletter));
357
+ if (!res.ok) throw new Error(`GET newsletter: ${detail(res)}`);
358
+ assertNewsletter(res.data, "before writing");
359
+ return res.data;
360
+ }
361
+
362
+ /** Every page of a list endpoint. A failed page throws: an empty list
363
+ * would read as "none on the account" and skip what is there. */
364
+ async function list(url) {
365
+ const items = [];
366
+ for (let next = url; next;) {
367
+ const res = await call("GET", next);
368
+ if (!res.ok) throw new Error(`GET ${next}: ${detail(res)}`);
369
+ items.push(...res.data.results);
370
+ const more = res.data.next && new URL(res.data.next);
371
+ next = more ? more.pathname.slice(API_PATH.length) + more.search : null;
372
+ }
373
+ return items;
374
+ }
375
+
376
+ return {call, assertNewsletter, live, list};
377
+ }
378
+
379
+ /**
380
+ * Refuses an `api` call that writes newsletter settings anywhere but this
381
+ * newsletter. Judged on the path the server routes: percent-decoded, then
382
+ * normalised, so `/%6eewsletters/<other>`, `/./newsletters/<other>` and
383
+ * `<own>/../<other>` are seen for what they are. A path still encoded after
384
+ * one decoding is refused outright rather than guessed at.
385
+ */
386
+ export function assertApiPath(settings, method, url) {
387
+ if (method.toUpperCase() === "GET") return;
388
+ const decoded = decodeURIComponent(new URL(API + url).pathname);
389
+ if (decoded.includes("%")) {
390
+ throw new Error(`api refuses an encoded path: ${url}`);
391
+ }
392
+ const {pathname} = new URL(decoded, API);
393
+ const own = API_PATH + newsletterPath(settings.newsletter);
394
+ if (pathname.startsWith(`${API_PATH}/newsletters`)
395
+ && pathname !== own && !pathname.startsWith(`${own}/`)) {
396
+ throw new Error(`api writes under /newsletters only to ${own}`);
397
+ }
398
+ }
399
+
400
+ /** Whether a live value already says what the repo wants. */
401
+ export function same(field, got, want) {
402
+ if (IMAGE_FIELDS.has(field)) {
403
+ if (!want) return !got;
404
+ let url;
405
+ try {
406
+ url = new URL(got);
407
+ } catch {
408
+ // Unset, or not a URL at all: not the image the repo names.
409
+ return false;
410
+ }
411
+ // Buttondown stores an image behind its proxy, with the original URL
412
+ // percent-encoded in the path, so compare the decoded host and path.
413
+ const decoded = decodeURIComponent(`${url.origin}${url.pathname}`);
414
+ const w = new URL(want);
415
+ return decoded.endsWith(`${w.host}${w.pathname}`);
416
+ }
417
+ if (typeof want === "string") {
418
+ return typeof got === "string" && got.trim() === want.trim();
419
+ }
420
+ if (Array.isArray(want)) {
421
+ return Array.isArray(got) && got.length === want.length
422
+ && want.every((item, i) => same(field, got[i], item));
423
+ }
424
+ if (want && typeof want === "object") {
425
+ // A live object may carry keys the repo does not set.
426
+ return !!got && typeof got === "object"
427
+ && Object.entries(want).every(([k, v]) => same(k, got[k], v));
428
+ }
429
+ return got === want;
430
+ }
431
+
432
+ /** What live differs in: the newsletter's fields and its tags. */
433
+ export async function drift(settings, api) {
434
+ const [payload, current, tags] = await Promise.all([
435
+ buildPayload(settings), api.live(), tagDrift(settings, api),
436
+ ]);
437
+ return {
438
+ payload,
439
+ differs: Object.keys(payload).filter(
440
+ (field) => !same(field, current[field], payload[field]),
441
+ ),
442
+ tags,
443
+ };
444
+ }
445
+
446
+ /**
447
+ * Patches what differs, in one call where Buttondown takes it. A
448
+ * plan-gated field makes Buttondown refuse the whole call, naming a
449
+ * feature rather than the field, so a refusal falls back to one field at
450
+ * a time. Returns what was refused, so a caller can fail on a partial
451
+ * write.
452
+ */
453
+ export async function apply(settings, api, log = console.log) {
454
+ const {payload, differs, tags} = await drift(settings, api);
455
+ const refused = [];
456
+ if (!differs.length && !tags.length) {
457
+ log("buttondown: live already matches");
458
+ return {refused};
459
+ }
460
+ if (differs.length) {
461
+ const url = newsletterPath(settings.newsletter);
462
+ const body = Object.fromEntries(differs.map((f) => [f, payload[f]]));
463
+ const whole = await api.call("PATCH", url, body);
464
+ if (whole.ok) {
465
+ api.assertNewsletter(whole.data, "after writing");
466
+ log(`buttondown: updated ${differs.join(", ")}`);
467
+ } else {
468
+ log(`buttondown: whole PATCH refused (${detail(whole)}); `
469
+ + "field by field:");
470
+ for (const field of differs) {
471
+ const one = await api.call("PATCH", url, {[field]: payload[field]});
472
+ if (one.ok) {
473
+ api.assertNewsletter(one.data, "after writing");
474
+ log(` ok ${field}`);
475
+ } else {
476
+ refused.push(field);
477
+ log(` REFUSED ${field}: ${detail(one)}`);
478
+ }
479
+ }
480
+ }
481
+ }
482
+ for (const {tagId, body} of tags) {
483
+ if (!tagId) {
484
+ refused.push(`tag ${body.name}`);
485
+ log(` tag ${body.name}: not on the account`);
486
+ continue;
487
+ }
488
+ const res = await api.call("PATCH", `/tags/${tagId}`, body);
489
+ if (!res.ok) refused.push(`tag ${body.name}`);
490
+ log(` tag ${body.name}: ${res.ok ? "updated" : `REFUSED ${detail(res)}`}`);
491
+ }
492
+ return {refused};
493
+ }
494
+
495
+ /**
496
+ * A draft from a markdown file whose first line, after any front matter,
497
+ * is `# Subject`. Front matter carries the email's id once it exists, so a
498
+ * re-run replaces that draft rather than creating a second; `slug` and
499
+ * `description` are passed through. An email already sent is refused:
500
+ * replacing it would rewrite the published archive.
501
+ */
502
+ export async function draft(settings, api, file, log = console.log) {
503
+ const raw = await readFile(file, "utf8");
504
+ const m = raw.match(FRONT_MATTER);
505
+ const fm = Object.fromEntries((m?.[1] ?? "").split("\n")
506
+ .filter((line) => line.includes(":"))
507
+ .map((line) => {
508
+ const at = line.indexOf(":");
509
+ return [line.slice(0, at).trim(), line.slice(at + 1).trim()];
510
+ }));
511
+ const lines = (m ? raw.slice(m[0].length) : raw).trimStart().split("\n");
512
+ if (!lines[0]?.startsWith("# ")) {
513
+ throw new Error("after any front matter, the first line must be "
514
+ + "`# Subject`");
515
+ }
516
+ if ("id" in fm && !fm.id) {
517
+ throw new Error("front matter has an empty `id:`; delete the line to "
518
+ + "create a draft");
519
+ }
520
+ // The marker says the body is markdown (Buttondown's `plaintext` mode).
521
+ // Without it Buttondown guesses, and a draft once stored as rich text
522
+ // takes the next PATCH as rich text too.
523
+ const body = {
524
+ subject: lines[0].slice(2).trim(),
525
+ body: "<!-- buttondown-editor-mode: plaintext -->\n"
526
+ + lines.slice(1).join("\n").trim(),
527
+ ...Object.fromEntries(["slug", "description"]
528
+ .filter((k) => k in fm).map((k) => [k, fm[k]])),
529
+ };
530
+ if (fm.id) {
531
+ const current = await api.call("GET", `/emails/${fm.id}`);
532
+ if (!current.ok) throw new Error(`GET email: ${detail(current)}`);
533
+ if (current.data.status !== DRAFT) {
534
+ throw new Error(`${fm.id} is ${current.data.status}, not a draft; `
535
+ + "replacing it would rewrite what was published");
536
+ }
537
+ const res = await api.call("PATCH", `/emails/${fm.id}`, body);
538
+ if (!res.ok) throw new Error(`refused: ${detail(res)}`);
539
+ log(`updated draft ${res.data.id}`);
540
+ return;
541
+ }
542
+ const res = await api.call("POST", "/emails", {...body, status: DRAFT});
543
+ if (!res.ok) throw new Error(`refused: ${detail(res)}`);
544
+ const withId = m
545
+ ? `---\nid: ${res.data.id}\n${raw.slice(4)}`
546
+ : `---\nid: ${res.data.id}\n---\n${raw}`;
547
+ await writeFile(file, withId);
548
+ log(`created draft ${res.data.id}; id written back into ${file}`);
549
+ }
550
+
551
+ /** Uploads an image and returns its Buttondown URL: the newsletter's
552
+ * images must be Buttondown's own, not linked from elsewhere. */
553
+ export async function upload(api, file) {
554
+ const form = new FormData();
555
+ form.append("image", new Blob([await readFile(file)]), path.basename(file));
556
+ const res = await api.call("POST", "/images", undefined, {form});
557
+ if (!res.ok || !res.data?.image) {
558
+ throw new Error(`upload refused: ${detail(res)}`);
559
+ }
560
+ return res.data.image;
561
+ }
562
+
563
+ /** Writes each long field out as rendered, so what Buttondown receives
564
+ * can be read and diffed directly. */
565
+ export async function emit(settings, dir) {
566
+ const payload = await buildPayload(settings);
567
+ await mkdir(dir, {recursive: true});
568
+ const written = [];
569
+ for (const [field, value] of Object.entries(payload)) {
570
+ if (typeof value !== "string" || value.length < EMIT_MIN_LENGTH) continue;
571
+ await writeFile(path.join(dir, field), value);
572
+ written.push(field);
573
+ }
574
+ return written;
575
+ }
576
+
577
+ /** Comments removed by index rather than a lazy regex, which backtracks
578
+ * between its two literals. */
579
+ export function stripComments(text) {
580
+ let out = "";
581
+ let at = 0;
582
+ for (;;) {
583
+ const open = text.indexOf("/*", at);
584
+ if (open === -1) return out + text.slice(at);
585
+ out += text.slice(at, open);
586
+ const close = text.indexOf("*/", open + 2);
587
+ if (close === -1) return out;
588
+ at = close + 2;
589
+ }
590
+ }
591
+
592
+ /** Why a call failed: Buttondown's `detail`, else its whole body, which
593
+ * names the fields it refused, else the status. */
594
+ const detail = (res) =>
595
+ JSON.stringify(res.data?.detail ?? res.data ?? res.status);
596
+
597
+ const newsletterPath = (newsletter) => `/newsletters/${newsletter.id}`;
598
+
599
+ const skinPath = (settings, kind) =>
600
+ path.join(settings.dir, "fields", SKIN[kind]);
601
+
602
+ const readIf = async (file) =>
603
+ existsSync(file) ? readFile(file, "utf8") : "";
604
+
605
+ /** Whether `index` sits directly in a top-level `:root` or `@theme` block:
606
+ * `:root.dark`, `:root[data-theme]` and a `:root` nested in an at-rule are
607
+ * variants of the token, not the token. */
608
+ function isBaseBlock(css, index) {
609
+ const depth = [...css.slice(0, index)].reduce(
610
+ (d, c) => d + (c === "{") - (c === "}"), 0,
611
+ );
612
+ const head = enclosing(css, index);
613
+ return depth === 1 && (head === ":root" || /^@theme(\s|$)/.test(head));
614
+ }
615
+
616
+ /** The head of the block enclosing `index`, or "" at the top level. A
617
+ * comment before the head is not part of it, even one holding a `;`. */
618
+ function enclosing(css, index) {
619
+ let depth = 0;
620
+ for (let i = index; i >= 0; i -= 1) {
621
+ if (css[i] === "}") depth += 1;
622
+ if (css[i] === "{") {
623
+ if (depth === 0) {
624
+ const head = css.slice(0, i);
625
+ const tail = head.slice(Math.max(head.lastIndexOf("}"),
626
+ head.lastIndexOf(";"), head.lastIndexOf("{")) + 1);
627
+ return stripComments(tail).replace(/^[\s\S]*\*\//, "").trim();
628
+ }
629
+ depth -= 1;
630
+ }
631
+ }
632
+ return "";
633
+ }
634
+
635
+ /** The one TOKENS block's bounds; anything else is refused, since a
636
+ * missing, repeated or reversed pair would slice the wrong text. */
637
+ function markers(web) {
638
+ const s = web.indexOf(START);
639
+ const e = web.indexOf(END);
640
+ if (s === -1 || e === -1 || e < s
641
+ || web.indexOf(START, s + 1) !== -1 || web.indexOf(END, e + 1) !== -1) {
642
+ throw new Error("the web skin needs exactly one TOKENS block, start "
643
+ + "before end");
644
+ }
645
+ return {s, e: e + END.length};
646
+ }
647
+
648
+ function longHex(hex) {
649
+ const h = hex.slice(1).toLowerCase();
650
+ return h.length <= 4 ? `#${[...h].map((c) => c + c).join("")}` : `#${h}`;
651
+ }
652
+
653
+ /** Each rule as [selector, body], selectors whitespace-normalised. A rule
654
+ * inside an at-rule takes the text after the at-rule's own brace. */
655
+ function rules(text) {
656
+ return stripComments(text).split("}").flatMap((chunk) => {
657
+ const brace = chunk.lastIndexOf("{");
658
+ if (brace === -1) return [];
659
+ const head = chunk.slice(0, brace);
660
+ const selector = head.slice(head.lastIndexOf("{") + 1)
661
+ .split(/\s+/).join(" ").trim();
662
+ return [[selector, chunk.slice(brace + 1)]];
663
+ });
664
+ }
665
+
666
+ /** Each declaration in a stylesheet as [property, value]. */
667
+ function declarations(text) {
668
+ return rules(text).flatMap(([, body]) => body.split(";").flatMap((decl) => {
669
+ const at = decl.indexOf(":");
670
+ return at === -1
671
+ ? []
672
+ : [[decl.slice(0, at).trim(), decl.slice(at + 1).trim()]];
673
+ }));
674
+ }
675
+
676
+ /** Declaration values only, so a selector such as `a:not(#cafe)` is not
677
+ * read as a colour. */
678
+ const cssValues = (text) =>
679
+ declarations(text).map(([, value]) => value).join(" ");
680
+
681
+ /** An SVG's attribute values. */
682
+ const attributeValues = (text) =>
683
+ [...text.matchAll(/="([^"]*)"/g)].map((m) => m[1]).join(" ");
684
+
685
+ function offPalette(values, palette) {
686
+ return [...new Set(
687
+ [...values.matchAll(/#[0-9a-f]{3,8}\b/gi)]
688
+ .map((m) => m[0])
689
+ .filter((hex) => !palette.has(longHex(hex))),
690
+ )];
691
+ }
692
+
693
+ function hides(text) {
694
+ return rules(text)
695
+ .filter(([, body]) => body.split(";").some((decl) =>
696
+ /^(display:none|font-size:0)(!important)?$/.test(
697
+ decl.replace(/\s/g, ""),
698
+ )))
699
+ .map(([selector]) => selector);
700
+ }
701
+
702
+ function injections(text) {
703
+ return rules(text)
704
+ .filter(([selector, body]) => !selector.startsWith("@")
705
+ && body.split(";").some((decl) => {
706
+ // Anchored: justify-, align- and place-content end in content too.
707
+ const m = decl.match(/^\s*content\s*:\s*(.*?)\s*(!important)?\s*$/);
708
+ return m && !NO_CONTENT.has(m[1]);
709
+ }))
710
+ .map(([selector]) => selector);
711
+ }
712
+
713
+ /** Every href attribute selector the skin keys on that names no socials
714
+ * URL, quoted either way or unquoted. */
715
+ function orphanHrefs(text, urls) {
716
+ const matches = {
717
+ "=": (url, v) => url === v,
718
+ "~=": (url, v) => url.split(/\s+/).includes(v),
719
+ "|=": (url, v) => url === v || url.startsWith(`${v}-`),
720
+ "*=": (url, v) => url.includes(v),
721
+ "^=": (url, v) => url.startsWith(v),
722
+ "$=": (url, v) => url.endsWith(v),
723
+ };
724
+ return [...stripComments(text).matchAll(
725
+ /\[\s*href\s*([~|*^$]?=)\s*(?:"([^"]*)"|'([^']*)'|([^\s\]]+))(?:\s+[is])?\s*\]/gi,
726
+ )]
727
+ .filter(([, op, d, s, bare]) =>
728
+ !urls.some((url) => matches[op](url, d ?? s ?? bare)))
729
+ .map(([whole]) => whole);
730
+ }
731
+
732
+ /**
733
+ * Each tag in tags.json whose live fields differ, keyed by id rather than
734
+ * name: a tag's name is the label a subscriber reads in their preferences,
735
+ * so renaming one is an ordinary edit, and a lookup by name would miss on
736
+ * exactly that edit. The name is the fallback for a tag whose id is not
737
+ * written in yet; a tag found by neither comes back without a `tagId`.
738
+ */
739
+ async function tagDrift(settings, api) {
740
+ const file = path.join(settings.dir, "tags.json");
741
+ if (!existsSync(file)) return [];
742
+ const live = await api.list("/tags");
743
+ return JSON.parse(await readFile(file, "utf8")).flatMap(({id, ...body}) => {
744
+ const current = live.find((t) => t.id === id)
745
+ ?? (id ? undefined : live.find((t) => t.name === body.name));
746
+ if (current && same("tag", current, body)) return [];
747
+ return [{tagId: current?.id, body}];
748
+ });
749
+ }
@@ -0,0 +1,66 @@
1
+ /** A newsletter field's value: a string, or the structured settings
2
+ * Buttondown takes as JSON (socials, theme_configuration). */
3
+ export type FieldValue =
4
+ | string
5
+ | number
6
+ | boolean
7
+ | null
8
+ | FieldValue[]
9
+ | {[key: string]: FieldValue};
10
+
11
+ /** Every field the newsletter is set to, by Buttondown field name. */
12
+ export type Payload = Record<string, FieldValue>;
13
+
14
+ /** The one newsletter a repo may write to. */
15
+ export interface Newsletter {
16
+ id: string;
17
+ /** The prefixed id some Buttondown endpoints return for the same one. */
18
+ publicId?: string;
19
+ username: string;
20
+ }
21
+
22
+ /** A repo's settings, as resolved from `buttondown` in casomo.config.mjs,
23
+ * with every path absolute. */
24
+ export interface Settings {
25
+ root: string;
26
+ newsletter: Newsletter;
27
+ dir: string;
28
+ globals: string;
29
+ tokens: [target: string, source: string][];
30
+ extra: [target: string, value: string][];
31
+ asserts: Record<string, string>;
32
+ allowedHides: Record<string, string>;
33
+ linkClasses: string[];
34
+ ownLinks: string[];
35
+ paletteFiles: string[];
36
+ checks: string[];
37
+ }
38
+
39
+ /** What a repo's own gate receives: the default export of each file
40
+ * under `checks` takes this and resolves to its problems, empty when
41
+ * clean. */
42
+ export interface CheckContext {
43
+ root: string;
44
+ payload: Payload;
45
+ /** The site's tokens stylesheet. */
46
+ css: string;
47
+ /** The web skin and the email skin, as written. */
48
+ web: string;
49
+ email: string;
50
+ readToken(css: string, name: string): string;
51
+ }
52
+
53
+ export type Check = (context: CheckContext) => Promise<string[]>;
54
+
55
+ /** Buttondown's email chrome, for a repo whose emails draw their own
56
+ * bands: spread into `allowedHides` to allow hiding it. */
57
+ export const EMAIL_CHROME_HIDES: Readonly<Record<string, string>>;
58
+
59
+ export function loadSettings(root?: string): Promise<Settings>;
60
+
61
+ export function buildPayload(settings: Settings): Promise<Payload>;
62
+
63
+ /** A reader for one stylesheet's `:root` and `@theme` tokens. */
64
+ export function tokenReader(
65
+ file: string | URL,
66
+ ): Promise<(name: string) => string>;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The public face of the Buttondown engine: what a repo's own files
3
+ * import. Everything else is the `buttondown` command's (engine.mjs).
4
+ */
5
+ export {
6
+ buildPayload, EMAIL_CHROME_HIDES, loadSettings, tokenReader,
7
+ } from "./engine.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casomoltd/tooling",
3
- "version": "0.31.3",
3
+ "version": "0.32.1",
4
4
  "description": "Casomo's shared dev config: ESLint, Prettier, commitlint, knip & jscpd presets and dev CLIs (git hooks, readability, screenshots, design specs)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,13 +17,14 @@
17
17
  "readability/",
18
18
  "report/",
19
19
  "styles/",
20
+ "buttondown/",
20
21
  "README.md",
21
22
  "!**/*.test.mjs"
22
23
  ],
23
24
  "scripts": {
24
25
  "check": "node bin/check-gates.mjs && node bin/verify-pack.mjs && npm run check:private-refs && npm run check:personal-data && npm run typecheck && npm run test && npm run lint:md",
25
26
  "typecheck": "tsc --noEmit -p hooks/tsconfig.json",
26
- "test": "node hooks/test.mts && node bin/check-gates.test.mjs && node bin/check-personal-data.test.mjs && node bin/casomo-config.test.mjs && node commitlint/index.test.mjs",
27
+ "test": "node hooks/test.mts && node bin/check-gates.test.mjs && node bin/check-personal-data.test.mjs && node bin/casomo-config.test.mjs && node bin/buttondown.test.mjs && node commitlint/index.test.mjs",
27
28
  "lint:md": "remark --frail --quiet --no-stdout --rc-path bin/skills-lint.config.mjs skills agents docs",
28
29
  "build": "echo 'no-op: source-only repo, nothing to build'",
29
30
  "prepare": "husky || true",
@@ -46,10 +47,15 @@
46
47
  "./commitlint": "./commitlint/index.cjs",
47
48
  "./knip": "./knip/index.json",
48
49
  "./jscpd": "./jscpd/index.json",
49
- "./readability": "./readability/index.mjs"
50
+ "./readability": "./readability/index.mjs",
51
+ "./buttondown": {
52
+ "types": "./buttondown/index.d.mts",
53
+ "default": "./buttondown/index.mjs"
54
+ }
50
55
  },
51
56
  "bin": {
52
57
  "build-report": "./bin/build-report.mjs",
58
+ "buttondown": "./bin/buttondown.mjs",
53
59
  "check-gates": "./bin/check-gates.mjs",
54
60
  "check-hooks": "./bin/check-hooks.sh",
55
61
  "check-linear": "./bin/check-linear.sh",