@casomoltd/tooling 0.41.0 → 0.42.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.
@@ -8,9 +8,11 @@
8
8
  * buttondown [diff] what live differs in: fields and tags
9
9
  * buttondown apply patch what differs
10
10
  * buttondown check gate the skins and settings (no key)
11
+ * buttondown check <file.md> gate one post's copy (no key)
11
12
  * buttondown tokens rewrite the web skin's TOKENS block
12
13
  * buttondown emit <dir> write each long field out as rendered
13
- * buttondown draft <file.md> create or replace a draft
14
+ * buttondown draft <file.md> gate the post, then create or replace
15
+ * its draft
14
16
  * buttondown upload <image> upload an image, print its URL
15
17
  * buttondown api <METHOD> <path> [json] [--send]
16
18
  * any call, for setting up a manual test;
@@ -24,21 +26,48 @@ import {
24
26
  apply, assertApiPath, assertNotSending, client, draft, drift, emit,
25
27
  loadSettings, readKey, runCheck, upload, writeTokens,
26
28
  } from "../buttondown/engine.mjs";
29
+ import {checkPost} from "../buttondown/post-check.mjs";
27
30
 
28
31
  /** The one flag: without it, `api` refuses any call that could send an
29
32
  * email, and no other command takes it. */
30
33
  const SEND_FLAG = "--send";
31
34
 
32
- const USAGE = "usage: buttondown [diff|apply|check|tokens|emit <dir>|"
35
+ const USAGE = "usage: buttondown [diff|apply|check [file.md]|tokens|emit <dir>|"
33
36
  + `draft <file.md>|upload <image>|api <METHOD> <path> [json] [${SEND_FLAG}]]`;
34
37
 
38
+ /** The exit status for a command line that can't be run as written,
39
+ * apart from 1, which every command uses for a refusal or a problem. */
40
+ const EXIT_USAGE = 2;
41
+
42
+ /** The indent `api` prints a response body with. */
43
+ const JSON_INDENT = 2;
44
+
45
+ /** `api` takes a method and a path, and optionally a JSON body. */
46
+ const API_ARGS = ["method", "path", "json"];
47
+
35
48
  /** Each command with the fewest and most arguments it takes. */
36
49
  const ARGS = {
37
- diff: [0, 0], apply: [0, 0], check: [0, 0], tokens: [0, 0],
38
- emit: [1, 1], draft: [1, 1], upload: [1, 1], api: [2, 3],
50
+ diff: [0, 0], apply: [0, 0], check: [0, 1], tokens: [0, 0],
51
+ emit: [1, 1], draft: [1, 1], upload: [1, 1],
52
+ api: [API_ARGS.length - 1, API_ARGS.length],
39
53
  };
40
54
 
41
- const argv = process.argv.slice(2);
55
+ /** Print a post's warnings and problems; true when it may go. */
56
+ async function postPasses(file) {
57
+ const {problems, warnings} = await checkPost(file, {root: process.cwd()});
58
+ for (const w of warnings) console.warn(`buttondown check: warning: ${w}`);
59
+ if (problems.length) {
60
+ console.error(`buttondown check: ${file}:\n ${problems.join("\n ")}`);
61
+ return false;
62
+ }
63
+ console.log(`buttondown check: ${file} clean`);
64
+ return true;
65
+ }
66
+
67
+ /** `node` and this script come first in `process.argv`. */
68
+ const OWN_ARGS_FROM = 2;
69
+
70
+ const argv = process.argv.slice(OWN_ARGS_FROM);
42
71
  const send = argv.includes(SEND_FLAG);
43
72
  const [command = "diff", ...args] = argv.filter((a) => a !== SEND_FLAG);
44
73
  const [fewest, most] = ARGS[command] ?? [];
@@ -46,9 +75,12 @@ const badUsage = fewest === undefined || args.length < fewest
46
75
  || args.length > most || (send && command !== "api");
