@casomoltd/tooling 0.34.2 → 0.36.0

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.
@@ -12,19 +12,25 @@
12
12
  * buttondown emit <dir> write each long field out as rendered
13
13
  * buttondown draft <file.md> create or replace a draft
14
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
15
+ * buttondown api <METHOD> <path> [json] [--send]
16
+ * any call, for setting up a manual test;
17
+ * one that sends or schedules an email
18
+ * is refused without --send
17
19
  *
18
20
  * `diff`, `apply` and `check` exit 1 on drift, a refusal or a problem, so a
19
21
  * script can gate on them.
20
22
  */
21
23
  import {
22
- apply, assertApiPath, client, draft, drift, emit, loadSettings, readKey,
23
- runCheck, upload, writeTokens,
24
+ apply, assertApiPath, assertNotSending, client, draft, drift, emit,
25
+ loadSettings, readKey, runCheck, upload, writeTokens,
24
26
  } from "../buttondown/engine.mjs";
25
27
 
28
+ /** The one flag: without it, `api` refuses any call that could send an
29
+ * email, and no other command takes it. */
30
+ const SEND_FLAG = "--send";
31
+
26
32
  const USAGE = "usage: buttondown [diff|apply|check|tokens|emit <dir>|"
27
- + "draft <file.md>|upload <image>|api <METHOD> <path> [json]]";
33
+ + `draft <file.md>|upload <image>|api <METHOD> <path> [json] [${SEND_FLAG}]]`;
28
34
 
29
35
  /** Each command with the fewest and most arguments it takes. */
