@casomoltd/tooling 0.35.0 → 0.36.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,19 +12,25 @@
12
12
  * buttondown emit <dir> write each long field out as rendered
13
13
  * buttondown draft <file.md> create or replace a draft
14
14
  * buttondown upload <image> upload an image, print its URL
15
- * buttondown api <METHOD> <path> [json]
16
- * any call, for setting up a manual test
15
+ * buttondown api <METHOD> <path> [json] [--send]
16
+ * any call, for setting up a manual test;
17
+ * one that sends or schedules an email
18
+ * is refused without --send
17
19
  *
18
20
  * `diff`, `apply` and `check` exit 1 on drift, a refusal or a problem, so a
19
21
  * script can gate on them.
20
22
  */
21
23
  import {
22
- apply, assertApiPath, client, draft, drift, emit, loadSettings, readKey,
23
- runCheck, upload, writeTokens,
24
+ apply, assertApiPath, assertNotSending, client, draft, drift, emit,
25
+ loadSettings, readKey, runCheck, upload, writeTokens,
24
26
  } from "../buttondown/engine.mjs";
25
27
 
28
+ /** The one flag: without it, `api` refuses any call that could send an
29
+ * email, and no other command takes it. */
30
+ const SEND_FLAG = "--send";
31
+
26
32
  const USAGE = "usage: buttondown [diff|apply|check|tokens|emit <dir>|"
27
- + "draft <file.md>|upload <image>|api <METHOD> <path> [json]]";
33
+ + `draft <file.md>|upload <image>|api <METHOD> <path> [json] [${SEND_FLAG}]]`;
28
34
 
29
35
  /** Each command with the fewest and most arguments it takes. */
