@casomoltd/tooling 0.36.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.
Files changed (2) hide show
  1. package/package.json +4 -3
  2. package/readability/index.mjs +156 -46
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casomoltd/tooling",
3
- "version": "0.36.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