30
36
  const ARGS = {
@@ -32,9 +38,13 @@ const ARGS = {
32
38
  emit: [1, 1], draft: [1, 1], upload: [1, 1], api: [2, 3],
33
39
  };
34
40
 
35
- const [command = "diff", ...args] = process.argv.slice(2);
41
+ const argv = process.argv.slice(2);
42
+ const send = argv.includes(SEND_FLAG);
43
+ const [command = "diff", ...args] = argv.filter((a) => a !== SEND_FLAG);
36
44
  const [fewest, most] = ARGS[command] ?? [];
37
- if (fewest === undefined || args.length < fewest || args.length > most) {
45
+ const badUsage = fewest === undefined || args.length < fewest
46
+ || args.length > most || (send && command !== "api");
47
+ if (badUsage) {
38
48
  console.error(USAGE);
39
49
  process.exit(2);
40
50
  }
@@ -85,9 +95,16 @@ switch (command) {
85
95
  case "api": {
86
96
  const [method, url, body] = args;
87
97
  assertApiPath(settings, method, url);
88
- const res = await (await api()).call(
89
- method.toUpperCase(), url, body && JSON.parse(body),
90
- );
98
+ const payload = body && JSON.parse(body);
99
+ if (!send) {
100
+ try {
101
+ assertNotSending(method, url, payload);
102
+ } catch (e) {
103
+ console.error(`buttondown: ${e.message}; add ${SEND_FLAG} to send it`);
104
+ process.exit(1);
105
+ }
106
+ }
107
+ const res = await (await api()).call(method.toUpperCase(), url, payload);
91
108
  console.log(res.status, res.data ? JSON.stringify(res.data, null, 2) : "");
92
109
  process.exit(res.ok ? 0 : 1);
93
110
  }
@@ -27,7 +27,8 @@
27
27
  * },
28
28
  * };
29
29
  *
30
- * `newsletter.json` holds the newsletter's short fields, `tags.json` its
30
+ * `newsletter.json` holds the newsletter's short fields (its `image` is
31
+ * also every draft's default share image), `tags.json` its
31
32
  * tags, and each file in `fields/` is the field it is named after: a
32
33
  * stylesheet is read verbatim, and an `.mjs` file's default export is the
33
34
  * field's value.
@@ -86,6 +87,19 @@ const API_PATH = new URL(API).pathname;
86
87
  /** The status of an email not yet sent. */
87
88
  const DRAFT = "draft";
88
89
 
90
+ /**
91
+ * The api routes that can mail someone, judged on a `requestPath`. A write
92
+ * to the emails collection or to one email may carry a status that sends
93
+ * it, so it passes only as a draft; any action on an email (publish,
94
+ * send-draft and whatever Buttondown adds) or on a subscriber (a reminder,
95
+ * an email to one reader) is refused outright, so a new route fails closed.
96
+ * Creating an unactivated subscriber also mails a confirmation, and stays
97
+ * allowed: it is how a manual test stands in for a reader signing up, to an
98
+ * address the tester owns.
99
+ */
100
+ const EMAIL_WRITE = new RegExp(`^${API_PATH}/emails(/[^/]+)?/?$`);
101
+ const SENDING_ACTION = new RegExp(`^${API_PATH}/(emails|subscribers)/[^/]+/.`);
102
+
89
103
  const FRONT_MATTER = /^---\n([\s\S]*?)\n---\n/;
90
104
 
91
105
  /** The Buttondown fields holding the two skins. */
@@ -180,15 +194,7 @@ export async function tokenReader(file) {
180
194
 
181
195
  /** Every field the newsletter is set to: newsletter.json, then fields/. */
182
196
  export async function buildPayload(settings) {
183
- const payload = JSON.parse(
184
- await readFile(path.join(settings.dir, "newsletter.json"), "utf8"),
185
- );
186
- for (const field of IMAGE_FIELDS) {
187
- if (payload[field] && !URL.canParse(payload[field])) {
188
- throw new Error(`${field} must be an absolute URL, got `
189
- + JSON.stringify(payload[field]));
190
- }
191
- }
197
+ const payload = await readNewsletter(settings);
192
198
  const identity = IDENTITY.filter((key) => key in payload);
193
199
  if (identity.length) {
194
200
  throw new Error(
@@ -394,7 +400,7 @@ export async function readKey(env = process.env) {
394
400
  export function client(settings, key) {
395
401
  const {newsletter} = settings;
396
402
  async function call(method, url, body, {form} = {}) {
397
- const res = await fetch(new URL(API + url), {
403
+ const res = await fetch(requestUrl(url), {
398
404
  method,
399
405
  headers: {
400
406
  Authorization: `Token ${key}`,
@@ -452,25 +458,37 @@ export function client(settings, key) {
452
458
 
453
459
  /**
454
460
  * Refuses an `api` call that writes newsletter settings anywhere but this
455
- * newsletter. Judged on the path the server routes: percent-decoded, then
456
- * normalised, so `/%6eewsletters/<other>`, `/./newsletters/<other>` and
457
- * `<own>/../<other>` are seen for what they are. A path still encoded after
458
- * one decoding is refused outright rather than guessed at.
461
+ * newsletter, judged on the path the server routes (`requestPath`).
459
462
  */
460
463
  export function assertApiPath(settings, method, url) {
461
464
  if (method.toUpperCase() === "GET") return;
462
- const decoded = decodeURIComponent(new URL(API + url).pathname);
463
- if (decoded.includes("%")) {
464
- throw new Error(`api refuses an encoded path: ${url}`);
465
- }
466
- const {pathname} = new URL(decoded, API);
467
- const own = API_PATH + newsletterPath(settings.newsletter);
465
+ const pathname = requestPath(url);
466
+ const own = (API_PATH + newsletterPath(settings.newsletter)).toLowerCase();
468
467
  if (pathname.startsWith(`${API_PATH}/newsletters`)
469
468
  && pathname !== own && !pathname.startsWith(`${own}/`)) {
470
469
  throw new Error(`api writes under /newsletters only to ${own}`);
471
470
  }
472
471
  }
473
472
 
473
+ /**
474
+ * Throws if an api call could send or schedule an email (`EMAIL_WRITE`,
475
+ * `SENDING_ACTION`). Sending reaches real inboxes and cannot be recalled, so
476
+ * the caller lets a call through only on a person's explicit say-so.
477
+ */
478
+ export function assertNotSending(method, url, body) {
479
+ const cannotSend = ["GET", "DELETE"].includes(method.toUpperCase());
480
+ if (cannotSend) return;
481
+ const pathname = requestPath(url);
482
+ const status = body && typeof body === "object" ? body.status : undefined;
483
+ const creates = pathname.replace(/\/$/, "") === `${API_PATH}/emails`;
484
+ const draftOnly = status === DRAFT || (status === undefined && !creates);
485
+ if (SENDING_ACTION.test(pathname)
486
+ || (EMAIL_WRITE.test(pathname) && !draftOnly)) {
487
+ throw new Error(`api refuses a call that could send an email: `
488
+ + `${method.toUpperCase()} ${url}`);
489
+ }
490
+ }
491
+
474
492
  /** Whether a live value already says what the repo wants. */
475
493
  export function same(field, got, want) {
476
494
  if (IMAGE_FIELDS.has(field)) {
@@ -570,7 +588,8 @@ export async function apply(settings, api, log = console.log) {
570
588
  * A draft from a markdown file whose first line, after any front matter,
571
589
  * is `# Subject`. Front matter carries the email's id once it exists, so a
572
590
  * re-run replaces that draft rather than creating a second; `slug` and
573
- * `description` are passed through. An email already sent is refused:
591
+ * `description` are passed through, and `image` (the share image) defaults
592
+ * to the newsletter's own. An email already sent is refused:
574
593
  * replacing it would rewrite the published archive.
575
594
  */
576
595
  export async function draft(settings, api, file, log = console.log) {
@@ -591,6 +610,10 @@ export async function draft(settings, api, file, log = console.log) {
591
610
  throw new Error("front matter has an empty `id:`; delete the line to "
592
611
  + "create a draft");
593
612
  }
613
+ if ("image" in fm && !fm.image) {
614
+ throw new Error("front matter has an empty `image:`; delete the line "
615
+ + "to use the newsletter's share image");
616
+ }
594
617
  // The marker says the body is markdown (Buttondown's `plaintext` mode).
595
618
  // Without it Buttondown guesses, and a draft once stored as rich text
596
619
  // takes the next PATCH as rich text too.
@@ -601,6 +624,15 @@ export async function draft(settings, api, file, log = console.log) {
601
624
  ...Object.fromEntries(["slug", "description"]
602
625
  .filter((k) => k in fm).map((k) => [k, fm[k]])),
603
626
  };
627
+ // Left unset, Buttondown takes the post's first picture as its share
628
+ // image, so every post would preview differently; the newsletter's own
629
+ // share image is the default, and `image:` in the front matter wins.
630
+ const image = "image" in fm
631
+ ? fm.image : (await readNewsletter(settings)).image;
632
+ if (image) {
633
+ assertAbsoluteUrl("image", image);
634
+ body.image = image;
635
+ }
604
636
  if (fm.id) {
605
637
  const current = await api.call("GET", `/emails/${fm.id}`);
606
638
  if (!current.ok) throw new Error(`GET email: ${detail(current)}`);
@@ -686,6 +718,50 @@ export function stripComments(text) {
686
718
  }
687
719
  }
688
720
 
721
+ /** The URL `call` fetches for an api path, and the one the guards judge. */
722
+ function requestUrl(url) {
723
+ return new URL(API + url);
724
+ }
725
+
726
+ /**
727
+ * The path the server routes for an `api` url, from `requestUrl`,
728
+ * then percent-decoded, slash-collapsed and normalised, so
729
+ * `/%6eewsletters/<other>`, `//emails/<id>/publish`, `/./newsletters/<other>`
730
+ * and `<own>/../<other>` are seen for what they are. A path still encoded
731
+ * after one decoding is refused outright rather than guessed at.
732
+ */
733
+ function requestPath(url) {
734
+ const decoded = decodeURIComponent(requestUrl(url).pathname);
735
+ if (decoded.includes("%")) {
736
+ throw new Error(`api refuses an encoded path: ${url}`);
737
+ }
738
+ return new URL(decoded.replace(/\/{2,}/g, "/"), API).pathname.toLowerCase();
739
+ }
740
+
741
+ /** Throws unless an image field holds an absolute URL, since Buttondown
742
+ * stores a relative one as given and the image then never loads. */
743
+ function assertAbsoluteUrl(field, value) {
744
+ if (!URL.canParse(value)) {
745
+ throw new Error(`${field} must be an absolute URL, got `
746
+ + JSON.stringify(value));
747
+ }
748
+ }
749
+
750
+ /** newsletter.json, parsed and with its image fields checked. */
751
+ async function readNewsletter(settings) {
752
+ const file = path.join(settings.dir, "newsletter.json");
753
+ let payload;
754
+ try {
755
+ payload = JSON.parse(await readFile(file, "utf8"));
756
+ } catch (error) {
757
+ throw new Error(`${file}: ${error.message}`, {cause: error});
758
+ }
759
+ for (const field of IMAGE_FIELDS) {
760
+ if (payload[field]) assertAbsoluteUrl(field, payload[field]);
761
+ }
762
+ return payload;
763
+ }
764
+
689
765
  /** Why a call failed: Buttondown's `detail`, else its whole body, which
690
766
  * names the fields it refused, else the status. */
691
767
  function detail(res) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casomoltd/tooling",
3
- "version": "0.34.2",
3
+ "version": "0.36.0",
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": {