@casomoltd/tooling 0.40.1 → 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.
- package/bin/buttondown.mjs +41 -7
- package/bin/check-personal-data.mjs +23 -6
- package/buttondown/banned-words.json +26 -0
- package/buttondown/post-check.mjs +317 -0
- package/identity/organization.mjs +3 -0
- package/package.json +4 -2
package/bin/buttondown.mjs
CHANGED
|
@@ -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
|
|
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,
|
|
38
|
-
emit: [1, 1], draft: [1, 1], upload: [1, 1],
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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 ??
|
|
233
|
-
const 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 = /—|—|—|—/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
|
+
}
|
|
@@ -25,6 +25,9 @@ export const CASOMO_ORGANIZATION = Object.freeze({
|
|
|
25
25
|
"https://casomo.co.uk",
|
|
26
26
|
"https://www.facebook.com/casomouk",
|
|
27
27
|
"https://www.linkedin.com/company/111967567/",
|
|
28
|
+
// Where the company's open-source libraries are developed and published.
|
|
29
|
+
"https://github.com/casomoltd",
|
|
30
|
+
"https://www.npmjs.com/org/casomoltd",
|
|
28
31
|
]),
|
|
29
32
|
});
|
|
30
33
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@casomoltd/tooling",
|
|
3
|
-
"version": "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
|
+
"author": "Casomo Ltd",
|
|
5
6
|
"license": "MIT",
|
|
6
7
|
"repository": {
|
|
7
8
|
"type": "git",
|
|
@@ -27,7 +28,7 @@
|
|
|
27
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",
|
|
28
29
|
"lint": "eslint .",
|
|
29
30
|
"typecheck": "tsc --noEmit -p hooks/tsconfig.json && tsc --noEmit -p identity/tsconfig.json",
|
|
30
|
-
"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",
|
|
31
32
|
"lint:md": "remark --frail --quiet --no-stdout --rc-path bin/skills-lint.config.mjs skills agents docs",
|
|
32
33
|
"build": "echo 'no-op: source-only repo, nothing to build'",
|
|
33
34
|
"prepare": "husky || true",
|
|
@@ -54,6 +55,7 @@
|
|
|
54
55
|
"types": "./buttondown/index.d.mts",
|
|
55
56
|
"default": "./buttondown/index.mjs"
|
|
56
57
|
},
|
|
58
|
+
"./banned-words": "./buttondown/banned-words.json",
|
|
57
59
|
"./identity": {
|
|
58
60
|
"types": "./identity/index.d.mts",
|
|
59
61
|
"default": "./identity/index.mjs"
|