30
36
  const ARGS = {
@@ -32,9 +38,13 @@ const ARGS = {
32
38
  emit: [1, 1], draft: [1, 1], upload: [1, 1], api: [2, 3],
33
39
  };
34
40
 
35
- const [command = "diff", ...args] = process.argv.slice(2);
41
+ const argv = process.argv.slice(2);
42
+ const send = argv.includes(SEND_FLAG);
43
+ const [command = "diff", ...args] = argv.filter((a) => a !== SEND_FLAG);
36
44
  const [fewest, most] = ARGS[command] ?? [];
37
- if (fewest === undefined || args.length < fewest || args.length > most) {
45
+ const badUsage = fewest === undefined || args.length < fewest
46
+ || args.length > most || (send && command !== "api");
47
+ if (badUsage) {
38
48
  console.error(USAGE);
39
49
  process.exit(2);
40
50
  }
@@ -85,9 +95,16 @@ switch (command) {
85
95
  case "api": {
86
96
  const [method, url, body] = args;
87
97
  assertApiPath(settings, method, url);
88
- const res = await (await api()).call(
89
- method.toUpperCase(), url, body && JSON.parse(body),
90
- );
98
+ const payload = body && JSON.parse(body);
99
+ if (!send) {
100
+ try {
101
+ assertNotSending(method, url, payload);
102
+ } catch (e) {
103
+ console.error(`buttondown: ${e.message}; add ${SEND_FLAG} to send it`);
104
+ process.exit(1);
105
+ }
106
+ }
107
+ const res = await (await api()).call(method.toUpperCase(), url, payload);
91
108
  console.log(res.status, res.data ? JSON.stringify(res.data, null, 2) : "");
92
109
  process.exit(res.ok ? 0 : 1);
93
110
  }
@@ -87,6 +87,19 @@ const API_PATH = new URL(API).pathname;
87
87
  /** The status of an email not yet sent. */
88
88
  const DRAFT = "draft";
89
89
 
90
+ /**
91
+ * The api routes that can mail someone, judged on a `requestPath`. A write
92
+ * to the emails collection or to one email may carry a status that sends
93
+ * it, so it passes only as a draft; any action on an email (publish,
94
+ * send-draft and whatever Buttondown adds) or on a subscriber (a reminder,
95
+ * an email to one reader) is refused outright, so a new route fails closed.
96
+ * Creating an unactivated subscriber also mails a confirmation, and stays
97
+ * allowed: it is how a manual test stands in for a reader signing up, to an
98
+ * address the tester owns.
99
+ */
100
+ const EMAIL_WRITE = new RegExp(`^${API_PATH}/emails(/[^/]+)?/?$`);
101
+ const SENDING_ACTION = new RegExp(`^${API_PATH}/(emails|subscribers)/[^/]+/.`);
102
+
90
103
  const FRONT_MATTER = /^---\n([\s\S]*?)\n---\n/;
91
104
 
92
105
  /** The Buttondown fields holding the two skins. */
@@ -387,7 +400,7 @@ export async function readKey(env = process.env) {
387
400
  export function client(settings, key) {
388
401
  const {newsletter} = settings;
389
402
  async function call(method, url, body, {form} = {}) {
390
- const res = await fetch(new URL(API + url), {
403
+ const res = await fetch(requestUrl(url), {
391
404
  method,
392
405
  headers: {
393
406
  Authorization: `Token ${key}`,
@@ -445,25 +458,37 @@ export function client(settings, key) {
445
458
 
446
459
  /**
447
460
  * Refuses an `api` call that writes newsletter settings anywhere but this
448
- * newsletter. Judged on the path the server routes: percent-decoded, then
449
- * normalised, so `/%6eewsletters/<other>`, `/./newsletters/<other>` and
450
- * `<own>/../<other>` are seen for what they are. A path still encoded after
451
- * one decoding is refused outright rather than guessed at.
461
+ * newsletter, judged on the path the server routes (`requestPath`).
452
462
  */
453
463
  export function assertApiPath(settings, method, url) {
454
464
  if (method.toUpperCase() === "GET") return;
455
- const decoded = decodeURIComponent(new URL(API + url).pathname);
456
- if (decoded.includes("%")) {
457
- throw new Error(`api refuses an encoded path: ${url}`);
458
- }
459
- const {pathname} = new URL(decoded, API);
460
- const own = API_PATH + newsletterPath(settings.newsletter);
465
+ const pathname = requestPath(url);
466
+ const own = (API_PATH + newsletterPath(settings.newsletter)).toLowerCase();
461
467
  if (pathname.startsWith(`${API_PATH}/newsletters`)
462
468
  && pathname !== own && !pathname.startsWith(`${own}/`)) {
463
469
  throw new Error(`api writes under /newsletters only to ${own}`);
464
470
  }
465
471
  }
466
472
 
473
+ /**
474
+ * Throws if an api call could send or schedule an email (`EMAIL_WRITE`,
475
+ * `SENDING_ACTION`). Sending reaches real inboxes and cannot be recalled, so
476
+ * the caller lets a call through only on a person's explicit say-so.
477
+ */
478
+ export function assertNotSending(method, url, body) {
479
+ const cannotSend = ["GET", "DELETE"].includes(method.toUpperCase());
480
+ if (cannotSend) return;
481
+ const pathname = requestPath(url);
482
+ const status = body && typeof body === "object" ? body.status : undefined;
483
+ const creates = pathname.replace(/\/$/, "") === `${API_PATH}/emails`;
484
+ const draftOnly = status === DRAFT || (status === undefined && !creates);
485
+ if (SENDING_ACTION.test(pathname)
486
+ || (EMAIL_WRITE.test(pathname) && !draftOnly)) {
487
+ throw new Error(`api refuses a call that could send an email: `
488
+ + `${method.toUpperCase()} ${url}`);
489
+ }
490
+ }
491
+
467
492
  /** Whether a live value already says what the repo wants. */
468
493
  export function same(field, got, want) {
469
494
  if (IMAGE_FIELDS.has(field)) {
@@ -693,6 +718,26 @@ export function stripComments(text) {
693
718
  }
694
719
  }
695
720
 
721
+ /** The URL `call` fetches for an api path, and the one the guards judge. */
722
+ function requestUrl(url) {
723
+ return new URL(API + url);
724
+ }
725
+
726
+ /**
727
+ * The path the server routes for an `api` url, from `requestUrl`,
728
+ * then percent-decoded, slash-collapsed and normalised, so
729
+ * `/%6eewsletters/<other>`, `//emails/<id>/publish`, `/./newsletters/<other>`
730
+ * and `<own>/../<other>` are seen for what they are. A path still encoded
731
+ * after one decoding is refused outright rather than guessed at.
732
+ */
733
+ function requestPath(url) {
734
+ const decoded = decodeURIComponent(requestUrl(url).pathname);
735
+ if (decoded.includes("%")) {
736
+ throw new Error(`api refuses an encoded path: ${url}`);
737
+ }
738
+ return new URL(decoded.replace(/\/{2,}/g, "/"), API).pathname.toLowerCase();
739
+ }
740
+
696
741
  /** Throws unless an image field holds an absolute URL, since Buttondown
697
742
  * stores a relative one as given and the image then never loads. */
698
743
  function assertAbsoluteUrl(field, value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casomoltd/tooling",
3
- "version": "0.35.0",
3
+ "version": "0.36.1",
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": {
@@ -24,7 +24,7 @@
24
24
  "scripts": {
25
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",
26
26
  "typecheck": "tsc --noEmit -p hooks/tsconfig.json",
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
+ "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 && node readability/index.test.mjs",
28
28
  "lint:md": "remark --frail --quiet --no-stdout --rc-path bin/skills-lint.config.mjs skills agents docs",
29
29
  "build": "echo 'no-op: source-only repo, nothing to build'",
30
30
  "prepare": "husky || true",
@@ -84,7 +84,8 @@
84
84
  "eslint-config-next": ">=16",
85
85
  "eslint-plugin-sonarjs": ">=3",
86
86
  "jscpd": ">=4",
87
- "knip": ">=5"
87
+ "knip": ">=5",
88
+ "typescript": ">=5"
88
89
  },
89
90
  "devDependencies": {
90
91
  "@types/node": "^26.0.0",
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import rs from "text-readability";
10
+ import ts from "typescript";
10
11
  import fs from "node:fs";
11
12
  import path from "node:path";
12
13
 
@@ -17,6 +18,34 @@ export const DEFAULT_WORD_SUBS = {
17
18
  calculator: "tool",
18
19
  };
19
20
 
21
+ const IMPORT_RE = /import\s+(?:type\s+)?(?:[\w*{},\s]+from\s+)?["']([^"']+)["']/g;
22
+
23
+ /** Elements that sit inside a sentence rather than starting one: their
24
+ * text joins the block around them. Everything else is a block, whose
25
+ * text is a sentence or more of its own. Matched on the tag's own text,
26
+ * so a member tag (`<Foo.Link>`) counts as a block. */
27
+ const INLINE_TAGS = new Set([
28
+ "a", "Link", "strong", "em", "b", "i", "u", "span", "code", "abbr",
29
+ "sup", "sub", "small", "mark", "time", "br", "wbr",
30
+ ]);
31
+
32
+ /** What an interpolated value reads as inside a sentence: a figure. */
33
+ const FIGURE = "1";
34
+
35
+ /** The shortest string literal read as prose: shorter ones are labels. */
36
+ const MIN_PROSE_CHARS = 20;
37
+
38
+ /** A run of at least two letters: the least that reads as a word. */
39
+ const HAS_WORD = /[A-Za-z]{2,}/;
40
+
41
+ /** Typographic entities, read as the characters they stand for. */
42
+ const ENTITIES = {
43
+ "&nbsp;": " ", "&amp;": "&", "&lt;": "<", "&gt;": ">",
44
+ "&rsquo;": "\u2019", "&lsquo;": "\u2018", "&rdquo;": "\u201d",
45
+ "&ldquo;": "\u201c", "&ndash;": "\u2013", "&mdash;": "\u2014",
46
+ "&hellip;": "\u2026", "&#39;": "'", "&quot;": "\"",
47
+ };
48
+
20
49
  // -------------------------------------------------------------------
21
50
  // Discover page slugs from an app-router directory
22
51
  // -------------------------------------------------------------------
@@ -72,37 +101,121 @@ function srcDirOf(filePath) {
72
101
  return i === -1 ? null : filePath.slice(0, i + marker.length - 1);
73
102
  }
74
103
 
75
- const IMPORT_RE = /import\s+(?:type\s+)?(?:[\w*{},\s]+from\s+)?["']([^"']+)["']/g;
104
+ /**
105
+ * Prose lifted from ONE file's JSX, without following its imports.
106
+ *
107
+ * Parsed, because a JSX file is mostly code and code is full of `<` and
108
+ * `>` (generics, arrow functions, comparisons): only a parser can tell
109
+ * JSX text from it. What comes back is each block's text, with its links
110
+ * and emphasis folded into the sentence they sit in and each
111
+ * interpolated value read as a figure, plus string literals that read as
112
+ * prose. Those include attribute values, because a component's
113
+ * `description="…"` is copy on the page, and they also include strings a
114
+ * reader never sees (a metadata title, an error message): an
115
+ * approximation of what the page shows, close enough to grade it.
116
+ */
117
+ function extractOwnText(source, fileName) {
118
+ const sf = ts.createSourceFile(
119
+ fileName, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX,
120
+ );
121
+ const out = [];
76
122
 
77
- /** Prose lifted from ONE file's JSX, without following its imports. */
78
- function extractOwnText(source) {
79
- const jsxText = [];
80
-
81
- // Tag content. `{` is ALLOWED inside the match and the expressions
82
- // are stripped further down. Excluding it dropped the whole sentence
83
- // rather than the interpolation, so any served line carrying a
84
- // figure — most of the prose on a pay page — was invisible here.
85
- const tagContentRe = /&gt;([^&lt;&gt;]+)&lt;/g;
86
- let match;
87
- while ((match = tagContentRe.exec(source)) !== null) {
88
- const text = match[1].replace(/\{[^}]*\}/g, " ").trim();
89
- if (text) jsxText.push(text);
90
- }
123
+ const tagOf = (el) => {
124
+ const opening = ts.isJsxElement(el) ? el.openingElement : el;
125
+ return opening.tagName.getText(sf);
126
+ };
127
+ const isInline = (el) =>
128
+ (ts.isJsxElement(el) || ts.isJsxSelfClosingElement(el))
129
+ && INLINE_TAGS.has(tagOf(el));
91
130
 
92
- // String literals that look like readable prose. "Look like" is
93
- // doing real work: an SVG `d` attribute begins with a capital M and
94
- // runs for thousands of characters, and counting one as a sentence
95
- // put a whole article three grades above its true score.
96
- const stringRe = /['"`]([A-Z][^'"`]{20,})['"`]/g;
97
- while ((match = stringRe.exec(source)) !== null) {
98
- const candidate = match[1].trim();
99
- if (isProse(candidate)) jsxText.push(candidate);
100
- }
101
- // NOT filtered as a whole: tag content is legitimately fragmentary,
102
- // because a formatter wraps one served sentence across several
103
- // lines. Only string literals need the prose test, and only they can
104
- // contain an SVG path.
105
- return jsxText;
131
+ // The text a block reads as: its own text, its inline children's,
132
+ // and a figure for each interpolation. Block children are left out:
133
+ // the walk below scores each as its own block.
134
+ const inlineText = (children) => children.map((child) => {
135
+ if (ts.isJsxText(child)) return child.text;
136
+ if (ts.isJsxExpression(child)) {
137
+ return child.expression ? expressionText(child.expression) : "";
138
+ }
139
+ if (isInline(child)) {
140
+ return ts.isJsxElement(child) ? inlineText(child.children) : " ";
141
+ }
142
+ return "";
143
+ }).join("");
144
+
145
+ // What an interpolation reads as. A string is its text; a conditional
146
+ // reads as its first branch, the one a reader sees by default; inline
147
+ // JSX joins the sentence; block JSX is scored on its own by the walk
148
+ // below; anything else is a value, read as a figure.
149
+ const expressionText = (e) => {
150
+ if (ts.isParenthesizedExpression(e)) return expressionText(e.expression);
151
+ if (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) {
152
+ return e.text;
153
+ }
154
+ if (ts.isConditionalExpression(e)) return expressionText(e.whenTrue);
155
+ if (ts.isBinaryExpression(e)
156
+ && e.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) {
157
+ return expressionText(e.right);
158
+ }
159
+ if (isInline(e)) {
160
+ return ts.isJsxElement(e) ? inlineText(e.children) : " ";
161
+ }
162
+ return containsJsx(e) ? "" : ` ${FIGURE} `;
163
+ };
164
+
165
+ const containsJsx = (node) => {
166
+ let found = false;
167
+ const look = (n) => {
168
+ if (found) return;
169
+ if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n)
170
+ || ts.isJsxFragment(n)) {
171
+ found = true;
172
+ return;
173
+ }
174
+ ts.forEachChild(n, look);
175
+ };
176
+ look(node);
177
+ return found;
178
+ };
179
+
180
+ // A run of strings joined with `+`, read as the one sentence it is.
181
+ const stringOf = (node) => {
182
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
183
+ return node.text;
184
+ }
185
+ if (ts.isTemplateExpression(node)) {
186
+ return node.head.text + node.templateSpans
187
+ .map((span) => ` ${FIGURE} ${span.literal.text}`).join("");
188
+ }
189
+ if (ts.isParenthesizedExpression(node)) return stringOf(node.expression);
190
+ if (ts.isBinaryExpression(node)
191
+ && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
192
+ const left = stringOf(node.left);
193
+ const right = stringOf(node.right);
194
+ return left === null || right === null ? null : left + right;
195
+ }
196
+ return null;
197
+ };
198
+
199
+ const visit = (node) => {
200
+ const isBlock = (ts.isJsxElement(node) && !isInline(node))
201
+ || ts.isJsxFragment(node);
202
+ if (isBlock) {
203
+ const text = inlineText(node.children).replace(/\s+/g, " ").trim();
204
+ if (HAS_WORD.test(text)) out.push(text);
205
+ } else {
206
+ const text = stringOf(node);
207
+ if (text !== null) {
208
+ const prose = text.replace(/\s+/g, " ").trim();
209
+ const readsAsProse = /^[A-Z]/.test(prose)
210
+ && prose.length > MIN_PROSE_CHARS && isProse(prose);
211
+ if (readsAsProse) out.push(prose);
212
+ return;
213
+ }
214
+ }
215
+ ts.forEachChild(node, visit);
216
+ };
217
+ visit(sf);
218
+ return out;
106
219
  }
107
220
 
108
221
  /**
@@ -119,7 +232,7 @@ function isProse(text) {
119
232
  if (compact.length === 0) return false;
120
233
  const letters = (compact.match(/[A-Za-z]/g) ?? []).length;
121
234
  if (letters / compact.length < 0.6) return false;
122
- const words = text.split(/\s+/).filter((w) => /[A-Za-z]{2,}/.test(w));
235
+ const words = text.split(/\s+/).filter((w) => HAS_WORD.test(w));
123
236
  return words.length >= 3;
124
237
  }
125
238
 
@@ -127,11 +240,8 @@ function isProse(text) {
127
240
  * Prose a reader actually sees on a page, following the components it
128
241
  * imports.
129
242
  *
130
- * Import-following is the point. Until 3 Sep 2026 this read one file
131
- * and stopped, so every shared prose component was unmeasured — a
132
- * notice component scoring grade 12.6 while all five pages rendering
133
- * it reported PASS. A page's score has to cover what it renders, not
134
- * what happens to be written in its own file.
243
+ * Import-following is the point: a page's score has to cover the
244
+ * components it renders, not only what is written in its own file.
135
245
  *
136
246
  * Depth-limited and cycle-safe. Only project-local imports are
137
247
  * followed; a package's prose is not this site's prose.
@@ -144,9 +254,16 @@ export function extractText(filePath, {maxDepth = 3} = {}) {
144
254
  const walk = (file, depth) => {
145
255
  const resolved = path.resolve(file);
146
256
  if (seen.has(resolved) || depth > maxDepth) return;
257
+ // Components only. A `.ts` module's strings are mostly data (role
258
+ // lists, labels, the company statement) that the page shows only in
259
+ // part or elsewhere, and scoring them graded every page on the same
260
+ // shared lists. The cost: prose a page renders from a `.ts` module
261
+ // goes unread.
262
+ const importedNonComponent = depth > 0 && !resolved.endsWith(".tsx");
263
+ if (importedNonComponent) return;
147
264
  seen.add(resolved);
148
265
  const source = fs.readFileSync(resolved, "utf-8");
149
- collected.push(...extractOwnText(source));
266
+ collected.push(...extractOwnText(source, resolved));
150
267
  if (depth === maxDepth) return;
151
268
  for (const m of source.matchAll(IMPORT_RE)) {
152
269
  const target = resolveLocalImport(m[1], resolved, srcDir);
@@ -162,12 +279,7 @@ export function extractText(filePath, {maxDepth = 3} = {}) {
162
279
  );
163
280
 
164
281
  let text = terminated.join(" ");
165
- text = text.replace(/\{[^}]*\}/g, " ");
166
- text = text.replace(/&amp;nbsp;/g, " ");
167
- text = text.replace(/&amp;amp;/g, "&amp;");
168
- text = text.replace(/&amp;lt;/g, "&lt;");
169
- text = text.replace(/&amp;gt;/g, "&gt;");
170
- text = text.replace(/&amp;[a-z]+;/g, " ");
282
+ text = text.replace(/&#?\w+;/g, (entity) => ENTITIES[entity] ?? " ");
171
283
  text = text.replace(/\s+/g, " ").trim();
172
284
 
173
285
  return text;
@@ -195,10 +307,8 @@ function applyWordSubs(text, wordSubs) {
195
307
  /**
196
308
  * The floor below which the formulas are not meaningful.
197
309
  *
198
- * A page under it is UNSCORED, not passing. The two used to be
199
- * indistinguishable — the CLI read a null grade as a pass — so eight
200
- * of forty-five pages were reported green on the strength of no
201
- * measurement at all.
310
+ * A page under it is UNSCORED, not passing: a score that measured
311
+ * nothing is not evidence the page reads well.
202
312
  */
203
313
  export const MIN_SCORABLE_WORDS = 100;
204
314