47
76
  if (badUsage) {
48
77
  console.error(USAGE);
49
- process.exit(2);
78
+ process.exit(EXIT_USAGE);
50
79
  }
51
80
 
81
+ const checksOnePost = command === "check" && args.length > 0;
82
+ if (checksOnePost) process.exit(await postPasses(args[0]) ? 0 : 1);
83
+
52
84
  const settings = await loadSettings();
53
85
  const api = async () => client(settings, await readKey());
54
86
 
@@ -87,6 +119,7 @@ switch (command) {
87
119
  for (const field of await emit(settings, args[0])) console.log(field);
88
120
  break;
89
121
  case "draft":
122
+ if (!await postPasses(args[0])) process.exit(1);
90
123
  await draft(settings, await api(), args[0]);
91
124
  break;
92
125
  case "upload":
@@ -105,7 +138,8 @@ switch (command) {
105
138
  }
106
139
  }
107
140
  const res = await (await api()).call(method.toUpperCase(), url, payload);
108
- console.log(res.status, res.data ? JSON.stringify(res.data, null, 2) : "");
141
+ const shown = res.data ? JSON.stringify(res.data, null, JSON_INDENT) : "";
142
+ console.log(res.status, shown);
109
143
  process.exit(res.ok ? 0 : 1);
110
144
  }
111
145
  }
@@ -36,6 +36,7 @@
36
36
  */
37
37
  import {execFileSync} from "node:child_process";
38
38
  import {existsSync, readFileSync, realpathSync, statSync} from "node:fs";
39
+ import path from "node:path";
39
40
  import {pathToFileURL} from "node:url";
40
41
  import {CASOMO_CONFIG_KEYS, loadCasomoConfig} from "./utils.mjs";
41
42
 
@@ -202,9 +203,26 @@ function files(stagedOnly) {
202
203
  && statSync(f).isFile());
203
204
  }
204
205
 
