@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.
- package/bin/buttondown.mjs +27 -10
- package/buttondown/engine.mjs +98 -22
- package/package.json +1 -1
package/bin/buttondown.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
+
|
|
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
|
|
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
|
-
|
|
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
|
|
89
|
-
|
|
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
|
}
|
package/buttondown/engine.mjs
CHANGED
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
* },
|
|
28
28
|
* };
|
|
29
29
|
*
|
|
30
|
-
* `newsletter.json` holds the newsletter's short fields
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
463
|
-
|
|
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
|
|
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.
|
|
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": {
|