@casomoltd/tooling 0.31.3 → 0.32.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/README.md +2 -0
- package/bin/buttondown.mjs +94 -0
- package/bin/utils.mjs +1 -0
- package/bin/verify-pack.mjs +1 -0
- package/buttondown/engine.mjs +747 -0
- package/buttondown/index.d.mts +66 -0
- package/buttondown/index.mjs +7 -0
- package/package.json +9 -3
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
|
};
|
package/bin/verify-pack.mjs
CHANGED
|
@@ -0,0 +1,747 @@
|
|
|
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. */
|
|
617
|
+
function enclosing(css, index) {
|
|
618
|
+
let depth = 0;
|
|
619
|
+
for (let i = index; i >= 0; i -= 1) {
|
|
620
|
+
if (css[i] === "}") depth += 1;
|
|
621
|
+
if (css[i] === "{") {
|
|
622
|
+
if (depth === 0) {
|
|
623
|
+
const head = css.slice(0, i);
|
|
624
|
+
return head.slice(Math.max(head.lastIndexOf("}"),
|
|
625
|
+
head.lastIndexOf(";"), head.lastIndexOf("{")) + 1).trim();
|
|
626
|
+
}
|
|
627
|
+
depth -= 1;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return "";
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** The one TOKENS block's bounds; anything else is refused, since a
|
|
634
|
+
* missing, repeated or reversed pair would slice the wrong text. */
|
|
635
|
+
function markers(web) {
|
|
636
|
+
const s = web.indexOf(START);
|
|
637
|
+
const e = web.indexOf(END);
|
|
638
|
+
if (s === -1 || e === -1 || e < s
|
|
639
|
+
|| web.indexOf(START, s + 1) !== -1 || web.indexOf(END, e + 1) !== -1) {
|
|
640
|
+
throw new Error("the web skin needs exactly one TOKENS block, start "
|
|
641
|
+
+ "before end");
|
|
642
|
+
}
|
|
643
|
+
return {s, e: e + END.length};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function longHex(hex) {
|
|
647
|
+
const h = hex.slice(1).toLowerCase();
|
|
648
|
+
return h.length <= 4 ? `#${[...h].map((c) => c + c).join("")}` : `#${h}`;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Each rule as [selector, body], selectors whitespace-normalised. A rule
|
|
652
|
+
* inside an at-rule takes the text after the at-rule's own brace. */
|
|
653
|
+
function rules(text) {
|
|
654
|
+
return stripComments(text).split("}").flatMap((chunk) => {
|
|
655
|
+
const brace = chunk.lastIndexOf("{");
|
|
656
|
+
if (brace === -1) return [];
|
|
657
|
+
const head = chunk.slice(0, brace);
|
|
658
|
+
const selector = head.slice(head.lastIndexOf("{") + 1)
|
|
659
|
+
.split(/\s+/).join(" ").trim();
|
|
660
|
+
return [[selector, chunk.slice(brace + 1)]];
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** Each declaration in a stylesheet as [property, value]. */
|
|
665
|
+
function declarations(text) {
|
|
666
|
+
return rules(text).flatMap(([, body]) => body.split(";").flatMap((decl) => {
|
|
667
|
+
const at = decl.indexOf(":");
|
|
668
|
+
return at === -1
|
|
669
|
+
? []
|
|
670
|
+
: [[decl.slice(0, at).trim(), decl.slice(at + 1).trim()]];
|
|
671
|
+
}));
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Declaration values only, so a selector such as `a:not(#cafe)` is not
|
|
675
|
+
* read as a colour. */
|
|
676
|
+
const cssValues = (text) =>
|
|
677
|
+
declarations(text).map(([, value]) => value).join(" ");
|
|
678
|
+
|
|
679
|
+
/** An SVG's attribute values. */
|
|
680
|
+
const attributeValues = (text) =>
|
|
681
|
+
[...text.matchAll(/="([^"]*)"/g)].map((m) => m[1]).join(" ");
|
|
682
|
+
|
|
683
|
+
function offPalette(values, palette) {
|
|
684
|
+
return [...new Set(
|
|
685
|
+
[...values.matchAll(/#[0-9a-f]{3,8}\b/gi)]
|
|
686
|
+
.map((m) => m[0])
|
|
687
|
+
.filter((hex) => !palette.has(longHex(hex))),
|
|
688
|
+
)];
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function hides(text) {
|
|
692
|
+
return rules(text)
|
|
693
|
+
.filter(([, body]) => body.split(";").some((decl) =>
|
|
694
|
+
/^(display:none|font-size:0)(!important)?$/.test(
|
|
695
|
+
decl.replace(/\s/g, ""),
|
|
696
|
+
)))
|
|
697
|
+
.map(([selector]) => selector);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function injections(text) {
|
|
701
|
+
return rules(text)
|
|
702
|
+
.filter(([selector, body]) => !selector.startsWith("@")
|
|
703
|
+
&& body.split(";").some((decl) => {
|
|
704
|
+
// Anchored: justify-, align- and place-content end in content too.
|
|
705
|
+
const m = decl.match(/^\s*content\s*:\s*(.*?)\s*(!important)?\s*$/);
|
|
706
|
+
return m && !NO_CONTENT.has(m[1]);
|
|
707
|
+
}))
|
|
708
|
+
.map(([selector]) => selector);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Every href attribute selector the skin keys on that names no socials
|
|
712
|
+
* URL, quoted either way or unquoted. */
|
|
713
|
+
function orphanHrefs(text, urls) {
|
|
714
|
+
const matches = {
|
|
715
|
+
"=": (url, v) => url === v,
|
|
716
|
+
"~=": (url, v) => url.split(/\s+/).includes(v),
|
|
717
|
+
"|=": (url, v) => url === v || url.startsWith(`${v}-`),
|
|
718
|
+
"*=": (url, v) => url.includes(v),
|
|
719
|
+
"^=": (url, v) => url.startsWith(v),
|
|
720
|
+
"$=": (url, v) => url.endsWith(v),
|
|
721
|
+
};
|
|
722
|
+
return [...stripComments(text).matchAll(
|
|
723
|
+
/\[\s*href\s*([~|*^$]?=)\s*(?:"([^"]*)"|'([^']*)'|([^\s\]]+))(?:\s+[is])?\s*\]/gi,
|
|
724
|
+
)]
|
|
725
|
+
.filter(([, op, d, s, bare]) =>
|
|
726
|
+
!urls.some((url) => matches[op](url, d ?? s ?? bare)))
|
|
727
|
+
.map(([whole]) => whole);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Each tag in tags.json whose live fields differ, keyed by id rather than
|
|
732
|
+
* name: a tag's name is the label a subscriber reads in their preferences,
|
|
733
|
+
* so renaming one is an ordinary edit, and a lookup by name would miss on
|
|
734
|
+
* exactly that edit. The name is the fallback for a tag whose id is not
|
|
735
|
+
* written in yet; a tag found by neither comes back without a `tagId`.
|
|
736
|
+
*/
|
|
737
|
+
async function tagDrift(settings, api) {
|
|
738
|
+
const file = path.join(settings.dir, "tags.json");
|
|
739
|
+
if (!existsSync(file)) return [];
|
|
740
|
+
const live = await api.list("/tags");
|
|
741
|
+
return JSON.parse(await readFile(file, "utf8")).flatMap(({id, ...body}) => {
|
|
742
|
+
const current = live.find((t) => t.id === id)
|
|
743
|
+
?? (id ? undefined : live.find((t) => t.name === body.name));
|
|
744
|
+
if (current && same("tag", current, body)) return [];
|
|
745
|
+
return [{tagId: current?.id, body}];
|
|
746
|
+
});
|
|
747
|
+
}
|
|
@@ -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>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@casomoltd/tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.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": {
|
|
@@ -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",
|