205
- function denied(path) {
206
- if (!existsSync(path)) return [];
207
- return readFileSync(path, "utf8").split("\n")
206
+ /** Where a repo keeps its local denylist unless its settings say. */
207
+ export const DEFAULT_DENYLIST = ".personal-data-denylist";
208
+
209
+ /** The rules and denied names a repo's `personalData` settings give, and the
210
+ * denylist path they were read from (relative paths against `root`). */
211
+ export function personalDataSettings(config, root = process.cwd()) {
212
+ const denylist = path.resolve(root, config.denylist ?? DEFAULT_DENYLIST);
213
+ return {
214
+ rules: [...identityRules(config.allowEmail), ...(config.rules ?? [])],
215
+ names: readDenylist(denylist),
216
+ denylist,
217
+ hasDenylist: existsSync(denylist),
218
+ };
219
+ }
220
+
221
+ /** The names on a local denylist, one per line; `#` starts a comment. No
222
+ * file means no names. */
223
+ export function readDenylist(file) {
224
+ if (!existsSync(file)) return [];
225
+ return readFileSync(file, "utf8").split("\n")
208
226
  .map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
209
227
  }
210
228
 
@@ -229,9 +247,8 @@ export function scan(fileList, read, rules, names) {
229
247
 
230
248
  async function main() {
231
249
  const config = await loadCasomoConfig(CASOMO_CONFIG_KEYS.personalData);
232
- const denylist = config.denylist ?? ".personal-data-denylist";
233
- const rules = [...identityRules(config.allowEmail), ...(config.rules ?? [])];
234
- const names = denied(denylist);
250
+ const denylist = config.denylist ?? DEFAULT_DENYLIST;
251
+ const {rules, names} = personalDataSettings(config);
235
252
  const allow = (config.allow ?? []).map((a) =>
236
253
  a instanceof RegExp ? a : new RegExp(a));
237
254
 
@@ -0,0 +1,26 @@
1
+ {
2
+ "words": [
3
+ "solution",
4
+ "solutions",
5
+ "innovative",
6
+ "cutting-edge",
7
+ "world-class",
8
+ "best-in-class",
9
+ "unparalleled",
10
+ "passionate",
11
+ "exciting",
12
+ "stakeholder",
13
+ "stakeholders",
14
+ "synergy",
15
+ "empower",
16
+ "empowers",
17
+ "unlock",
18
+ "unlocks",
19
+ "deep dive",
20
+ "reach out",
21
+ "circle back",
22
+ "touch base",
23
+ "let's connect"
24
+ ],
25
+ "inContext": ["leverage", "drive", "bandwidth"]
26
+ }
@@ -0,0 +1,317 @@
1
+ /**
2
+ * The copy gate for one newsletter post, behind `buttondown check
3
+ * <file.md>`; `draft` runs it before it uploads anything. A problem fails
4
+ * the post, a warning is printed and doesn't. The checks and their
5
+ * thresholds are in `docs/buttondown.md`, *What `check` refuses in a post*.
6
+ */
7
+ import {execFile} from "node:child_process";
8
+ import {existsSync, readFileSync} from "node:fs";
9
+ import path from "node:path";
10
+ import {
11
+ findPersonalData, personalDataSettings,
12
+ } from "../bin/check-personal-data.mjs";
13
+ import {
14
+ CASOMO_CONFIG_KEYS, loadCasomoConfig, loadConfig,
15
+ } from "../bin/utils.mjs";
16
+ import {
17
+ DEFAULT_MAX_GRADE, DEFAULT_WORD_SUBS, scoreText,
18
+ } from "../readability/index.mjs";
19
+
20
+ /** The banned-word list: `words` are checked, `inContext` words are wrong
21
+ * in one sense only, which a word match can't tell, so they are not. */
22
+ export const BANNED = JSON.parse(readFileSync(
23
+ new URL("banned-words.json", import.meta.url), "utf8"));
24
+
25
+ const FRONT_MATTER = /^---\n([\s\S]*?)\n---\n/;
26
+ /** The line that opens a post's body names its subject. */
27
+ const HEADING = "# ";
28
+
29
+ /** An em dash, as the character or as any HTML entity for it. */
30
+ const EM_DASH = /—|&mdash;|&#8212;|&#x2014;/i;
31
+
32
+ /** The HTML elements a reader takes as a unit of their own. */
33
+ const BLOCK_END = /<\/(?:p|li|h[1-6]|figcaption|td|th|div|blockquote)>/gi;
34
+ /** A markdown line that stands alone: a heading, list item or table row. */
35
+ const BLOCK_LINE = /^[ \t]*(?:#+ |[-*+] |\d+\. |\|)/;
36
+ /** How a sentence ends, for the readability formulas. */
37
+ const ENDS_SENTENCE = /[.!?:;]\s*$/;
38
+ /** A block that isn't running prose: a heading, list, table, quote,
39
+ * caption or emphasis line, image or HTML block. */
40
+ const NOT_PARAGRAPH = /^(?:[#\-*+>|<!]|\d+\.)/;
41
+
42
+ /** The names cspell looks for its config under, in its own order. */
43
+ const CSPELL_CONFIGS = [
44
+ ".cspell.json", "cspell.json", ".cSpell.json", "cSpell.json",
45
+ "cspell.config.json", "cspell.config.mjs", "cspell.config.js",
46
+ "cspell.config.yaml", "cspell.yaml",
47
+ ];
48
+ /** cspell's exit status when it found unknown words, and nothing worse. */
49
+ const CSPELL_FOUND = 1;
50
+
51
+ /** A link that is gone for good: the page was removed, not mislaid. */
52
+ const NOT_FOUND = 404;
53
+ const GONE_FOR_GOOD = 410;
54
+ const GONE = new Set([NOT_FOUND, GONE_FOR_GOOD]);
55
+ /** A status from the publisher's side, which may clear on a retry. */
56
+ const SERVER_ERROR = 500;
57
+ /** A refusal from the publisher (403, 429), which says nothing either way
58
+ * about the page. */
59
+ const CLIENT_ERROR = 400;
60
+ /** How long one fetch of a link may take. Proposed here; no external
61
+ * source. */
62
+ const LINK_TIMEOUT_MS = 10_000;
63
+
64
+ /**
65
+ * Every problem and warning for the post at `file`, checked as the repo
66
+ * at `root` configures it: `root` picks whose word lists, ceiling and
67
+ * personal-data rules apply, so it has no default. `fetchImpl` is the link
68
+ * fetcher, so a test runs without the network.
69
+ */
70
+ export async function checkPost(file, {root, fetchImpl = fetch}) {
71
+ if (!root) throw new Error("checkPost needs the repo root to check against");
72
+ const raw = readFileSync(file, "utf8");
73
+ const post = parsePost(raw);
74
+ const config = await loadConfig("readability.config.mjs", root);
75
+ return merge([
76
+ readabilityProblems(post, {
77
+ maxGrade: config.maxGrade ?? DEFAULT_MAX_GRADE,
78
+ wordSubs: config.wordSubs ?? DEFAULT_WORD_SUBS,
79
+ }),
80
+ emDashProblems(post),
81
+ bannedProblems(post),
82
+ await personalDataProblems(raw, root),
83
+ await spellingProblems(raw, root),
84
+ await linkProblems(post, fetchImpl),
85
+ ]);
86
+ }
87
+
88
+ /** A post split into the fields the gate reads. Like `draft`, it refuses a
89
+ * post whose body doesn't open on `# Subject`. */
90
+ export function parsePost(raw) {
91
+ const m = raw.match(FRONT_MATTER);
92
+ const fields = Object.fromEntries((m?.[1] ?? "").split("\n")
93
+ .filter((line) => line.includes(":"))
94
+ .map((line) => {
95
+ const at = line.indexOf(":");
96
+ return [line.slice(0, at).trim(), line.slice(at + 1).trim()];
97
+ }));
98
+ const lines = (m ? raw.slice(m[0].length) : raw).trimStart().split("\n");
99
+ if (!lines[0]?.startsWith(HEADING)) {
100
+ throw new Error("after any front matter, the first line must be `# Subject`");
101
+ }
102
+ return {
103
+ subject: lines[0].slice(HEADING.length).trim(),
104
+ description: fields.description ?? "",
105
+ image: fields.image ?? "",
106
+ body: lines.slice(1).join("\n"),
107
+ };
108
+ }
109
+
110
+ /** The words a reader reads: markup, code and URLs taken out, link text
111
+ * and alt text kept. */
112
+ export function proseOf(markdown) {
113
+ const blocks = endBlocks(withoutCode(stripSpans(markdown, "<!--", "-->")));
114
+ return stripSpans(blocks, "<", ">")
115
+ .replace(/\]\([^)\s]*\)/g, " ")
116
+ .replace(/!?\[/g, " ")
117
+ .replace(/https?:\/\/\S+/g, " ")
118
+ .replace(/&[a-z]+;/g, " ")
119
+ .replace(/^[ \t]*(?:#+|>|[-*+]|\d+\.|\|)/gm, "")
120
+ .replace(/[*_|]/g, " ");
121
+ }
122
+
123
+ /** The body's paragraphs: the blank-line-separated blocks of running text.
124
+ * The formulas score sentences, and a caption or list item dense with
125
+ * figures and titles reads as a hard sentence when it isn't one. */
126
+ export function paragraphsOf(body) {
127
+ return body.split(/\n[ \t]*\n/).map((block) => block.trim())
128
+ .filter((block) => block && !NOT_PARAGRAPH.test(block))
129
+ .join("\n\n");
130
+ }
131
+
132
+ /** Every http(s) URL the post links to or loads, once each; an example in
133
+ * code is not a link. */
134
+ export function linksOf(markdown) {
135
+ const text = withoutCode(markdown);
136
+ const found = [
137
+ ...text.matchAll(/\]\((https?:\/\/[^)\s]+)\)/g),
138
+ ...text.matchAll(/(?:href|src)="(https?:\/\/[^"]+)"/g),
139
+ ...text.matchAll(/<(https?:\/\/[^>\s]+)>/g),
140
+ ].map((m) => m[1]);
141
+ return [...new Set(found)];
142
+ }
143
+
144
+ /** Each banned word or phrase the prose uses, once. A typographic
145
+ * apostrophe matches a straight one. */
146
+ export function bannedIn(text, words = BANNED.words) {
147
+ const plain = text.replace(/’/g, "'");
148
+ return words.filter((w) => new RegExp(
149
+ `(?<![\\w-])${w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w-])`, "i",
150
+ ).test(plain));
151
+ }
152
+
153
+ const merge = (parts) => ({
154
+ problems: parts.flatMap((p) => p.problems ?? []),
155
+ warnings: parts.flatMap((p) => p.warnings ?? []),
156
+ });
157
+
158
+ /** `text` with every span from `open` to the next `close` taken out. A
159
+ * scan rather than a lazy regex, which backtracks on an unclosed span. */
160
+ function stripSpans(text, open, close) {
161
+ let out = "";
162
+ let at = 0;
163
+ for (let start = text.indexOf(open); start !== -1;
164
+ start = text.indexOf(open, at)) {
165
+ const end = text.indexOf(close, start + open.length);
166
+ if (end === -1) break;
167
+ out += `${text.slice(at, start)} `;
168
+ at = end + close.length;
169
+ }
170
+ return out + text.slice(at);
171
+ }
172
+
173
+ /** `text` with fenced and inline code taken out. */
174
+ const withoutCode = (text) => stripSpans(stripSpans(text, "```", "```"), "`", "`");
175
+
176
+ /** Each block ended as a sentence, so the formulas don't read a list or a
177
+ * run of headings as one long sentence; a reader takes each on its own. */
178
+ function endBlocks(text) {
179
+ return text.replace(BLOCK_END, ". ").split("\n")
180
+ .map((line) => BLOCK_LINE.test(line) && !ENDS_SENTENCE.test(line)
181
+ ? `${line}.` : line)
182
+ .join("\n");
183
+ }
184
+
185
+ function readabilityProblems(post, {maxGrade, wordSubs}) {
186
+ const result = scoreText(proseOf(paragraphsOf(post.body)), wordSubs);
187
+ if (result.unscored) {
188
+ return {warnings: [`readability: ${result.wordCount} words, too few to score`]};
189
+ }
190
+ return result.consensusGrade > maxGrade
191
+ ? {problems: [`readability: grade ${result.consensusGrade}, above ${maxGrade}`]}
192
+ : {};
193
+ }
194
+
195
+ /** Checked on the text as written, code aside, so an entity counts too. */
196
+ function emDashProblems(post) {
197
+ const where = [
198
+ ["subject", post.subject], ["description", post.description],
199
+ ["body", withoutCode(post.body)],
200
+ ].filter(([, text]) => EM_DASH.test(text)).map(([field]) => field);
201
+ return where.length
202
+ ? {problems: [`em dash in the ${where.join(", ")}: use a full stop, colon, `
203
+ + "semicolon, comma or brackets"]}
204
+ : {};
205
+ }
206
+
207
+ function bannedProblems(post) {
208
+ const used = bannedIn(proseOf(
209
+ [post.subject, post.description, post.body].join("\n")));
210
+ return used.length ? {problems: [`banned words: ${used.join(", ")}`]} : {};
211
+ }
212
+
213
+ async function personalDataProblems(raw, root) {
214
+ const config = await loadCasomoConfig(CASOMO_CONFIG_KEYS.personalData, root);
215
+ const {rules, names, denylist, hasDenylist} = personalDataSettings(config, root);
216
+ const hits = findPersonalData(raw, rules, names);
217
+ return {
218
+ problems: hits.map((h) => `personal data, line ${h.line}: ${h.rule}`),
219
+ warnings: hasDenylist ? []
220
+ : [`personal data: no ${path.relative(root, denylist)}; name checking off`],
221
+ };
222
+ }
223
+
224
+ /** Unknown words the repo's cspell reports, read from stdin as markdown.
225
+ * Any failure of cspell itself is a problem: a check that didn't run must
226
+ * not read as clean. */
227
+ async function spellingProblems(raw, root) {
228
+ const bin = path.join(root, "node_modules", ".bin", "cspell");
229
+ if (!existsSync(bin)) {
230
+ return {problems: ["spelling: cspell is not installed in this repo"]};
231
+ }
232
+ const config = spellConfig(root);
233
+ if (config.missing) {
234
+ return {problems: [`spelling: the spell script names ${config.missing}, `
235
+ + "which doesn't exist"]};
236
+ }
237
+ const run = await outputOf(bin, [
238
+ "lint", "--no-progress", "--no-summary", "--no-color",
239
+ ...(config.file ? ["--config", config.file] : []),
240
+ "--language-id", "markdown", "stdin",
241
+ ], {cwd: root, input: raw});
242
+ const unknown = [...run.stdout.matchAll(/Unknown word \(([^)]+)\)/g)]
243
+ .map((m) => m[1]);
244
+ if (run.code === 0) return {};
245
+ if (run.code === CSPELL_FOUND && unknown.length) {
246
+ return {problems: [`spelling: ${[...new Set(unknown)].join(", ")}`]};
247
+ }
248
+ const why = (run.stderr.trim().split("\n")[0] || run.stdout.trim()) || "no output";
249
+ return {problems: [`spelling: cspell failed (${run.code}): ${why}`]};
250
+ }
251
+
252
+ /** The cspell config the repo's own `spell` script names, so a post is
253
+ * checked against the words its pages are; else the first file cspell's
254
+ * own search would find. Read from stdin, cspell searches for neither. */
255
+ function spellConfig(root) {
256
+ const pkg = path.join(root, "package.json");
257
+ const script = existsSync(pkg)
258
+ ? JSON.parse(readFileSync(pkg, "utf8")).scripts?.spell ?? "" : "";
259
+ const named = script.match(/(?:^|\s)(?:-c|--config)[\s=]+(\S+)/)?.[1];
260
+ if (named) {
261
+ const file = path.join(root, named.replace(/^["']|["']$/g, ""));
262
+ return existsSync(file) ? {file} : {missing: named};
263
+ }
264
+ return {file: CSPELL_CONFIGS.map((f) => path.join(root, f))
265
+ .find((f) => existsSync(f))};
266
+ }
267
+
268
+ /** What a command prints and how it exits, given `input` on stdin. A spawn
269
+ * failure comes back as a non-numeric code, never as a throw. */
270
+ const outputOf = (bin, args, {cwd, input}) => new Promise((resolve) => {
271
+ const child = execFile(bin, args, {cwd}, (error, stdout, stderr) => resolve({
272
+ code: error ? error.code ?? "error" : 0, stdout: String(stdout ?? ""),
273
+ stderr: String(stderr ?? ""),
274
+ }));
275
+ child.stdin.on("error", () => {});
276
+ child.stdin.end(input);
277
+ });
278
+
279
+ /** Every link in the body, and the share image, fetched. When every one
280
+ * failed for a reason a retry might clear, the check didn't run, which is
281
+ * said once rather than per link. */
282
+ async function linkProblems(post, fetchImpl) {
283
+ const urls = [...new Set([
284
+ ...linksOf(post.body), ...(/^https?:\/\//.test(post.image) ? [post.image] : []),
285
+ ])];
286
+ const outcomes = await Promise.all(urls.map((url) => linkOutcome(url, fetchImpl)));
287
+ if (urls.length && outcomes.every((o) => o.transient)) {
288
+ return {warnings: [`links: none of ${urls.length} could be checked (network?)`]};
289
+ }
290
+ return merge(outcomes);
291
+ }
292
+
293
+ async function linkOutcome(url, fetchImpl) {
294
+ let r = await fetchStatus(url, fetchImpl);
295
+ if (transient(r)) r = await fetchStatus(url, fetchImpl);
296
+ if (GONE.has(r.status)) return {problems: [`link ${r.status}: ${url}`]};
297
+ if (transient(r)) {
298
+ return {transient: true,
299
+ warnings: [`link ${r.status ?? r.error} after a retry: ${url}`]};
300
+ }
301
+ return r.status >= CLIENT_ERROR ? {warnings: [`link ${r.status}: ${url}`]} : {};
302
+ }
303
+
304
+ const transient = (r) => r.error || r.status >= SERVER_ERROR;
305
+
306
+ /** One link's status, or the error that stopped it. */
307
+ async function fetchStatus(url, fetchImpl) {
308
+ try {
309
+ const res = await fetchImpl(url, {
310
+ redirect: "follow", signal: AbortSignal.timeout(LINK_TIMEOUT_MS),
311
+ });
312
+ await res.body?.cancel();
313
+ return {status: res.status};
314
+ } catch (error) {
315
+ return {error: error?.name ?? String(error)};
316
+ }
317
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casomoltd/tooling",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "Casomo's shared dev config: ESLint, Prettier, commitlint, knip & jscpd presets, dev CLIs (git hooks, readability, screenshots, design specs), and the build-time data and helpers sites import (the Buttondown engine, the shared Organization)",
5
5
  "author": "Casomo Ltd",
6
6
  "license": "MIT",
@@ -28,7 +28,7 @@
28
28
  "check": "node bin/check-gates.mjs && node bin/verify-pack.mjs && npm run check:private-refs && npm run check:personal-data && npm run lint && npm run typecheck && npm run test && npm run lint:md",
29
29
  "lint": "eslint .",
30
30
  "typecheck": "tsc --noEmit -p hooks/tsconfig.json && tsc --noEmit -p identity/tsconfig.json",
31
- "test": "node hooks/test.mts && node bin/check-gates.test.mjs && node bin/check-listed-files.test.mjs && node bin/check-personal-data.test.mjs && node bin/check-routes.test.mjs && node bin/casomo-config.test.mjs && node bin/buttondown.test.mjs && node commitlint/index.test.mjs && node readability/index.test.mjs && node identity/index.test.mjs",
31
+ "test": "node hooks/test.mts && node bin/check-gates.test.mjs && node bin/check-listed-files.test.mjs && node bin/check-personal-data.test.mjs && node bin/check-routes.test.mjs && node bin/casomo-config.test.mjs && node bin/buttondown.test.mjs && node buttondown/post-check.test.mjs && node commitlint/index.test.mjs && node readability/index.test.mjs && node identity/index.test.mjs",
32
32
  "lint:md": "remark --frail --quiet --no-stdout --rc-path bin/skills-lint.config.mjs skills agents docs",
33
33
  "build": "echo 'no-op: source-only repo, nothing to build'",
34
34
  "prepare": "husky || true",
@@ -55,6 +55,7 @@
55
55
  "types": "./buttondown/index.d.mts",
56
56
  "default": "./buttondown/index.mjs"
57
57
  },
58
+ "./banned-words": "./buttondown/banned-words.json",
58
59
  "./identity": {
59
60
  "types": "./identity/index.d.mts",
60
61
  "default": "./identity/index.mjs"