@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.
- package/package.json +4 -3
- 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.
|
|
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",
|
package/readability/index.mjs
CHANGED
|
@@ -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
|
+
" ": " ", "&": "&", "<": "<", ">": ">",
|
|
44
|
+
"’": "\u2019", "‘": "\u2018", "”": "\u201d",
|
|
45
|
+
"“": "\u201c", "–": "\u2013", "—": "\u2014",
|
|
46
|
+
"…": "\u2026", "'": "'", """: "\"",
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
// figure — most of the prose on a pay page — was invisible here.
|
|
85
|
-
const tagContentRe = />([^<>]+)</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
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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) =>
|
|
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
|
|
131
|
-
*
|
|
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(
|
|
166
|
-
text = text.replace(/&nbsp;/g, " ");
|
|
167
|
-
text = text.replace(/&amp;/g, "&");
|
|
168
|
-
text = text.replace(/&lt;/g, "<");
|
|
169
|
-
text = text.replace(/&gt;/g, ">");
|
|
170
|
-
text = text.replace(/&[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
|
|
199
|
-
*
|
|
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
|
|