@trackunit/eslint-plugin-trackunit 0.6.49 → 0.6.52
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/CHANGELOG.md +18 -0
- package/README.md +6 -0
- package/package.json +1 -1
- package/src/lib/config/fragments/typescript-rules.js +1 -1
- package/src/lib/config/index.d.ts +9 -0
- package/src/lib/config/plugins.d.ts +3 -0
- package/src/lib/config/presets/base.d.ts +6 -0
- package/src/lib/config/presets/react.d.ts +3 -0
- package/src/lib/rules/prefer-event-specific-callback-naming/utils.d.ts +1 -15
- package/src/lib/rules/prefer-event-specific-callback-naming/utils.js +0 -81
- package/src/lib/rules/require-storybook-folder-landing-page/require-storybook-folder-landing-page.d.ts +31 -0
- package/src/lib/rules/require-storybook-folder-landing-page/require-storybook-folder-landing-page.js +238 -0
- package/src/lib/rules-map.d.ts +3 -0
- package/src/lib/rules-map.js +2 -0
- package/src/lib/utils/ast-utils.js +0 -39
- package/src/lib/utils/classname-utils.js +0 -80
- package/src/lib/utils/import-utils.js +0 -36
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
## 0.6.52 (2026-07-01)
|
|
2
|
+
|
|
3
|
+
### 🧱 Updated Dependencies
|
|
4
|
+
|
|
5
|
+
- Updated shared-utils to 1.15.52
|
|
6
|
+
|
|
7
|
+
## 0.6.51 (2026-07-01)
|
|
8
|
+
|
|
9
|
+
### 🧱 Updated Dependencies
|
|
10
|
+
|
|
11
|
+
- Updated shared-utils to 1.15.51
|
|
12
|
+
|
|
13
|
+
## 0.6.50 (2026-06-30)
|
|
14
|
+
|
|
15
|
+
### 🧱 Updated Dependencies
|
|
16
|
+
|
|
17
|
+
- Updated shared-utils to 1.15.50
|
|
18
|
+
|
|
1
19
|
## 0.6.49 (2026-06-30)
|
|
2
20
|
|
|
3
21
|
### 🧱 Updated Dependencies
|
package/README.md
CHANGED
|
@@ -83,6 +83,12 @@ All custom rules are enabled automatically through the presets under the `@track
|
|
|
83
83
|
| `@trackunit/no-internal-graphql-when-tagged-with-gql-public` | error | — | Prevents internal GraphQL usage in projects tagged with `gql-public`. Applied to `project.json` files. |
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
### Storybook
|
|
87
|
+
|
|
88
|
+
| Rule | Severity | Auto-fix | Description |
|
|
89
|
+
| ----------------------------------------------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
90
|
+
| `@trackunit/require-storybook-folder-landing-page` | — | no | Requires a `*.docs.mdx` landing page (`<Meta title>`) for every ancestor folder implied by a story's title. Report-only; not yet enabled in a preset. |
|
|
91
|
+
|
|
86
92
|
## Utilities
|
|
87
93
|
|
|
88
94
|
### `createSkipWhen`
|
package/package.json
CHANGED
|
@@ -86,6 +86,9 @@ export declare const configs: {
|
|
|
86
86
|
}], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
87
87
|
name: string;
|
|
88
88
|
};
|
|
89
|
+
"require-storybook-folder-landing-page": import("@typescript-eslint/utils/ts-eslint").RuleModule<"missingLandingPage", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
90
|
+
name: string;
|
|
91
|
+
};
|
|
89
92
|
};
|
|
90
93
|
};
|
|
91
94
|
"@typescript-eslint": {
|
|
@@ -272,6 +275,9 @@ export declare const configs: {
|
|
|
272
275
|
}], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
273
276
|
name: string;
|
|
274
277
|
};
|
|
278
|
+
"require-storybook-folder-landing-page": import("@typescript-eslint/utils/ts-eslint").RuleModule<"missingLandingPage", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
279
|
+
name: string;
|
|
280
|
+
};
|
|
275
281
|
};
|
|
276
282
|
};
|
|
277
283
|
"@nx"?: undefined;
|
|
@@ -390,6 +396,9 @@ export declare const configs: {
|
|
|
390
396
|
}], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
391
397
|
name: string;
|
|
392
398
|
};
|
|
399
|
+
"require-storybook-folder-landing-page": import("@typescript-eslint/utils/ts-eslint").RuleModule<"missingLandingPage", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
400
|
+
name: string;
|
|
401
|
+
};
|
|
393
402
|
};
|
|
394
403
|
};
|
|
395
404
|
"@typescript-eslint": {
|
|
@@ -104,6 +104,9 @@ export declare const localRulesPlugin: {
|
|
|
104
104
|
}], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
105
105
|
name: string;
|
|
106
106
|
};
|
|
107
|
+
"require-storybook-folder-landing-page": import("@typescript-eslint/utils/ts-eslint").RuleModule<"missingLandingPage", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
108
|
+
name: string;
|
|
109
|
+
};
|
|
107
110
|
};
|
|
108
111
|
};
|
|
109
112
|
export { eslintConfigPrettier, globals, importPlugin, jest, jsdoc, jsoncParser, noNull, nx, react, reactHooks, testingLibrary, tsParser, typescriptEslint, };
|
|
@@ -86,6 +86,9 @@ export declare const base: (import("eslint").Linter.FlatConfig<import("eslint").
|
|
|
86
86
|
}], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
87
87
|
name: string;
|
|
88
88
|
};
|
|
89
|
+
"require-storybook-folder-landing-page": import("@typescript-eslint/utils/ts-eslint").RuleModule<"missingLandingPage", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
90
|
+
name: string;
|
|
91
|
+
};
|
|
89
92
|
};
|
|
90
93
|
};
|
|
91
94
|
"@typescript-eslint": {
|
|
@@ -272,6 +275,9 @@ export declare const base: (import("eslint").Linter.FlatConfig<import("eslint").
|
|
|
272
275
|
}], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
273
276
|
name: string;
|
|
274
277
|
};
|
|
278
|
+
"require-storybook-folder-landing-page": import("@typescript-eslint/utils/ts-eslint").RuleModule<"missingLandingPage", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
279
|
+
name: string;
|
|
280
|
+
};
|
|
275
281
|
};
|
|
276
282
|
};
|
|
277
283
|
"@nx"?: undefined;
|
|
@@ -85,6 +85,9 @@ export declare const reactPreset: (import("eslint").Linter.Config<import("eslint
|
|
|
85
85
|
}], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
86
86
|
name: string;
|
|
87
87
|
};
|
|
88
|
+
"require-storybook-folder-landing-page": import("@typescript-eslint/utils/ts-eslint").RuleModule<"missingLandingPage", [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
89
|
+
name: string;
|
|
90
|
+
};
|
|
88
91
|
};
|
|
89
92
|
};
|
|
90
93
|
"@typescript-eslint": {
|
|
@@ -4,19 +4,6 @@
|
|
|
4
4
|
* used by both string-based and type-based detection strategies.
|
|
5
5
|
*/
|
|
6
6
|
import { TSESLint, TSESTree } from "@typescript-eslint/utils";
|
|
7
|
-
/**
|
|
8
|
-
* Result of extracting callback information from an expression.
|
|
9
|
-
* Does not include messageId - each strategy adds its own.
|
|
10
|
-
*/
|
|
11
|
-
export type CallbackExtractionResult = {
|
|
12
|
-
callbackName: string;
|
|
13
|
-
suggestedName: string;
|
|
14
|
-
node: TSESTree.Node;
|
|
15
|
-
/** The identifier node for the callback - used for fix ranges */
|
|
16
|
-
identifierNode: TSESTree.Identifier;
|
|
17
|
-
/** Whether the callback is wrapped in an arrow function */
|
|
18
|
-
isWrapped: boolean;
|
|
19
|
-
} | null;
|
|
20
7
|
/**
|
|
21
8
|
* Information about a locally defined variable, including its definition
|
|
22
9
|
* and all references within the same file.
|
|
@@ -40,8 +27,7 @@ export type LocalDefinitionInfo = {
|
|
|
40
27
|
export declare const findLocalDefinitionInfo: (identifier: TSESTree.Identifier, context: TSESLint.RuleContext<string, ReadonlyArray<unknown>>) => LocalDefinitionInfo | null;
|
|
41
28
|
/**
|
|
42
29
|
* Result of extracting a callback identifier from an expression.
|
|
43
|
-
*
|
|
44
|
-
* since those depend on the specific event handler being used.
|
|
30
|
+
* Does not include suggested names since those depend on the specific event handler being used.
|
|
45
31
|
*/
|
|
46
32
|
export type CallbackIdentifierResult = {
|
|
47
33
|
callbackName: string;
|
|
@@ -105,87 +105,6 @@ const findLocalDefinitionInfo = (identifier, context) => {
|
|
|
105
105
|
};
|
|
106
106
|
};
|
|
107
107
|
exports.findLocalDefinitionInfo = findLocalDefinitionInfo;
|
|
108
|
-
/**
|
|
109
|
-
* Checks if a callback name is problematic when used with click handlers.
|
|
110
|
-
* A name is problematic if it starts with "on" but not "onClick".
|
|
111
|
-
*
|
|
112
|
-
* @example
|
|
113
|
-
* isProblematicName("onClose") // true - should be onClickClose
|
|
114
|
-
* isProblematicName("onClickClose") // false - already correct
|
|
115
|
-
* isProblematicName("handleClose") // false - different convention
|
|
116
|
-
* isProblematicName("close") // false - doesn't start with "on"
|
|
117
|
-
*/
|
|
118
|
-
const isProblematicName = (name) => {
|
|
119
|
-
return name.startsWith("on") && !name.startsWith("onClick");
|
|
120
|
-
};
|
|
121
|
-
/**
|
|
122
|
-
* Converts a problematic callback name to the suggested onClick* format.
|
|
123
|
-
*
|
|
124
|
-
* @example
|
|
125
|
-
* getSuggestedName("onClose") // "onClickClose"
|
|
126
|
-
* getSuggestedName("onCancel") // "onClickCancel"
|
|
127
|
-
*/
|
|
128
|
-
const getSuggestedName = (name) => {
|
|
129
|
-
// Remove "on" prefix and add "onClick" prefix
|
|
130
|
-
return `onClick${name.slice(2)}`;
|
|
131
|
-
};
|
|
132
|
-
/**
|
|
133
|
-
* Extracts callback info from a direct identifier pattern: onClick={onClose}
|
|
134
|
-
*/
|
|
135
|
-
const extractFromDirectIdentifier = (expression) => {
|
|
136
|
-
if (expression.type !== utils_1.AST_NODE_TYPES.Identifier) {
|
|
137
|
-
return null;
|
|
138
|
-
}
|
|
139
|
-
const callbackName = expression.name;
|
|
140
|
-
if (!isProblematicName(callbackName)) {
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
|
-
return {
|
|
144
|
-
callbackName,
|
|
145
|
-
suggestedName: getSuggestedName(callbackName),
|
|
146
|
-
node: expression,
|
|
147
|
-
identifierNode: expression,
|
|
148
|
-
isWrapped: false,
|
|
149
|
-
};
|
|
150
|
-
};
|
|
151
|
-
/**
|
|
152
|
-
* Extracts callback info from a single-call arrow function: onClick={() => onClose()}
|
|
153
|
-
*/
|
|
154
|
-
const extractFromSingleCallArrow = (expression) => {
|
|
155
|
-
if (expression.type !== utils_1.AST_NODE_TYPES.ArrowFunctionExpression) {
|
|
156
|
-
return null;
|
|
157
|
-
}
|
|
158
|
-
const { body } = expression;
|
|
159
|
-
// Only check if body is a single call expression (not a block statement)
|
|
160
|
-
if (body.type !== utils_1.AST_NODE_TYPES.CallExpression) {
|
|
161
|
-
return null;
|
|
162
|
-
}
|
|
163
|
-
// Only check if the callee is a simple identifier
|
|
164
|
-
if (body.callee.type !== utils_1.AST_NODE_TYPES.Identifier) {
|
|
165
|
-
return null;
|
|
166
|
-
}
|
|
167
|
-
const callbackName = body.callee.name;
|
|
168
|
-
if (!isProblematicName(callbackName)) {
|
|
169
|
-
return null;
|
|
170
|
-
}
|
|
171
|
-
return {
|
|
172
|
-
callbackName,
|
|
173
|
-
suggestedName: getSuggestedName(callbackName),
|
|
174
|
-
node: body.callee,
|
|
175
|
-
identifierNode: body.callee,
|
|
176
|
-
isWrapped: true,
|
|
177
|
-
};
|
|
178
|
-
};
|
|
179
|
-
/**
|
|
180
|
-
* Extracts callback information from an expression.
|
|
181
|
-
* Handles both direct identifiers (onClick={onClose}) and
|
|
182
|
-
* single-call arrow functions (onClick={() => onClose()}).
|
|
183
|
-
*
|
|
184
|
-
* @returns Callback extraction result without messageId, or null if no problematic callback found
|
|
185
|
-
*/
|
|
186
|
-
const extractCallbackInfo = (expression) => {
|
|
187
|
-
return extractFromDirectIdentifier(expression) ?? extractFromSingleCallArrow(expression);
|
|
188
|
-
};
|
|
189
108
|
/**
|
|
190
109
|
* Extracts the callback identifier from an expression without checking naming patterns.
|
|
191
110
|
* Used by strict prop events check which has its own naming criteria.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Require a Storybook landing page (`*.docs.mdx` with `<Meta title="...">`) for every ancestor
|
|
3
|
+
* FOLDER implied by a story's title.
|
|
4
|
+
*
|
|
5
|
+
* Architecture: per-story-file rule. Each `*.stories.@(js|jsx|ts|tsx)` file declares a Storybook
|
|
6
|
+
* `title` (e.g. "Hooks/react-core-hooks/useDebounce"). The folders implied by that title are the
|
|
7
|
+
* accumulated path prefixes EXCLUDING the story leaf (here: "Hooks" and "Hooks/react-core-hooks").
|
|
8
|
+
* Every such folder must have a landing page: a `*.docs.mdx` somewhere in the workspace whose
|
|
9
|
+
* `<Meta title="FOLDER" />` EXACTLY equals the folder path, with non-empty body and no TODO marker.
|
|
10
|
+
*
|
|
11
|
+
* The rule is REPORT-ONLY: it does not scaffold files. Creating a separate `*.docs.mdx` cannot be
|
|
12
|
+
* expressed as a pure ESLint fix, and ESLint evaluates fix/suggestion callbacks on every lint pass
|
|
13
|
+
* (not only under `--fix`), so a file-writing fix would write stubs during ordinary editor/CI lint.
|
|
14
|
+
* Stub scaffolding is therefore delivered as a separate, explicit command (tracked as a follow-up).
|
|
15
|
+
*
|
|
16
|
+
* See README.md for incorrect/correct examples.
|
|
17
|
+
*/
|
|
18
|
+
import { ESLintUtils } from "@typescript-eslint/utils";
|
|
19
|
+
/** Canonical TODO marker. A landing page still containing this marker does NOT satisfy the rule. */
|
|
20
|
+
export declare const STORYBOOK_LANDING_PAGE_TODO_MARKER = "{/* TODO: storybook-folder-landing-page */}";
|
|
21
|
+
/**
|
|
22
|
+
* Canonical permalink to the how-to-author doc. Single source of truth, embedded in the rule's
|
|
23
|
+
* error message so developers tripping the rule get a direct link. The `master` permalink stays
|
|
24
|
+
* stable across file moves within the (internal) repo.
|
|
25
|
+
*/
|
|
26
|
+
export declare const STORYBOOK_LANDING_PAGE_DOCS_URL = "https://github.com/Trackunit/manager/blob/master/docs/STORYBOOK-LANDING-PAGES.md";
|
|
27
|
+
/** Clear the module-level scan cache (primarily for tests). */
|
|
28
|
+
export declare const clearLandingPageCache: () => void;
|
|
29
|
+
export declare const requireStorybookFolderLandingPage: ESLintUtils.RuleModule<"missingLandingPage", [], unknown, ESLintUtils.RuleListener> & {
|
|
30
|
+
name: string;
|
|
31
|
+
};
|
package/src/lib/rules/require-storybook-folder-landing-page/require-storybook-folder-landing-page.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Require a Storybook landing page (`*.docs.mdx` with `<Meta title="...">`) for every ancestor
|
|
4
|
+
* FOLDER implied by a story's title.
|
|
5
|
+
*
|
|
6
|
+
* Architecture: per-story-file rule. Each `*.stories.@(js|jsx|ts|tsx)` file declares a Storybook
|
|
7
|
+
* `title` (e.g. "Hooks/react-core-hooks/useDebounce"). The folders implied by that title are the
|
|
8
|
+
* accumulated path prefixes EXCLUDING the story leaf (here: "Hooks" and "Hooks/react-core-hooks").
|
|
9
|
+
* Every such folder must have a landing page: a `*.docs.mdx` somewhere in the workspace whose
|
|
10
|
+
* `<Meta title="FOLDER" />` EXACTLY equals the folder path, with non-empty body and no TODO marker.
|
|
11
|
+
*
|
|
12
|
+
* The rule is REPORT-ONLY: it does not scaffold files. Creating a separate `*.docs.mdx` cannot be
|
|
13
|
+
* expressed as a pure ESLint fix, and ESLint evaluates fix/suggestion callbacks on every lint pass
|
|
14
|
+
* (not only under `--fix`), so a file-writing fix would write stubs during ordinary editor/CI lint.
|
|
15
|
+
* Stub scaffolding is therefore delivered as a separate, explicit command (tracked as a follow-up).
|
|
16
|
+
*
|
|
17
|
+
* See README.md for incorrect/correct examples.
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.requireStorybookFolderLandingPage = exports.clearLandingPageCache = exports.STORYBOOK_LANDING_PAGE_DOCS_URL = exports.STORYBOOK_LANDING_PAGE_TODO_MARKER = void 0;
|
|
21
|
+
const tslib_1 = require("tslib");
|
|
22
|
+
const utils_1 = require("@typescript-eslint/utils");
|
|
23
|
+
const crypto = tslib_1.__importStar(require("crypto"));
|
|
24
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
25
|
+
const path = tslib_1.__importStar(require("path"));
|
|
26
|
+
const file_utils_1 = require("../../utils/file-utils");
|
|
27
|
+
const createRule = utils_1.ESLintUtils.RuleCreator(name => `https://github.com/trackunit/manager/blob/master/libs/eslint/plugin-trackunit/src/lib/rules/${name}/${name}.ts`);
|
|
28
|
+
/** Canonical TODO marker. A landing page still containing this marker does NOT satisfy the rule. */
|
|
29
|
+
exports.STORYBOOK_LANDING_PAGE_TODO_MARKER = "{/* TODO: storybook-folder-landing-page */}";
|
|
30
|
+
/**
|
|
31
|
+
* Canonical permalink to the how-to-author doc. Single source of truth, embedded in the rule's
|
|
32
|
+
* error message so developers tripping the rule get a direct link. The `master` permalink stays
|
|
33
|
+
* stable across file moves within the (internal) repo.
|
|
34
|
+
*/
|
|
35
|
+
exports.STORYBOOK_LANDING_PAGE_DOCS_URL = "https://github.com/Trackunit/manager/blob/master/docs/STORYBOOK-LANDING-PAGES.md";
|
|
36
|
+
/**
|
|
37
|
+
* Cache of landing-page scans, keyed by workspace root. The cached value is reused only while the
|
|
38
|
+
* current docs-file fingerprint matches, so long-lived ESLint processes pick up newly added or
|
|
39
|
+
* edited landing pages without requiring an ESLint server restart.
|
|
40
|
+
*/
|
|
41
|
+
const landingPageCache = new Map();
|
|
42
|
+
/** Clear the module-level scan cache (primarily for tests). */
|
|
43
|
+
const clearLandingPageCache = () => {
|
|
44
|
+
landingPageCache.clear();
|
|
45
|
+
};
|
|
46
|
+
exports.clearLandingPageCache = clearLandingPageCache;
|
|
47
|
+
const SKIP_DIRECTORIES = new Set(["node_modules", "dist", ".git", ".nx"]);
|
|
48
|
+
// Match `title="..."` anywhere within an opening `<Meta ...>` tag (attribute order independent).
|
|
49
|
+
const META_TITLE_REGEX = /<Meta\b[^>]*?\btitle\s*=\s*("|')(.*?)\1/;
|
|
50
|
+
const STORY_FILE_REGEX = /\.stories\.(js|jsx|ts|tsx)$/;
|
|
51
|
+
const DOCS_MDX_REGEX = /\.docs\.mdx$/;
|
|
52
|
+
/** Recursively collect absolute paths of every `*.docs.mdx` file under `dir`. */
|
|
53
|
+
const collectDocsMdxFiles = (dir) => {
|
|
54
|
+
const result = [];
|
|
55
|
+
let entries;
|
|
56
|
+
try {
|
|
57
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
const fullPath = path.join(dir, entry.name);
|
|
64
|
+
if (entry.isDirectory()) {
|
|
65
|
+
if (!SKIP_DIRECTORIES.has(entry.name)) {
|
|
66
|
+
result.push(...collectDocsMdxFiles(fullPath));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else if (DOCS_MDX_REGEX.test(entry.name)) {
|
|
70
|
+
result.push(fullPath);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
};
|
|
75
|
+
/** Strip import lines and the `<Meta .../>` tag, leaving the documentation body. */
|
|
76
|
+
const extractBody = (contents) => {
|
|
77
|
+
return contents
|
|
78
|
+
.split("\n")
|
|
79
|
+
.filter(line => !/^\s*import\s/.test(line))
|
|
80
|
+
.join("\n")
|
|
81
|
+
.replace(/<Meta\b[^>]*\/>/, "")
|
|
82
|
+
.trim();
|
|
83
|
+
};
|
|
84
|
+
/** Scan the workspace for `*.docs.mdx` landing pages, building a title -> entry map. */
|
|
85
|
+
const scanLandingPages = (workspaceRoot) => {
|
|
86
|
+
const landingPages = new Map();
|
|
87
|
+
const hash = crypto.createHash("sha256");
|
|
88
|
+
for (const filePath of collectDocsMdxFiles(workspaceRoot).toSorted()) {
|
|
89
|
+
hash.update(filePath);
|
|
90
|
+
hash.update("\0");
|
|
91
|
+
let contents;
|
|
92
|
+
try {
|
|
93
|
+
contents = fs.readFileSync(filePath, "utf-8");
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
hash.update("unreadable");
|
|
97
|
+
hash.update("\0");
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
hash.update(contents);
|
|
101
|
+
hash.update("\0");
|
|
102
|
+
const match = META_TITLE_REGEX.exec(contents);
|
|
103
|
+
if (match && typeof match[2] === "string") {
|
|
104
|
+
landingPages.set(match[2], { filePath, body: extractBody(contents) });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { fingerprint: hash.digest("hex"), landingPages };
|
|
108
|
+
};
|
|
109
|
+
/** Get landing pages from cache when the current docs-file fingerprint is unchanged. */
|
|
110
|
+
const getLandingPages = (workspaceRoot) => {
|
|
111
|
+
const scan = scanLandingPages(workspaceRoot);
|
|
112
|
+
const cached = landingPageCache.get(workspaceRoot);
|
|
113
|
+
if (cached?.fingerprint === scan.fingerprint) {
|
|
114
|
+
return cached.landingPages;
|
|
115
|
+
}
|
|
116
|
+
landingPageCache.set(workspaceRoot, scan);
|
|
117
|
+
return scan.landingPages;
|
|
118
|
+
};
|
|
119
|
+
/** Derive ancestor folder paths from a title, dropping the story leaf. */
|
|
120
|
+
const deriveFolders = (title) => {
|
|
121
|
+
const segments = title.split("/");
|
|
122
|
+
const folders = [];
|
|
123
|
+
// Drop the last segment (story leaf); accumulate prefixes for the rest.
|
|
124
|
+
for (let i = 1; i < segments.length; i++) {
|
|
125
|
+
folders.push(segments.slice(0, i).join("/"));
|
|
126
|
+
}
|
|
127
|
+
return folders;
|
|
128
|
+
};
|
|
129
|
+
/** A folder is satisfied by a landing page entry with non-empty body and no TODO marker. */
|
|
130
|
+
const isSatisfied = (entry) => {
|
|
131
|
+
if (!entry) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
if (entry.body.length === 0) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
return !entry.body.includes(exports.STORYBOOK_LANDING_PAGE_TODO_MARKER);
|
|
138
|
+
};
|
|
139
|
+
/** Unwrap satisfies/as wrappers around an expression. */
|
|
140
|
+
const unwrapExpression = (node) => {
|
|
141
|
+
let current = node;
|
|
142
|
+
while (current.type === utils_1.AST_NODE_TYPES.TSSatisfiesExpression || current.type === utils_1.AST_NODE_TYPES.TSAsExpression) {
|
|
143
|
+
current = current.expression;
|
|
144
|
+
}
|
|
145
|
+
return current;
|
|
146
|
+
};
|
|
147
|
+
/** Extract a string-literal `title` property from an object expression, if present. */
|
|
148
|
+
const getTitleFromObject = (object) => {
|
|
149
|
+
for (const property of object.properties) {
|
|
150
|
+
if (property.type === utils_1.AST_NODE_TYPES.Property &&
|
|
151
|
+
property.key.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
152
|
+
property.key.name === "title" &&
|
|
153
|
+
property.value.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
154
|
+
typeof property.value.value === "string") {
|
|
155
|
+
return property.value;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
};
|
|
160
|
+
exports.requireStorybookFolderLandingPage = createRule({
|
|
161
|
+
name: "require-storybook-folder-landing-page",
|
|
162
|
+
meta: {
|
|
163
|
+
type: "problem",
|
|
164
|
+
docs: {
|
|
165
|
+
description: "Require a Storybook landing page (*.docs.mdx) for every ancestor folder implied by a story's title",
|
|
166
|
+
},
|
|
167
|
+
schema: [],
|
|
168
|
+
messages: {
|
|
169
|
+
// URL embedded from the single-source-of-truth const (see STORYBOOK_LANDING_PAGE_DOCS_URL).
|
|
170
|
+
missingLandingPage: `[require-storybook-folder-landing-page] Missing Storybook landing page for folder "{{folder}}". The <Meta title> must equal "{{folder}}" exactly (no extra leaf). Add a *.docs.mdx with <Meta title="{{folder}}" /> and real content. See ${exports.STORYBOOK_LANDING_PAGE_DOCS_URL} for how to author one.`,
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
defaultOptions: [],
|
|
174
|
+
create(context) {
|
|
175
|
+
const currentFile = context.filename;
|
|
176
|
+
// Only analyze story files (never .docs.* or .mdx).
|
|
177
|
+
if (!STORY_FILE_REGEX.test(currentFile)) {
|
|
178
|
+
return {};
|
|
179
|
+
}
|
|
180
|
+
const nxJsonPath = (0, file_utils_1.findNearestFile)(currentFile, "nx.json");
|
|
181
|
+
if (nxJsonPath === null) {
|
|
182
|
+
return {};
|
|
183
|
+
}
|
|
184
|
+
const workspaceRoot = path.dirname(nxJsonPath);
|
|
185
|
+
const reportForObject = (titleNode, reportNode) => {
|
|
186
|
+
const title = titleNode.value;
|
|
187
|
+
if (typeof title !== "string") {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const folders = deriveFolders(title);
|
|
191
|
+
if (folders.length === 0) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const landingPages = getLandingPages(workspaceRoot);
|
|
195
|
+
for (const folder of folders) {
|
|
196
|
+
const entry = landingPages.get(folder);
|
|
197
|
+
if (isSatisfied(entry)) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
context.report({
|
|
201
|
+
node: reportNode,
|
|
202
|
+
messageId: "missingLandingPage",
|
|
203
|
+
data: { folder },
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
return {
|
|
208
|
+
ExportDefaultDeclaration(node) {
|
|
209
|
+
const declaration = node.declaration;
|
|
210
|
+
// Shape 1: export default { title: "..." }
|
|
211
|
+
if (declaration.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
|
|
212
|
+
const titleNode = getTitleFromObject(declaration);
|
|
213
|
+
if (titleNode) {
|
|
214
|
+
reportForObject(titleNode, node);
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// Shape 2: const meta = { title: "..." } satisfies Meta; export default meta;
|
|
219
|
+
if (declaration.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
220
|
+
const variable = context.sourceCode
|
|
221
|
+
.getScope(node)
|
|
222
|
+
.references.find(ref => ref.identifier === declaration)?.resolved;
|
|
223
|
+
const definition = variable?.defs[0];
|
|
224
|
+
if (definition && definition.node.type === utils_1.AST_NODE_TYPES.VariableDeclarator && definition.node.init) {
|
|
225
|
+
const initializer = unwrapExpression(definition.node.init);
|
|
226
|
+
if (initializer.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
|
|
227
|
+
const titleNode = getTitleFromObject(initializer);
|
|
228
|
+
if (titleNode) {
|
|
229
|
+
reportForObject(titleNode, node);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
//# sourceMappingURL=require-storybook-folder-landing-page.js.map
|
package/src/lib/rules-map.d.ts
CHANGED
package/src/lib/rules-map.js
CHANGED
|
@@ -18,6 +18,7 @@ const require_classname_alternatives_1 = require("./rules/require-classname-alte
|
|
|
18
18
|
const require_component_prop_contracts_1 = require("./rules/require-component-prop-contracts/require-component-prop-contracts");
|
|
19
19
|
const require_list_item_virtualization_props_1 = require("./rules/require-list-item-virtualization-props/require-list-item-virtualization-props");
|
|
20
20
|
const require_optional_prop_initialization_1 = require("./rules/require-optional-prop-initialization/require-optional-prop-initialization");
|
|
21
|
+
const require_storybook_folder_landing_page_1 = require("./rules/require-storybook-folder-landing-page/require-storybook-folder-landing-page");
|
|
21
22
|
exports.rulesMap = {
|
|
22
23
|
"component-name-matches-filename": component_name_matches_filename_1.componentNameMatchesFilename,
|
|
23
24
|
"one-component-per-file": one_component_per_file_1.oneComponentPerFile,
|
|
@@ -36,5 +37,6 @@ exports.rulesMap = {
|
|
|
36
37
|
"require-component-prop-contracts": require_component_prop_contracts_1.requireComponentPropContracts,
|
|
37
38
|
"prefer-field-components": prefer_field_components_1.preferFieldComponents,
|
|
38
39
|
"require-list-item-virtualization-props": require_list_item_virtualization_props_1.requireListItemVirtualizationProps,
|
|
40
|
+
"require-storybook-folder-landing-page": require_storybook_folder_landing_page_1.requireStorybookFolderLandingPage,
|
|
39
41
|
};
|
|
40
42
|
//# sourceMappingURL=rules-map.js.map
|
|
@@ -12,7 +12,6 @@ const utils_1 = require("@typescript-eslint/utils");
|
|
|
12
12
|
* - `isParameterExplicitlyDestructured()` - Check if parameter is explicitly destructured
|
|
13
13
|
* - `isParameterInitialized()` - Check if parameter has a default value
|
|
14
14
|
* - `isParameterUsedInFunction()` - Check if parameter is used in function body
|
|
15
|
-
* - `getParameterRestUsage()` - Get info about parameter accessed via rest params
|
|
16
15
|
* - `hasExistingBodyInitializations()` - Check for initialization patterns in function body
|
|
17
16
|
*
|
|
18
17
|
* ## Identifier Origin Analysis (Usage → Origin)
|
|
@@ -194,44 +193,6 @@ const isParameterUsedInFunction = (context, functionNode, paramName) => {
|
|
|
194
193
|
return false;
|
|
195
194
|
};
|
|
196
195
|
exports.isParameterUsedInFunction = isParameterUsedInFunction;
|
|
197
|
-
/**
|
|
198
|
-
* Check if a parameter is accessed via rest parameters (e.g., rest.paramName)
|
|
199
|
-
* and return information about the rest parameter usage
|
|
200
|
-
*/
|
|
201
|
-
const getParameterRestUsage = (context, functionNode, paramName) => {
|
|
202
|
-
// Look for rest elements in both direct params and inside object patterns
|
|
203
|
-
const restParameters = [];
|
|
204
|
-
functionNode.params.forEach(param => {
|
|
205
|
-
if (param.type === utils_1.AST_NODE_TYPES.RestElement && param.argument.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
206
|
-
// Direct rest parameter: (...rest: Params)
|
|
207
|
-
restParameters.push({ name: param.argument.name, param });
|
|
208
|
-
}
|
|
209
|
-
else if (param.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
|
|
210
|
-
// Look for rest elements inside object patterns: ({ param1, ...rest }: Params)
|
|
211
|
-
param.properties.forEach(property => {
|
|
212
|
-
if (property.type === utils_1.AST_NODE_TYPES.RestElement && property.argument.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
213
|
-
restParameters.push({ name: property.argument.name, param });
|
|
214
|
-
}
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
});
|
|
218
|
-
if (restParameters.length === 0) {
|
|
219
|
-
return { isUsedViaRest: false };
|
|
220
|
-
}
|
|
221
|
-
// Check if the function body contains rest.paramName patterns
|
|
222
|
-
const functionText = context.sourceCode.getText(functionNode);
|
|
223
|
-
for (const restParam of restParameters) {
|
|
224
|
-
const pattern = new RegExp(`\\b${restParam.name}\\.${paramName}\\b`);
|
|
225
|
-
if (pattern.test(functionText)) {
|
|
226
|
-
return {
|
|
227
|
-
isUsedViaRest: true,
|
|
228
|
-
restParamName: restParam.name,
|
|
229
|
-
restParam: restParam.param,
|
|
230
|
-
};
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
return { isUsedViaRest: false };
|
|
234
|
-
};
|
|
235
196
|
const jsxTagNameIsIntrinsic = (name) => name.type === utils_1.AST_NODE_TYPES.JSXIdentifier && /^[a-z]/.test(name.name);
|
|
236
197
|
const nodeContainsJSX = (node) => {
|
|
237
198
|
if (node.type === utils_1.AST_NODE_TYPES.JSXElement) {
|
|
@@ -337,49 +337,6 @@ const extractFromJsxAttribute = (node) => {
|
|
|
337
337
|
return [];
|
|
338
338
|
};
|
|
339
339
|
// =============================================================================
|
|
340
|
-
// Variable Declaration Extraction
|
|
341
|
-
// =============================================================================
|
|
342
|
-
/**
|
|
343
|
-
* Extract classname locations from a variable declaration with a classname-like name.
|
|
344
|
-
*
|
|
345
|
-
* Example: const buttonClass = "flex items-center";
|
|
346
|
-
*/
|
|
347
|
-
const extractFromVariableDeclarator = (node) => {
|
|
348
|
-
if (node.id.type !== utils_1.AST_NODE_TYPES.Identifier) {
|
|
349
|
-
return [];
|
|
350
|
-
}
|
|
351
|
-
const varName = node.id.name;
|
|
352
|
-
if (!(0, exports.isClassnameVariable)(varName)) {
|
|
353
|
-
return [];
|
|
354
|
-
}
|
|
355
|
-
if (!node.init) {
|
|
356
|
-
return [];
|
|
357
|
-
}
|
|
358
|
-
const context = {
|
|
359
|
-
type: "variable",
|
|
360
|
-
variableName: varName,
|
|
361
|
-
};
|
|
362
|
-
// Handle string literal
|
|
363
|
-
const extracted = extractStringValue(node.init);
|
|
364
|
-
if (extracted) {
|
|
365
|
-
return [
|
|
366
|
-
{
|
|
367
|
-
value: extracted.value,
|
|
368
|
-
reportNode: node.init,
|
|
369
|
-
fixNode: extracted.node,
|
|
370
|
-
context,
|
|
371
|
-
},
|
|
372
|
-
];
|
|
373
|
-
}
|
|
374
|
-
// Handle array of strings
|
|
375
|
-
if (node.init.type === utils_1.AST_NODE_TYPES.ArrayExpression) {
|
|
376
|
-
return extractFromArray(node.init, context);
|
|
377
|
-
}
|
|
378
|
-
// Note: Function calls in variable initializers are handled separately
|
|
379
|
-
// by the CallExpression handler
|
|
380
|
-
return [];
|
|
381
|
-
};
|
|
382
|
-
// =============================================================================
|
|
383
340
|
// Unified Entry Points
|
|
384
341
|
// =============================================================================
|
|
385
342
|
/**
|
|
@@ -426,27 +383,6 @@ const findClassnameStringsInAttribute = (node) => {
|
|
|
426
383
|
return extractFromJsxAttribute(node);
|
|
427
384
|
};
|
|
428
385
|
exports.findClassnameStringsInAttribute = findClassnameStringsInAttribute;
|
|
429
|
-
/**
|
|
430
|
-
* Find all classname string locations in a VariableDeclarator node.
|
|
431
|
-
*
|
|
432
|
-
* Use this in your ESLint rule's VariableDeclarator handler to process
|
|
433
|
-
* variables with classname-like names.
|
|
434
|
-
*
|
|
435
|
-
* @example
|
|
436
|
-
* ```typescript
|
|
437
|
-
* VariableDeclarator(node) {
|
|
438
|
-
* const locations = findClassnameStringsInVariable(node);
|
|
439
|
-
* for (const location of locations) {
|
|
440
|
-
* if (hasBannedPattern(location.value)) {
|
|
441
|
-
* context.report({ node: location.reportNode, ... });
|
|
442
|
-
* }
|
|
443
|
-
* }
|
|
444
|
-
* }
|
|
445
|
-
* ```
|
|
446
|
-
*/
|
|
447
|
-
const findClassnameStringsInVariable = (node) => {
|
|
448
|
-
return extractFromVariableDeclarator(node);
|
|
449
|
-
};
|
|
450
386
|
// =============================================================================
|
|
451
387
|
// Utility Helpers for Fixing
|
|
452
388
|
// =============================================================================
|
|
@@ -473,20 +409,4 @@ const formatAsArray = (classes) => {
|
|
|
473
409
|
return JSON.stringify(classes);
|
|
474
410
|
};
|
|
475
411
|
exports.formatAsArray = formatAsArray;
|
|
476
|
-
/**
|
|
477
|
-
* Get the quote character used in a string literal.
|
|
478
|
-
*/
|
|
479
|
-
const getQuoteChar = (node) => {
|
|
480
|
-
if (typeof node.value === "string" && node.raw) {
|
|
481
|
-
return node.raw.charAt(0);
|
|
482
|
-
}
|
|
483
|
-
return '"';
|
|
484
|
-
};
|
|
485
|
-
/**
|
|
486
|
-
* Create a quoted string with the same quote style as the original.
|
|
487
|
-
*/
|
|
488
|
-
const quoteString = (value, originalNode) => {
|
|
489
|
-
const quote = getQuoteChar(originalNode);
|
|
490
|
-
return `${quote}${value}${quote}`;
|
|
491
|
-
};
|
|
492
412
|
//# sourceMappingURL=classname-utils.js.map
|
|
@@ -12,11 +12,8 @@ const utils_1 = require("@typescript-eslint/utils");
|
|
|
12
12
|
* ## Main Functions
|
|
13
13
|
* - `addImportSpecifiers()` - Add new imports or merge with existing ones
|
|
14
14
|
* - `replaceNamespaceWithDestructured()` - Replace namespace imports (e.g., `import React from "react"`)
|
|
15
|
-
* - `addSingleImportSpecifier()` - Convenience for adding a single import
|
|
16
|
-
*
|
|
17
15
|
* ## Helper Functions
|
|
18
16
|
* - `findImportDeclaration()` - Find existing import for a package
|
|
19
|
-
* - `hasImportSpecifier()` - Check if specific import already exists
|
|
20
17
|
* - `getImportSpecifiers()` - Extract specifiers from an import
|
|
21
18
|
*/
|
|
22
19
|
/**
|
|
@@ -39,21 +36,6 @@ const findImportDeclaration = (sourceCode, packageName) => {
|
|
|
39
36
|
return found;
|
|
40
37
|
};
|
|
41
38
|
exports.findImportDeclaration = findImportDeclaration;
|
|
42
|
-
/**
|
|
43
|
-
* Check if a specific specifier is already imported from a package.
|
|
44
|
-
*
|
|
45
|
-
* @example
|
|
46
|
-
* // File has: import { useState, useEffect } from "react";
|
|
47
|
-
* hasImportSpecifier(sourceCode, "react", "useState") // → true
|
|
48
|
-
* hasImportSpecifier(sourceCode, "react", "useRef") // → false
|
|
49
|
-
*/
|
|
50
|
-
const hasImportSpecifier = (sourceCode, packageName, specifierName) => {
|
|
51
|
-
return sourceCode.ast.body.some(node => node.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
|
|
52
|
-
node.source.value === packageName &&
|
|
53
|
-
node.specifiers.some(specifier => specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
54
|
-
specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
55
|
-
specifier.imported.name === specifierName));
|
|
56
|
-
};
|
|
57
39
|
/**
|
|
58
40
|
* Get existing import specifiers from an import declaration.
|
|
59
41
|
*
|
|
@@ -119,24 +101,6 @@ const addImportSpecifiers = (options) => {
|
|
|
119
101
|
return fixes;
|
|
120
102
|
};
|
|
121
103
|
exports.addImportSpecifiers = addImportSpecifiers;
|
|
122
|
-
/**
|
|
123
|
-
* Add a single import specifier - convenience wrapper around addImportSpecifiers.
|
|
124
|
-
* Automatically finds and merges with existing imports from the same package.
|
|
125
|
-
*
|
|
126
|
-
* @example
|
|
127
|
-
* // Before: import { useState } from "react";
|
|
128
|
-
* // After: import { useEffect, useState } from "react";
|
|
129
|
-
* addSingleImportSpecifier({ sourceCode, fixer, packageName: "react", specifier: "useEffect" })
|
|
130
|
-
*/
|
|
131
|
-
const addSingleImportSpecifier = (options) => {
|
|
132
|
-
const { sourceCode, packageName, specifier } = options;
|
|
133
|
-
const existingImport = (0, exports.findImportDeclaration)(sourceCode, packageName);
|
|
134
|
-
return (0, exports.addImportSpecifiers)({
|
|
135
|
-
...options,
|
|
136
|
-
specifiers: [specifier],
|
|
137
|
-
existingImport,
|
|
138
|
-
});
|
|
139
|
-
};
|
|
140
104
|
/**
|
|
141
105
|
* Replace a namespace/default import with destructured imports.
|
|
142
106
|
* Automatically handles merging, semicolons, sorting, and formatting.
|