@casomoltd/tooling 0.36.0 → 0.37.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/README.md +4 -3
- package/bin/check-listed-files.mjs +127 -0
- package/bin/commit-msg.sh +4 -0
- package/eslint/house-rules.mjs +102 -0
- package/eslint/index.mjs +5 -14
- package/eslint/lib.mjs +13 -11
- package/package.json +12 -7
- package/readability/index.mjs +156 -46
package/README.md
CHANGED
|
@@ -8,8 +8,8 @@ Shared linting, formatting, commit config, and CLI tools for Casomo Ltd's repos.
|
|
|
8
8
|
|
|
9
9
|
| Export | Description |
|
|
10
10
|
|---|---|
|
|
11
|
-
| `@casomoltd/tooling/eslint` | `createBaseConfig()` — ESLint flat config with Next.js, TypeScript, and
|
|
12
|
-
| `@casomoltd/tooling/eslint-lib` |
|
|
11
|
+
| `@casomoltd/tooling/eslint` | `createBaseConfig()` — ESLint flat config with Next.js, TypeScript, SonarJS and the house rules ([docs/lint.md](docs/lint.md)) |
|
|
12
|
+
| `@casomoltd/tooling/eslint-lib` | `createLibConfig()` — the same house rules for a TypeScript library ([docs/lint.md](docs/lint.md)) |
|
|
13
13
|
| `@casomoltd/tooling/prettier` | Prettier config object |
|
|
14
14
|
| `@casomoltd/tooling/commitlint` | commitlint config with `no-ai-attribution` plugin |
|
|
15
15
|
| `@casomoltd/tooling/knip` | Knip config for unused exports/dependencies |
|
|
@@ -25,7 +25,8 @@ Shared linting, formatting, commit config, and CLI tools for Casomo Ltd's repos.
|
|
|
25
25
|
| `check-linear` | Pre-push guard — rejects a push that would put a merge commit on the remote. History is linear; a branch catches up by rebasing. Only what the push adds is judged, so merges already on the remote stay |
|
|
26
26
|
| `pre-push` | Husky pre-push hook — runs check, then the version, tag and linear-history guards |
|
|
27
27
|
| `pre-commit` | Husky pre-commit hook — runs `npm run check` |
|
|
28
|
-
| `commit-msg` | Husky commit-msg hook — runs commitlint |
|
|
28
|
+
| `commit-msg` | Husky commit-msg hook — runs commitlint, then `check-listed-files` |
|
|
29
|
+
| `check-listed-files` | Refuse a commit that changes a file still listed in `eslint-suppressions.json`, unless a `Lint-deferred:` trailer says why ([docs/lint.md](docs/lint.md)) |
|
|
29
30
|
| `readability` | Measure reading difficulty of page content |
|
|
30
31
|
| `screenshot` | Capture a dev server page via Playwright — an agent's visual feedback loop |
|
|
31
32
|
| `build-report` | Compile a Typst client report to PDF with the house template |
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Refuse a commit that changes a file still listed in the repo's
|
|
4
|
+
* `eslint-suppressions.json`, unless the message says why it is deferred.
|
|
5
|
+
*
|
|
6
|
+
* The house size limits are lint errors, and a file that already broke one
|
|
7
|
+
* when the limit was switched on is listed there instead of failing. The
|
|
8
|
+
* list only shrinks if touching a file is the moment it is tidied: tidy it
|
|
9
|
+
* in its own commit (fix it, then `eslint --prune-suppressions`), and the
|
|
10
|
+
* change that needed it follows. ESLint itself fails while a fixed file
|
|
11
|
+
* keeps its entry, so a tidied file always comes off the list. A commit that
|
|
12
|
+
* has to change a listed file anyway carries a trailer naming why, so the
|
|
13
|
+
* deferral shows in `git log`:
|
|
14
|
+
*
|
|
15
|
+
* Lint-deferred: a one-line hotfix; the tidy follows in its own commit
|
|
16
|
+
*
|
|
17
|
+
* What it reads, and what it assumes:
|
|
18
|
+
*
|
|
19
|
+
* - **The list as staged**, so the commit that tidies a file and drops its
|
|
20
|
+
* entry passes.
|
|
21
|
+
* - **The files staged now**, renames counted under their old path too. An
|
|
22
|
+
* amend is checked on what it newly stages, not on the whole commit.
|
|
23
|
+
* - **One list, at the repo root**, keyed by root-relative path, which is
|
|
24
|
+
* where ESLint writes it when run from the root. A list anywhere else
|
|
25
|
+
* fails the check rather than being silently missed.
|
|
26
|
+
* - **No merge commits:** a merge carries changes each branch already
|
|
27
|
+
* committed and was checked for.
|
|
28
|
+
*
|
|
29
|
+
* Runs from the shared commit-msg hook, the first point the message exists.
|
|
30
|
+
*
|
|
31
|
+
* Usage:
|
|
32
|
+
* check-listed-files <commit-message-file>
|
|
33
|
+
*/
|
|
34
|
+
import {execFileSync} from "node:child_process";
|
|
35
|
+
import {readFileSync, realpathSync} from "node:fs";
|
|
36
|
+
import {pathToFileURL} from "node:url";
|
|
37
|
+
|
|
38
|
+
/** ESLint's default suppressions file, at the repo root. */
|
|
39
|
+
export const SUPPRESSIONS_FILE = "eslint-suppressions.json";
|
|
40
|
+
/** The trailer that lets a listed file through, with a reason on the same
|
|
41
|
+
* line. Git reads trailer keys without regard to case. */
|
|
42
|
+
export const DEFERRED_TRAILER = /^Lint-deferred:[ \t]*\S/im;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The staged files that are listed, each with the rules it is listed for.
|
|
46
|
+
*
|
|
47
|
+
* @param {readonly string[]} staged - repo-relative paths in the commit
|
|
48
|
+
* @param {Record<string, Record<string, {count: number}>>} listed - the
|
|
49
|
+
* staged suppressions file, keyed by repo-relative path
|
|
50
|
+
* @returns {{file: string, rules: string[]}[]}
|
|
51
|
+
*/
|
|
52
|
+
export function listedInCommit(staged, listed) {
|
|
53
|
+
return staged
|
|
54
|
+
.filter((file) => Object.hasOwn(listed, file))
|
|
55
|
+
.map((file) => ({file, rules: Object.keys(listed[file])}));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Whether the message defers the tidy with a reason. */
|
|
59
|
+
export function defers(message) {
|
|
60
|
+
return DEFERRED_TRAILER.test(message);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const git = (...args) => execFileSync("git", args,
|
|
64
|
+
{encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]});
|
|
65
|
+
|
|
66
|
+
/** Whether git can resolve a ref or object, without throwing. */
|
|
67
|
+
function resolves(ref) {
|
|
68
|
+
try {
|
|
69
|
+
git("rev-parse", "-q", "--verify", ref);
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
// Not resolvable is the answer asked for, not a failure.
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The repo's suppressions as staged; none where the repo stages no list.
|
|
78
|
+
* A list that is staged but unreadable throws: it is not an empty list. */
|
|
79
|
+
function stagedSuppressions() {
|
|
80
|
+
if (!resolves(`:${SUPPRESSIONS_FILE}`)) return {};
|
|
81
|
+
return JSON.parse(git("show", `:${SUPPRESSIONS_FILE}`));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Suppressions files tracked anywhere but the repo root. */
|
|
85
|
+
function listsBelowRoot() {
|
|
86
|
+
return git("ls-files", `*${SUPPRESSIONS_FILE}`).split("\n")
|
|
87
|
+
.filter((path) => path && path !== SUPPRESSIONS_FILE);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The paths the commit touches, a rename under both its old and new path
|
|
91
|
+
* and a deletion under its own. */
|
|
92
|
+
function stagedPaths() {
|
|
93
|
+
return git("diff", "--cached", "--name-only", "--no-renames",
|
|
94
|
+
"--diff-filter=ACMRD").split("\n").filter(Boolean);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function main(messageFile) {
|
|
98
|
+
if (resolves("MERGE_HEAD")) return;
|
|
99
|
+
const elsewhere = listsBelowRoot();
|
|
100
|
+
if (elsewhere.length > 0) {
|
|
101
|
+
console.error("check-listed-files: suppressions lists below the repo "
|
|
102
|
+
+ `root are not read, so this check cannot hold: ${elsewhere.join(", ")}`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
const hits = listedInCommit(stagedPaths(), stagedSuppressions());
|
|
106
|
+
if (hits.length === 0) return;
|
|
107
|
+
if (defers(readFileSync(messageFile, "utf8"))) {
|
|
108
|
+
for (const {file} of hits) {
|
|
109
|
+
console.warn(`check-listed-files: ${file} is listed; tidy deferred`);
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
console.error("check-listed-files: this commit changes files listed in "
|
|
114
|
+
+ `${SUPPRESSIONS_FILE}. Tidy each in its own commit first (fix it, `
|
|
115
|
+
+ "then eslint --prune-suppressions), or add a 'Lint-deferred: <why>' "
|
|
116
|
+
+ "trailer:");
|
|
117
|
+
for (const {file, rules} of hits) {
|
|
118
|
+
console.error(` ${file}: ${rules.join(", ")}`);
|
|
119
|
+
}
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Run through npm's bin symlink, argv[1] is the link and import.meta.url
|
|
124
|
+
// the real file, so compare real paths.
|
|
125
|
+
if (import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
126
|
+
main(process.argv[2]);
|
|
127
|
+
}
|
package/bin/commit-msg.sh
CHANGED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The house rules every repo's ESLint config shares: line length; the size
|
|
3
|
+
* of a function, its parameter list, a file, its branching and nesting;
|
|
4
|
+
* named numbers; no non-null assertions; and exhaustive switches. Both the
|
|
5
|
+
* Next config (`index.mjs`) and the library config (`lib.mjs`) spread these
|
|
6
|
+
* in, so each rule is stated once. Every rule is on unless a reason is
|
|
7
|
+
* written beside the exception.
|
|
8
|
+
*
|
|
9
|
+
* They are errors, never warnings: a warning nobody has to act on is how a
|
|
10
|
+
* module grows past what anyone can read. A file that already breaks one is
|
|
11
|
+
* recorded in the repo's `eslint-suppressions.json` (`eslint
|
|
12
|
+
* --suppress-all`), and `check-listed-files` fails a commit that touches it
|
|
13
|
+
* until it is tidied, unless the message carries a `Lint-deferred:` reason.
|
|
14
|
+
* A repo whose files are transcribed reference data (published tables,
|
|
15
|
+
* cited at the data) exempts those paths from
|
|
16
|
+
* `@typescript-eslint/no-magic-numbers` in its own config, saying why.
|
|
17
|
+
* What each rule is for, and every exemption, is in `docs/lint.md`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Lines a function may run to, blank lines and comments not counted:
|
|
21
|
+
* ESLint's default. */
|
|
22
|
+
const FUNCTION_LINES = 50;
|
|
23
|
+
/** Parameters a function may take; past this, pass a value object.
|
|
24
|
+
* Proposed here; no external source (ESLint's default is 3). */
|
|
25
|
+
const PARAMS = 4;
|
|
26
|
+
/** Lines a file may run to, blank lines and comments not counted.
|
|
27
|
+
* Proposed here; no external source (ESLint's default is 300). */
|
|
28
|
+
const FILE_LINES = 400;
|
|
29
|
+
/** Cyclomatic complexity a function may reach. Proposed here; no external
|
|
30
|
+
* source (ESLint's default is 20). */
|
|
31
|
+
const COMPLEXITY = 10;
|
|
32
|
+
|
|
33
|
+
/** How deep blocks may nest before the code should be flattened.
|
|
34
|
+
* Proposed here; no external source (ESLint's default is 4). */
|
|
35
|
+
const DEPTH = 3;
|
|
36
|
+
|
|
37
|
+
const SKIP_BLANKS_AND_COMMENTS = { skipBlankLines: true, skipComments: true };
|
|
38
|
+
|
|
39
|
+
/** The rules, as one flat-config block for every file. */
|
|
40
|
+
export const HOUSE_RULES = {
|
|
41
|
+
rules: {
|
|
42
|
+
// URLs and regex literals are the deviations from 88: neither can be
|
|
43
|
+
// broken without changing it. No other long line is allowed, so strings
|
|
44
|
+
// and template literals are not exempt.
|
|
45
|
+
'max-len': [
|
|
46
|
+
'error',
|
|
47
|
+
{ code: 88, ignoreUrls: true, ignoreRegExpLiterals: true },
|
|
48
|
+
],
|
|
49
|
+
'max-lines-per-function': [
|
|
50
|
+
'error', { max: FUNCTION_LINES, ...SKIP_BLANKS_AND_COMMENTS },
|
|
51
|
+
],
|
|
52
|
+
// The TypeScript version does not count a `this:` type annotation.
|
|
53
|
+
'max-params': 'off',
|
|
54
|
+
'@typescript-eslint/max-params': ['error', { max: PARAMS }],
|
|
55
|
+
'max-lines': ['error', { max: FILE_LINES, ...SKIP_BLANKS_AND_COMMENTS }],
|
|
56
|
+
complexity: ['error', COMPLEXITY],
|
|
57
|
+
'max-depth': ['error', DEPTH],
|
|
58
|
+
// A number in code is named, so the reader sees what it is. Counting
|
|
59
|
+
// and indexing numbers, enums and type positions are not magic, nor a
|
|
60
|
+
// parameter's default or a readonly class property, which already name
|
|
61
|
+
// the number they hold. The TypeScript version replaces the base rule.
|
|
62
|
+
'no-magic-numbers': 'off',
|
|
63
|
+
'@typescript-eslint/no-magic-numbers': ['error', {
|
|
64
|
+
ignore: [-1, 0, 1],
|
|
65
|
+
ignoreArrayIndexes: true,
|
|
66
|
+
ignoreDefaultValues: true,
|
|
67
|
+
ignoreEnums: true,
|
|
68
|
+
ignoreNumericLiteralTypes: true,
|
|
69
|
+
ignoreReadonlyClassProperties: true,
|
|
70
|
+
ignoreTypeIndexes: true,
|
|
71
|
+
}],
|
|
72
|
+
// A `!` tells the compiler a value exists without checking that it does.
|
|
73
|
+
'@typescript-eslint/no-non-null-assertion': 'error',
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Rules that need the type checker, for TypeScript files only. A switch
|
|
78
|
+
* over a union names every member, so a new member is a lint error at each
|
|
79
|
+
* switch rather than a silent fall-through. Type information also brings on the
|
|
80
|
+
* type-aware rules in sonarjs's recommended set, which are silent
|
|
81
|
+
* without it. */
|
|
82
|
+
export const TYPED_RULES = {
|
|
83
|
+
files: ['**/*.ts', '**/*.tsx', '**/*.mts'],
|
|
84
|
+
// A declaration file holds no code to switch on.
|
|
85
|
+
ignores: ['**/*.d.ts', '**/*.d.mts'],
|
|
86
|
+
languageOptions: { parserOptions: { projectService: true } },
|
|
87
|
+
rules: { '@typescript-eslint/switch-exhaustiveness-check': 'error' },
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** A test file's cases often sit in one top-level function by design (a
|
|
91
|
+
* `describe` block, an assert script), and its expected figures are
|
|
92
|
+
* literals by design, so function length and magic numbers are not held
|
|
93
|
+
* against test files; every other limit is. */
|
|
94
|
+
export const TEST_FILES_EXEMPT = {
|
|
95
|
+
files: [
|
|
96
|
+
'**/*.test.*', '**/*.spec.*', '**/tests/**', '**/test/**', '**/__tests__/**',
|
|
97
|
+
],
|
|
98
|
+
rules: {
|
|
99
|
+
'max-lines-per-function': 'off',
|
|
100
|
+
'@typescript-eslint/no-magic-numbers': 'off',
|
|
101
|
+
},
|
|
102
|
+
};
|
package/eslint/index.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {HOUSE_RULES, TEST_FILES_EXEMPT, TYPED_RULES} from "./house-rules.mjs";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Creates the shared ESLint base config.
|
|
3
5
|
*
|
|
@@ -33,19 +35,8 @@ export function createBaseConfig({
|
|
|
33
35
|
"@typescript-eslint/no-require-imports": "off",
|
|
34
36
|
},
|
|
35
37
|
},
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"error",
|
|
40
|
-
{
|
|
41
|
-
code: 88,
|
|
42
|
-
ignoreUrls: true,
|
|
43
|
-
ignoreStrings: true,
|
|
44
|
-
ignoreTemplateLiterals: true,
|
|
45
|
-
ignoreRegExpLiterals: true,
|
|
46
|
-
},
|
|
47
|
-
],
|
|
48
|
-
},
|
|
49
|
-
},
|
|
38
|
+
HOUSE_RULES,
|
|
39
|
+
TYPED_RULES,
|
|
40
|
+
TEST_FILES_EXEMPT,
|
|
50
41
|
]);
|
|
51
42
|
}
|
package/eslint/lib.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {HOUSE_RULES, TEST_FILES_EXEMPT, TYPED_RULES} from './house-rules.mjs';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Creates a shared ESLint config for TypeScript libraries
|
|
3
5
|
* (non-Next.js projects).
|
|
@@ -22,19 +24,19 @@ export function createLibConfig({tseslint, sonarjs}) {
|
|
|
22
24
|
'@typescript-eslint/no-require-imports': 'off',
|
|
23
25
|
},
|
|
24
26
|
},
|
|
27
|
+
HOUSE_RULES,
|
|
28
|
+
TYPED_RULES,
|
|
25
29
|
{
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
ignoreRegExpLiterals: true,
|
|
35
|
-
},
|
|
36
|
-
],
|
|
30
|
+
// A library's tsconfig includes only its sources and tests, so a root
|
|
31
|
+
// config file (vitest.config.ts) sits outside every project.
|
|
32
|
+
files: TYPED_RULES.files,
|
|
33
|
+
ignores: TYPED_RULES.ignores,
|
|
34
|
+
languageOptions: {
|
|
35
|
+
parserOptions: {
|
|
36
|
+
projectService: {allowDefaultProject: ['*.config.ts', '*.config.mts']},
|
|
37
|
+
},
|
|
37
38
|
},
|
|
38
39
|
},
|
|
40
|
+
TEST_FILES_EXEMPT,
|
|
39
41
|
);
|
|
40
42
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@casomoltd/tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.0",
|
|
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": {
|
|
@@ -22,9 +22,10 @@
|
|
|
22
22
|
"!**/*.test.mjs"
|
|
23
23
|
],
|
|
24
24
|
"scripts": {
|
|
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",
|
|
25
|
+
"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",
|
|
26
|
+
"lint": "eslint .",
|
|
26
27
|
"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",
|
|
28
|
+
"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/casomo-config.test.mjs && node bin/buttondown.test.mjs && node commitlint/index.test.mjs && node readability/index.test.mjs",
|
|
28
29
|
"lint:md": "remark --frail --quiet --no-stdout --rc-path bin/skills-lint.config.mjs skills agents docs",
|
|
29
30
|
"build": "echo 'no-op: source-only repo, nothing to build'",
|
|
30
31
|
"prepare": "husky || true",
|
|
@@ -33,7 +34,6 @@
|
|
|
33
34
|
},
|
|
34
35
|
"casomo": {
|
|
35
36
|
"gates": {
|
|
36
|
-
"lint": "publishes the shared eslint config but has none of its own yet",
|
|
37
37
|
"knip": "no knip config for this repo's mixed .mts/.mjs layout yet",
|
|
38
38
|
"jscpd": "no jscpd config for this repo yet",
|
|
39
39
|
"spell": "no shared cspell config exists yet",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"buttondown": "./bin/buttondown.mjs",
|
|
59
59
|
"check-gates": "./bin/check-gates.mjs",
|
|
60
60
|
"check-hooks": "./bin/check-hooks.sh",
|
|
61
|
+
"check-listed-files": "./bin/check-listed-files.mjs",
|
|
61
62
|
"check-linear": "./bin/check-linear.sh",
|
|
62
63
|
"check-personal-data": "./bin/check-personal-data.mjs",
|
|
63
64
|
"check-private-refs": "./bin/check-private-refs.mjs",
|
|
@@ -80,20 +81,24 @@
|
|
|
80
81
|
},
|
|
81
82
|
"peerDependencies": {
|
|
82
83
|
"@commitlint/cli": ">=20",
|
|
83
|
-
"eslint": ">=9",
|
|
84
|
+
"eslint": ">=9.24",
|
|
84
85
|
"eslint-config-next": ">=16",
|
|
85
86
|
"eslint-plugin-sonarjs": ">=3",
|
|
86
87
|
"jscpd": ">=4",
|
|
87
|
-
"knip": ">=5"
|
|
88
|
+
"knip": ">=5",
|
|
89
|
+
"typescript": ">=5"
|
|
88
90
|
},
|
|
89
91
|
"devDependencies": {
|
|
90
92
|
"@types/node": "^26.0.0",
|
|
93
|
+
"eslint": "^9.39.5",
|
|
94
|
+
"eslint-plugin-sonarjs": "^4.2.2",
|
|
91
95
|
"husky": "^9.1.7",
|
|
92
96
|
"remark-cli": "^12.0.1",
|
|
93
97
|
"remark-frontmatter": "^5.0.0",
|
|
94
98
|
"remark-lint-frontmatter-schema": "^3.15.4",
|
|
95
99
|
"remark-validate-links": "^13.1.0",
|
|
96
|
-
"typescript": "^6.0.3"
|
|
100
|
+
"typescript": "^6.0.3",
|
|
101
|
+
"typescript-eslint": "^8.72.0"
|
|
97
102
|
},
|
|
98
103
|
"overrides": {
|
|
99
104
|
"remark-lint-frontmatter-schema": {
|
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
|
|