react-props-parser 0.0.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/LICENSE +21 -0
- package/README.md +158 -0
- package/dist/extractProperties.d.ts +23 -0
- package/dist/extractProperties.js +213 -0
- package/dist/handlers/union.d.ts +15 -0
- package/dist/handlers/union.js +71 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +93 -0
- package/dist/options.d.ts +24 -0
- package/dist/options.js +10 -0
- package/dist/program.d.ts +21 -0
- package/dist/program.js +172 -0
- package/dist/resolvePropsType.d.ts +52 -0
- package/dist/resolvePropsType.js +206 -0
- package/dist/types.d.ts +86 -0
- package/dist/types.js +1 -0
- package/dist/utils/jsdoc.d.ts +4 -0
- package/dist/utils/jsdoc.js +14 -0
- package/dist/utils/truncateTypeName.d.ts +17 -0
- package/dist/utils/truncateTypeName.js +23 -0
- package/package.json +70 -0
- package/src/vite/argTypes.js +32 -0
- package/src/vite/argTypesEnhancer.js +172 -0
- package/src/vite/index.js +101 -0
- package/src/vite/preset.js +12 -0
- package/src/webpack/index.js +104 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
/**
|
|
3
|
+
* Returns a ts.Program that has `filePath` up to date and ready to read,
|
|
4
|
+
* reusing a shared per-project ts.LanguageService rather than building a
|
|
5
|
+
* new ts.Program from scratch on every call. See the module comment above
|
|
6
|
+
* for why that distinction matters.
|
|
7
|
+
*
|
|
8
|
+
* Uses the *consuming project's* real tsconfig.json when one can be found
|
|
9
|
+
* (walking up from the file's directory), rather than a fixed set of
|
|
10
|
+
* compiler options.
|
|
11
|
+
*
|
|
12
|
+
* This matters beyond just matching the project's strictness settings:
|
|
13
|
+
* without the real `baseUrl`/`paths`, any import that relies on a path
|
|
14
|
+
* alias — including a cross-file `extends` base interface reached that
|
|
15
|
+
* way — fails to resolve, silently degrading to whatever TS falls back
|
|
16
|
+
* to rather than the actual project structure. Falls back to a fixed
|
|
17
|
+
* set of reasonable defaults when no tsconfig.json is found (e.g. a
|
|
18
|
+
* standalone fixture file with no project root), so parsing a bare
|
|
19
|
+
* .tsx file in isolation still works.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createProgramForFile(filePath: string): ts.Program;
|
package/dist/program.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const FALLBACK_OPTIONS = {
|
|
4
|
+
target: ts.ScriptTarget.ES2020,
|
|
5
|
+
module: ts.ModuleKind.ESNext,
|
|
6
|
+
jsx: ts.JsxEmit.ReactJSX,
|
|
7
|
+
esModuleInterop: true,
|
|
8
|
+
skipLibCheck: true,
|
|
9
|
+
strict: true,
|
|
10
|
+
};
|
|
11
|
+
// Every file parsed with the same effective compiler options (i.e. every
|
|
12
|
+
// file under the same resolved tsconfig, or every file that falls back to
|
|
13
|
+
// FALLBACK_OPTIONS) shares one long-lived ts.LanguageService instead of
|
|
14
|
+
// getting its own throwaway ts.Program. A LanguageService reparses only
|
|
15
|
+
// the files whose version actually changed since the last getProgram()
|
|
16
|
+
// call — it re-checks the rest (React's own types, lib.dom.d.ts, sibling
|
|
17
|
+
// component files already seen) from its cached ASTs. Building a fresh
|
|
18
|
+
// ts.Program per file, as this used to do, redid that shared work (often
|
|
19
|
+
// hundreds of files' worth of parsing/binding) on every single call —
|
|
20
|
+
// the dominant cost behind slow Storybook startup with many components.
|
|
21
|
+
//
|
|
22
|
+
// Keyed by resolved tsconfig path (or FALLBACK_KEY when none is found),
|
|
23
|
+
// since that's exactly the granularity at which compiler options — and
|
|
24
|
+
// therefore which files can safely share one Program — are the same.
|
|
25
|
+
const FALLBACK_KEY = '\0fallback';
|
|
26
|
+
const projectCache = new Map();
|
|
27
|
+
// Memoizes the two filesystem walks resolveCompilerOptions used to redo
|
|
28
|
+
// on every call: ts.findConfigFile (per directory) and reading/parsing
|
|
29
|
+
// the tsconfig it finds (per resolved config path). Many files share a
|
|
30
|
+
// directory or a tsconfig, so this turns O(files) I/O into O(directories)
|
|
31
|
+
// + O(tsconfigs).
|
|
32
|
+
const configPathByDir = new Map();
|
|
33
|
+
const optionsByConfigPath = new Map();
|
|
34
|
+
const registry = ts.createDocumentRegistry();
|
|
35
|
+
/**
|
|
36
|
+
* Returns a ts.Program that has `filePath` up to date and ready to read,
|
|
37
|
+
* reusing a shared per-project ts.LanguageService rather than building a
|
|
38
|
+
* new ts.Program from scratch on every call. See the module comment above
|
|
39
|
+
* for why that distinction matters.
|
|
40
|
+
*
|
|
41
|
+
* Uses the *consuming project's* real tsconfig.json when one can be found
|
|
42
|
+
* (walking up from the file's directory), rather than a fixed set of
|
|
43
|
+
* compiler options.
|
|
44
|
+
*
|
|
45
|
+
* This matters beyond just matching the project's strictness settings:
|
|
46
|
+
* without the real `baseUrl`/`paths`, any import that relies on a path
|
|
47
|
+
* alias — including a cross-file `extends` base interface reached that
|
|
48
|
+
* way — fails to resolve, silently degrading to whatever TS falls back
|
|
49
|
+
* to rather than the actual project structure. Falls back to a fixed
|
|
50
|
+
* set of reasonable defaults when no tsconfig.json is found (e.g. a
|
|
51
|
+
* standalone fixture file with no project root), so parsing a bare
|
|
52
|
+
* .tsx file in isolation still works.
|
|
53
|
+
*/
|
|
54
|
+
export function createProgramForFile(filePath) {
|
|
55
|
+
const { key, options, projectDir } = resolveProjectForFile(filePath);
|
|
56
|
+
const entry = getOrCreateProject(key, options, projectDir);
|
|
57
|
+
entry.rootFiles.add(filePath);
|
|
58
|
+
// Always bump the requested file's own version so its content is
|
|
59
|
+
// re-read from disk on every call (needed for correctness across
|
|
60
|
+
// repeated parse() calls / watch-mode edits) — sibling files already
|
|
61
|
+
// in rootFiles keep their cached version, which is what lets the
|
|
62
|
+
// LanguageService skip re-parsing them.
|
|
63
|
+
entry.versions.set(filePath, (entry.versions.get(filePath) ?? 0) + 1);
|
|
64
|
+
const program = entry.service.getProgram();
|
|
65
|
+
if (!program) {
|
|
66
|
+
throw new Error(`Could not build a TypeScript program for: ${filePath}`);
|
|
67
|
+
}
|
|
68
|
+
return program;
|
|
69
|
+
}
|
|
70
|
+
function resolveProjectForFile(filePath) {
|
|
71
|
+
const configPath = resolveConfigPathForDir(path.dirname(filePath));
|
|
72
|
+
if (!configPath) {
|
|
73
|
+
return { key: FALLBACK_KEY, options: FALLBACK_OPTIONS, projectDir: process.cwd() };
|
|
74
|
+
}
|
|
75
|
+
let options = optionsByConfigPath.get(configPath);
|
|
76
|
+
if (!options) {
|
|
77
|
+
options = readCompilerOptions(configPath);
|
|
78
|
+
optionsByConfigPath.set(configPath, options);
|
|
79
|
+
}
|
|
80
|
+
return { key: configPath, options, projectDir: path.dirname(configPath) };
|
|
81
|
+
}
|
|
82
|
+
function resolveConfigPathForDir(dir) {
|
|
83
|
+
if (configPathByDir.has(dir))
|
|
84
|
+
return configPathByDir.get(dir) ?? null;
|
|
85
|
+
const foundPath = ts.findConfigFile(dir, ts.sys.fileExists);
|
|
86
|
+
const resolved = foundPath ? resolveSolutionStyleReferences(foundPath) : null;
|
|
87
|
+
configPathByDir.set(dir, resolved);
|
|
88
|
+
return resolved;
|
|
89
|
+
}
|
|
90
|
+
function readCompilerOptions(configPath) {
|
|
91
|
+
const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
92
|
+
if (configFile.error)
|
|
93
|
+
return FALLBACK_OPTIONS;
|
|
94
|
+
const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(configPath));
|
|
95
|
+
// A project's tsconfig may not set jsx at all if it doesn't compile
|
|
96
|
+
// .tsx directly (e.g. relies on a bundler for that) — without it, TS
|
|
97
|
+
// can't parse JSX syntax at all, so every component file would fail
|
|
98
|
+
// outright. Fill it in rather than let real project configs break.
|
|
99
|
+
return {
|
|
100
|
+
...parsed.options,
|
|
101
|
+
jsx: parsed.options.jsx ?? ts.JsxEmit.ReactJSX,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function getOrCreateProject(key, options, projectDir) {
|
|
105
|
+
const cached = projectCache.get(key);
|
|
106
|
+
if (cached)
|
|
107
|
+
return cached;
|
|
108
|
+
const rootFiles = new Set();
|
|
109
|
+
const versions = new Map();
|
|
110
|
+
const host = {
|
|
111
|
+
getScriptFileNames: () => [...rootFiles],
|
|
112
|
+
getScriptVersion: (fileName) => String(versions.get(fileName) ?? 0),
|
|
113
|
+
getScriptSnapshot: (fileName) => {
|
|
114
|
+
if (!ts.sys.fileExists(fileName))
|
|
115
|
+
return undefined;
|
|
116
|
+
const text = ts.sys.readFile(fileName);
|
|
117
|
+
return text !== undefined ? ts.ScriptSnapshot.fromString(text) : undefined;
|
|
118
|
+
},
|
|
119
|
+
getCurrentDirectory: () => projectDir,
|
|
120
|
+
getCompilationSettings: () => options,
|
|
121
|
+
getDefaultLibFileName: (opts) => ts.getDefaultLibFilePath(opts),
|
|
122
|
+
fileExists: ts.sys.fileExists,
|
|
123
|
+
readFile: ts.sys.readFile,
|
|
124
|
+
readDirectory: ts.sys.readDirectory,
|
|
125
|
+
directoryExists: ts.sys.directoryExists,
|
|
126
|
+
getDirectories: ts.sys.getDirectories,
|
|
127
|
+
realpath: ts.sys.realpath,
|
|
128
|
+
};
|
|
129
|
+
const entry = {
|
|
130
|
+
service: ts.createLanguageService(host, registry),
|
|
131
|
+
rootFiles,
|
|
132
|
+
versions,
|
|
133
|
+
};
|
|
134
|
+
projectCache.set(key, entry);
|
|
135
|
+
return entry;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* A "solution-style" root tsconfig.json — `{ "files": [], "references":
|
|
139
|
+
* [...] }`, common in real projects split into build/test/etc. sub-
|
|
140
|
+
* projects — declares no compilerOptions of its own; the actual settings
|
|
141
|
+
* (target, paths, jsx, ...) live in one of the referenced configs. Taking
|
|
142
|
+
* such a root at face value silently produces near-empty options: no
|
|
143
|
+
* `paths`, meaning any path-aliased import — including a cross-file
|
|
144
|
+
* `extends` base interface reached that way — fails to resolve.
|
|
145
|
+
*
|
|
146
|
+
* Follows the first `references` entry (recursively, depth-bounded)
|
|
147
|
+
* until landing on a config that either declares its own compilerOptions
|
|
148
|
+
* (which may itself `extends` a base config — parseJsonConfigFileContent
|
|
149
|
+
* follows that chain natively) or has no further references to follow.
|
|
150
|
+
*/
|
|
151
|
+
function resolveSolutionStyleReferences(configPath, depth = 0) {
|
|
152
|
+
if (depth > 5)
|
|
153
|
+
return configPath;
|
|
154
|
+
const raw = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
155
|
+
if (raw.error || !raw.config)
|
|
156
|
+
return configPath;
|
|
157
|
+
const hasOwnOptions = raw.config.compilerOptions && Object.keys(raw.config.compilerOptions).length > 0;
|
|
158
|
+
const references = raw.config.references;
|
|
159
|
+
if (hasOwnOptions || !Array.isArray(references) || references.length === 0) {
|
|
160
|
+
return configPath;
|
|
161
|
+
}
|
|
162
|
+
const firstRef = references[0];
|
|
163
|
+
if (!firstRef?.path)
|
|
164
|
+
return configPath;
|
|
165
|
+
const refTarget = path.resolve(path.dirname(configPath), firstRef.path);
|
|
166
|
+
const nextConfigPath = ts.sys.fileExists(refTarget)
|
|
167
|
+
? refTarget
|
|
168
|
+
: path.join(refTarget, 'tsconfig.json');
|
|
169
|
+
if (!ts.sys.fileExists(nextConfigPath))
|
|
170
|
+
return configPath;
|
|
171
|
+
return resolveSolutionStyleReferences(nextConfigPath, depth + 1);
|
|
172
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { type ResolvedParseOptions } from './options.js';
|
|
3
|
+
export interface ResolvedPropsType {
|
|
4
|
+
type: ts.Type;
|
|
5
|
+
/** Node to resolve individual prop symbols' types/locations against. */
|
|
6
|
+
contextNode: ts.Node;
|
|
7
|
+
displayName: string;
|
|
8
|
+
/**
|
|
9
|
+
* Symbol to read the top-level jsdoc description from — the *local*
|
|
10
|
+
* type reference's own symbol, not necessarily `type.symbol`/
|
|
11
|
+
* `type.aliasSymbol`. Those reflect whatever the resolved type's
|
|
12
|
+
* own definition is, which for something like `Partial<FullUserFields>`
|
|
13
|
+
* is TypeScript's *built-in* `Partial` alias — picking up its doc
|
|
14
|
+
* comment ("Make all properties in T optional") would be wrong. This
|
|
15
|
+
* is resolved from the actual identifier as written at the reference
|
|
16
|
+
* site (or the declaration site, for the bare-fallback case), which
|
|
17
|
+
* correctly follows to an imported type's own symbol without also
|
|
18
|
+
* picking up an unrelated wrapper utility type's docs.
|
|
19
|
+
*/
|
|
20
|
+
docSymbol: ts.Symbol | undefined;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Finds the props type for a component in a source file, resolving it
|
|
24
|
+
* via the checker rather than by name-matching declarations in the same
|
|
25
|
+
* file. This matters because in a typical real codebase the props type
|
|
26
|
+
* is declared in a sibling `types.ts` and imported — using the checker
|
|
27
|
+
* to resolve whatever type node the component's signature actually
|
|
28
|
+
* points at follows that import for free, the same way it follows an
|
|
29
|
+
* `extends` base interface across files. Checked several ways, since
|
|
30
|
+
* "the props type" shows up in different places depending on how the
|
|
31
|
+
* component is written:
|
|
32
|
+
* - a parameter's own type annotation, `(props: XProps) => ...` or
|
|
33
|
+
* `({ a, b }: XProps) => ...` (destructured — the annotation is on
|
|
34
|
+
* the parameter, not affected by the binding pattern)
|
|
35
|
+
* - the variable's own type annotation, `const X: React.FC<XProps> = ({ a }) => ...`
|
|
36
|
+
* - any call expression with type arguments assigned to a variable —
|
|
37
|
+
* `forwardRef<Ref, XProps>((props, ref) => ...)`,
|
|
38
|
+
* `SomeWrapper<XProps>(Component, ...)`, `memo<XProps>(...)`, etc.
|
|
39
|
+
* Generalized rather than hardcoded to forwardRef/memo by name, since
|
|
40
|
+
* real component libraries wrap components in their own HOCs using
|
|
41
|
+
* the same shape (a type argument that *is* the props type).
|
|
42
|
+
* - a `memo(...)`/any other wrapper with no type arguments of its own —
|
|
43
|
+
* handled for free by walking the whole tree rather than only the
|
|
44
|
+
* top level of a function declaration or variable initializer, so
|
|
45
|
+
* the inner function/arrow expression's own parameter annotation is
|
|
46
|
+
* still found.
|
|
47
|
+
*
|
|
48
|
+
* Falls back to "first exported `Props`-or-`*Props`-named declaration
|
|
49
|
+
* in this file" when no component signature can be resolved at all
|
|
50
|
+
* (e.g. a bare type-only fixture with no component function).
|
|
51
|
+
*/
|
|
52
|
+
export declare function resolvePropsType(sourceFile: ts.SourceFile, checker: ts.TypeChecker, options?: ResolvedParseOptions): ResolvedPropsType | undefined;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { DEFAULT_PARSE_OPTIONS } from './options.js';
|
|
3
|
+
import { truncateTypeName } from './utils/truncateTypeName.js';
|
|
4
|
+
/**
|
|
5
|
+
* Finds the props type for a component in a source file, resolving it
|
|
6
|
+
* via the checker rather than by name-matching declarations in the same
|
|
7
|
+
* file. This matters because in a typical real codebase the props type
|
|
8
|
+
* is declared in a sibling `types.ts` and imported — using the checker
|
|
9
|
+
* to resolve whatever type node the component's signature actually
|
|
10
|
+
* points at follows that import for free, the same way it follows an
|
|
11
|
+
* `extends` base interface across files. Checked several ways, since
|
|
12
|
+
* "the props type" shows up in different places depending on how the
|
|
13
|
+
* component is written:
|
|
14
|
+
* - a parameter's own type annotation, `(props: XProps) => ...` or
|
|
15
|
+
* `({ a, b }: XProps) => ...` (destructured — the annotation is on
|
|
16
|
+
* the parameter, not affected by the binding pattern)
|
|
17
|
+
* - the variable's own type annotation, `const X: React.FC<XProps> = ({ a }) => ...`
|
|
18
|
+
* - any call expression with type arguments assigned to a variable —
|
|
19
|
+
* `forwardRef<Ref, XProps>((props, ref) => ...)`,
|
|
20
|
+
* `SomeWrapper<XProps>(Component, ...)`, `memo<XProps>(...)`, etc.
|
|
21
|
+
* Generalized rather than hardcoded to forwardRef/memo by name, since
|
|
22
|
+
* real component libraries wrap components in their own HOCs using
|
|
23
|
+
* the same shape (a type argument that *is* the props type).
|
|
24
|
+
* - a `memo(...)`/any other wrapper with no type arguments of its own —
|
|
25
|
+
* handled for free by walking the whole tree rather than only the
|
|
26
|
+
* top level of a function declaration or variable initializer, so
|
|
27
|
+
* the inner function/arrow expression's own parameter annotation is
|
|
28
|
+
* still found.
|
|
29
|
+
*
|
|
30
|
+
* Falls back to "first exported `Props`-or-`*Props`-named declaration
|
|
31
|
+
* in this file" when no component signature can be resolved at all
|
|
32
|
+
* (e.g. a bare type-only fixture with no component function).
|
|
33
|
+
*/
|
|
34
|
+
export function resolvePropsType(sourceFile, checker, options = DEFAULT_PARSE_OPTIONS) {
|
|
35
|
+
const fromSignature = findPropsTypeFromComponentSignature(sourceFile, checker, options);
|
|
36
|
+
if (fromSignature)
|
|
37
|
+
return fromSignature;
|
|
38
|
+
let fallback;
|
|
39
|
+
ts.forEachChild(sourceFile, (node) => {
|
|
40
|
+
if (fallback)
|
|
41
|
+
return;
|
|
42
|
+
if (ts.isInterfaceDeclaration(node) && isPropsName(node.name.text))
|
|
43
|
+
fallback = node;
|
|
44
|
+
if (ts.isTypeAliasDeclaration(node) && isPropsName(node.name.text))
|
|
45
|
+
fallback = node;
|
|
46
|
+
});
|
|
47
|
+
if (!fallback)
|
|
48
|
+
return undefined;
|
|
49
|
+
return {
|
|
50
|
+
type: checker.getTypeAtLocation(fallback),
|
|
51
|
+
contextNode: fallback,
|
|
52
|
+
displayName: fallback.name.text,
|
|
53
|
+
docSymbol: checker.getSymbolAtLocation(fallback.name),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function findPropsTypeFromComponentSignature(sourceFile, checker, options) {
|
|
57
|
+
let found;
|
|
58
|
+
const resolveFromTypeNode = (typeNode, contextNode) => {
|
|
59
|
+
if (found)
|
|
60
|
+
return;
|
|
61
|
+
const type = checker.getTypeFromTypeNode(typeNode);
|
|
62
|
+
// Not a plausible props type (e.g. a primitive parameter on some
|
|
63
|
+
// unrelated helper function encountered while walking the tree) —
|
|
64
|
+
// skip rather than confidently claim it.
|
|
65
|
+
if (!(type.getFlags() & ts.TypeFlags.Object) && !type.isUnion() && !type.isIntersection()) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// A props type is a plain data shape — it never has call/construct
|
|
69
|
+
// signatures. A parameter typed `React.ComponentType<XProps>` (or
|
|
70
|
+
// `FC<...>`, `ElementType<...>`, a class reference, etc.) is *itself*
|
|
71
|
+
// union-shaped and object-flagged, so it would otherwise pass the
|
|
72
|
+
// check above and get misidentified as the props type — this is
|
|
73
|
+
// exactly what a HOC factory's first parameter looks like
|
|
74
|
+
// (`withExtraProps(WrappedComponent: React.ComponentType<Props>)`),
|
|
75
|
+
// and matching it hands back React's own static properties
|
|
76
|
+
// (`contextTypes`, `defaultProps`, ...) instead of the real props.
|
|
77
|
+
const branches = type.isUnion() ? type.types : [type];
|
|
78
|
+
if (branches.every((t) => t.getCallSignatures().length > 0 || t.getConstructSignatures().length > 0)) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// Resolve documentation from the identifier actually written at
|
|
82
|
+
// this reference site (e.g. "XProps" in `props: XProps`), not from
|
|
83
|
+
// the resolved type's own symbol — see ResolvedPropsType.docSymbol.
|
|
84
|
+
const docSymbol = ts.isTypeReferenceNode(typeNode)
|
|
85
|
+
? checker.getSymbolAtLocation(ts.isQualifiedName(typeNode.typeName) ? typeNode.typeName.right : typeNode.typeName)
|
|
86
|
+
: (type.aliasSymbol ?? type.symbol);
|
|
87
|
+
found = {
|
|
88
|
+
type,
|
|
89
|
+
contextNode,
|
|
90
|
+
displayName: typeDisplayName(typeNode, type, checker, options),
|
|
91
|
+
docSymbol,
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
const checkParams = (params) => {
|
|
95
|
+
if (found || params.length === 0)
|
|
96
|
+
return;
|
|
97
|
+
const paramType = params[0].type;
|
|
98
|
+
if (paramType)
|
|
99
|
+
resolveFromTypeNode(paramType, params[0]);
|
|
100
|
+
};
|
|
101
|
+
// `const X: React.FC<XProps> = (...) => ...` — the props type is a
|
|
102
|
+
// type argument on the *variable's* annotation, not the parameter.
|
|
103
|
+
//
|
|
104
|
+
// Requires the variable to be exported: without the old React.FC-
|
|
105
|
+
// name-only filter, matching *any* generic type reference here would
|
|
106
|
+
// false-positive on unrelated code (`const cache: Map<string, X> = ...`).
|
|
107
|
+
// An unexported local isn't a plausible component to document anyway.
|
|
108
|
+
const checkVariableTypeAnnotation = (decl) => {
|
|
109
|
+
if (found || !decl.type || !ts.isTypeReferenceNode(decl.type) || !isExported(decl))
|
|
110
|
+
return;
|
|
111
|
+
const propsArg = decl.type.typeArguments?.[0];
|
|
112
|
+
if (propsArg)
|
|
113
|
+
resolveFromTypeNode(propsArg, decl);
|
|
114
|
+
};
|
|
115
|
+
// Any `SomeWrapper<..., XProps, ...>(...)` call assigned to a
|
|
116
|
+
// variable — forwardRef, memo, or a project's own HOC. Not hardcoded
|
|
117
|
+
// to a specific wrapper name: tries each type argument in order and
|
|
118
|
+
// takes the first that resolves to a plausible object/union/
|
|
119
|
+
// intersection type, since which position holds the props type
|
|
120
|
+
// varies by wrapper (forwardRef's is typically the 2nd, a simple
|
|
121
|
+
// custom wrapper's is often the 1st). Same export requirement as
|
|
122
|
+
// above, for the same reason (avoid matching e.g. a local
|
|
123
|
+
// `useMemo<Y>(...)` call).
|
|
124
|
+
const checkWrapperCall = (decl) => {
|
|
125
|
+
if (found || !decl.initializer || !ts.isCallExpression(decl.initializer) || !isExported(decl)) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const typeArgs = decl.initializer.typeArguments;
|
|
129
|
+
if (!typeArgs || typeArgs.length === 0)
|
|
130
|
+
return;
|
|
131
|
+
// Prefer a type argument whose own name looks like a props type
|
|
132
|
+
// (the near-universal `*Props` convention) over just taking the
|
|
133
|
+
// first object-like one — forwardRef<HTMLButtonElement, XProps>
|
|
134
|
+
// would otherwise match the ref type first, since it's an object
|
|
135
|
+
// type too.
|
|
136
|
+
const byName = typeArgs.find((arg) => ts.isTypeReferenceNode(arg) && /Props$/.test(rightmostName(arg.typeName)));
|
|
137
|
+
if (byName) {
|
|
138
|
+
resolveFromTypeNode(byName, decl);
|
|
139
|
+
if (found)
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
for (const typeArg of typeArgs) {
|
|
143
|
+
resolveFromTypeNode(typeArg, decl);
|
|
144
|
+
if (found)
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
const visit = (node) => {
|
|
149
|
+
if (found)
|
|
150
|
+
return;
|
|
151
|
+
if (ts.isFunctionDeclaration(node)) {
|
|
152
|
+
checkParams(node.parameters);
|
|
153
|
+
}
|
|
154
|
+
// Checked regardless of where this function/arrow expression sits
|
|
155
|
+
// in the tree (top-level initializer, wrapped in memo(...), the
|
|
156
|
+
// second argument to forwardRef(...), etc.) — forEachChild below
|
|
157
|
+
// reaches it either way, so no wrapper needs special-casing here.
|
|
158
|
+
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
|
|
159
|
+
checkParams(node.parameters);
|
|
160
|
+
}
|
|
161
|
+
if (ts.isVariableStatement(node)) {
|
|
162
|
+
for (const decl of node.declarationList.declarations) {
|
|
163
|
+
checkVariableTypeAnnotation(decl);
|
|
164
|
+
checkWrapperCall(decl);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
ts.forEachChild(node, visit);
|
|
168
|
+
};
|
|
169
|
+
visit(sourceFile);
|
|
170
|
+
return found;
|
|
171
|
+
}
|
|
172
|
+
function typeDisplayName(typeNode, type, checker, options) {
|
|
173
|
+
if (ts.isTypeReferenceNode(typeNode)) {
|
|
174
|
+
return rightmostName(typeNode.typeName);
|
|
175
|
+
}
|
|
176
|
+
// A component's real parameter annotation is often an intersection
|
|
177
|
+
// like `XProps & InternalProps & WrapperProps` — internal/wrapper
|
|
178
|
+
// additions mixed in alongside the type a human actually thinks of
|
|
179
|
+
// as "the props type". Prefer the first `*Props`-named constituent
|
|
180
|
+
// over the full (long, implementation-detail-laden) intersection
|
|
181
|
+
// string as the display name; the full prop set from every
|
|
182
|
+
// constituent is still returned regardless, this only affects the
|
|
183
|
+
// one-line name shown for the component.
|
|
184
|
+
if (ts.isIntersectionTypeNode(typeNode)) {
|
|
185
|
+
const namedProps = typeNode.types.find((t) => ts.isTypeReferenceNode(t) && /Props$/.test(rightmostName(t.typeName)));
|
|
186
|
+
if (namedProps && ts.isTypeReferenceNode(namedProps)) {
|
|
187
|
+
return rightmostName(namedProps.typeName);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// Union/inline object literal/anything else — no single clean
|
|
191
|
+
// reference name on the node itself; fall back to whatever symbol
|
|
192
|
+
// the checker resolved, or the stringified type as a last resort.
|
|
193
|
+
return (type.symbol?.name ??
|
|
194
|
+
type.aliasSymbol?.name ??
|
|
195
|
+
truncateTypeName(checker.typeToString(type), options.maxTypeNameLength));
|
|
196
|
+
}
|
|
197
|
+
function isExported(decl) {
|
|
198
|
+
const statement = decl.parent.parent;
|
|
199
|
+
return !!(ts.getCombinedModifierFlags(statement) & ts.ModifierFlags.Export);
|
|
200
|
+
}
|
|
201
|
+
function rightmostName(name) {
|
|
202
|
+
return ts.isQualifiedName(name) ? name.right.text : name.text;
|
|
203
|
+
}
|
|
204
|
+
function isPropsName(name) {
|
|
205
|
+
return name === 'Props' || name.endsWith('Props');
|
|
206
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export interface PropDescriptor {
|
|
2
|
+
name: string;
|
|
3
|
+
required: boolean;
|
|
4
|
+
type: {
|
|
5
|
+
/** Raw TS type text, e.g. "string", "'a' | 'b'" */
|
|
6
|
+
name: string;
|
|
7
|
+
/**
|
|
8
|
+
* Present only when this prop's type is a union of object shapes
|
|
9
|
+
* (e.g. `{type:'a',foo} | {type:'b',bar}`) — one entry per branch,
|
|
10
|
+
* each with its own resolved props. This is what lets a rich
|
|
11
|
+
* ArgsTable render per-variant fields instead of one opaque
|
|
12
|
+
* stringified union.
|
|
13
|
+
*/
|
|
14
|
+
elements?: UnionBranch[];
|
|
15
|
+
/**
|
|
16
|
+
* Present when this prop's type resolves to a plain object shape
|
|
17
|
+
* (a named interface/type-alias reference, or an inline object
|
|
18
|
+
* type) — its own fields, one level deep, each with jsdoc intact.
|
|
19
|
+
* `name` stays the original reference (e.g. "AvatarUser") so the
|
|
20
|
+
* type identity is still visible; this is the expanded shape
|
|
21
|
+
* alongside it, not a replacement for it.
|
|
22
|
+
*/
|
|
23
|
+
properties?: Record<string, PropDescriptor>;
|
|
24
|
+
/**
|
|
25
|
+
* Present when this prop's type is itself a function (a named
|
|
26
|
+
* function type alias, an `interface` call signature, or an inline
|
|
27
|
+
* `(...) => ...` type) — one entry per parameter of its call
|
|
28
|
+
* signature. Each parameter's own type is expanded the same way a
|
|
29
|
+
* regular object prop's would be (via `properties`), but only when
|
|
30
|
+
* it resolves to a type declared in the user's own project —
|
|
31
|
+
* expanding a built-in like `MouseEvent` would just surface
|
|
32
|
+
* TypeScript's own lib.dom.d.ts internals, which isn't what anyone
|
|
33
|
+
* wants to see in a props table.
|
|
34
|
+
*/
|
|
35
|
+
parameters?: ParameterDescriptor[];
|
|
36
|
+
/**
|
|
37
|
+
* Present alongside `parameters` — the call signature's return type,
|
|
38
|
+
* expanded via `properties` under the same user-defined-only rule.
|
|
39
|
+
*/
|
|
40
|
+
returnType?: FunctionTypePart;
|
|
41
|
+
};
|
|
42
|
+
description?: string;
|
|
43
|
+
/** From an `@default` jsdoc tag, e.g. `@default 3` -> "3" */
|
|
44
|
+
defaultValue?: {
|
|
45
|
+
value: string;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export interface ParameterDescriptor {
|
|
49
|
+
name: string;
|
|
50
|
+
required: boolean;
|
|
51
|
+
type: FunctionTypePart;
|
|
52
|
+
}
|
|
53
|
+
interface FunctionTypePart {
|
|
54
|
+
name: string;
|
|
55
|
+
/** Same rule as `PropDescriptor.type.properties` — only expanded for user-defined types, never for a built-in like `MouseEvent`. */
|
|
56
|
+
properties?: Record<string, PropDescriptor>;
|
|
57
|
+
}
|
|
58
|
+
export interface UnionBranch {
|
|
59
|
+
/** The literal discriminant prop shared across all branches, if one exists (e.g. `type: 'a'`). */
|
|
60
|
+
discriminant?: {
|
|
61
|
+
name: string;
|
|
62
|
+
value: string;
|
|
63
|
+
};
|
|
64
|
+
props: Record<string, PropDescriptor>;
|
|
65
|
+
}
|
|
66
|
+
export interface Documentation {
|
|
67
|
+
/** Name of the exported component/props type */
|
|
68
|
+
displayName: string;
|
|
69
|
+
description?: string;
|
|
70
|
+
/**
|
|
71
|
+
* Flattened prop set. When the top-level Props type is itself a
|
|
72
|
+
* union of object shapes, this is only the properties common to
|
|
73
|
+
* every branch (often empty) — variant-specific props, including
|
|
74
|
+
* their jsdoc, live in `elements` instead. This is the exact gap
|
|
75
|
+
* plain react-docgen has for a top-level union Props type: it
|
|
76
|
+
* reports the flattened set and drops per-variant jsdoc entirely.
|
|
77
|
+
*/
|
|
78
|
+
props: Record<string, PropDescriptor>;
|
|
79
|
+
/**
|
|
80
|
+
* Present only when the top-level Props type is a union of object
|
|
81
|
+
* shapes (e.g. `type Props = AProps | BProps | ...`) — one entry
|
|
82
|
+
* per branch, each with its own resolved props and jsdoc intact.
|
|
83
|
+
*/
|
|
84
|
+
elements?: UnionBranch[];
|
|
85
|
+
}
|
|
86
|
+
export {};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
export declare function getSymbolDescription(symbol: ts.Symbol, checker: ts.TypeChecker): string | undefined;
|
|
3
|
+
/** Reads an `@default <value>` jsdoc tag off a symbol, e.g. `@default 3` -> "3". */
|
|
4
|
+
export declare function getDefaultValueTag(symbol: ts.Symbol, checker: ts.TypeChecker): string | undefined;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
export function getSymbolDescription(symbol, checker) {
|
|
3
|
+
const parts = symbol.getDocumentationComment(checker);
|
|
4
|
+
const text = ts.displayPartsToString(parts).trim();
|
|
5
|
+
return text.length > 0 ? text : undefined;
|
|
6
|
+
}
|
|
7
|
+
/** Reads an `@default <value>` jsdoc tag off a symbol, e.g. `@default 3` -> "3". */
|
|
8
|
+
export function getDefaultValueTag(symbol, checker) {
|
|
9
|
+
const tag = symbol.getJsDocTags(checker).find((t) => t.name === 'default');
|
|
10
|
+
if (!tag)
|
|
11
|
+
return undefined;
|
|
12
|
+
const text = tag.text ? ts.displayPartsToString(tag.text).trim() : '';
|
|
13
|
+
return text.length > 0 ? text : undefined;
|
|
14
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caps a rendered type-name string (`checker.typeToString(...)` output)
|
|
3
|
+
* at `maxLength` characters, replacing the overflow with a single `…`.
|
|
4
|
+
*
|
|
5
|
+
* `checker.typeToString` has no length limit of its own — a utility-type
|
|
6
|
+
* chain like `JssSupportedProperty<Pick<RheaTextProps, "content" |
|
|
7
|
+
* "ariaLabel" | "semanticTag" | ...>>` can run to hundreds of characters,
|
|
8
|
+
* which overflows fixed-width UI (e.g. Storybook's ArgsTable) instead of
|
|
9
|
+
* wrapping. Truncating here, once, at the source keeps every consumer of
|
|
10
|
+
* `Documentation` simple — no repeated CSS `text-overflow` workarounds
|
|
11
|
+
* downstream, and the same cap applies whether the value ends up in a
|
|
12
|
+
* table cell, a tooltip, or a JSON dump.
|
|
13
|
+
*
|
|
14
|
+
* `maxLength` of `Infinity` (or any falsy/`<= 0` value skipped by the
|
|
15
|
+
* caller) disables truncation entirely.
|
|
16
|
+
*/
|
|
17
|
+
export declare function truncateTypeName(name: string, maxLength: number): string;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caps a rendered type-name string (`checker.typeToString(...)` output)
|
|
3
|
+
* at `maxLength` characters, replacing the overflow with a single `…`.
|
|
4
|
+
*
|
|
5
|
+
* `checker.typeToString` has no length limit of its own — a utility-type
|
|
6
|
+
* chain like `JssSupportedProperty<Pick<RheaTextProps, "content" |
|
|
7
|
+
* "ariaLabel" | "semanticTag" | ...>>` can run to hundreds of characters,
|
|
8
|
+
* which overflows fixed-width UI (e.g. Storybook's ArgsTable) instead of
|
|
9
|
+
* wrapping. Truncating here, once, at the source keeps every consumer of
|
|
10
|
+
* `Documentation` simple — no repeated CSS `text-overflow` workarounds
|
|
11
|
+
* downstream, and the same cap applies whether the value ends up in a
|
|
12
|
+
* table cell, a tooltip, or a JSON dump.
|
|
13
|
+
*
|
|
14
|
+
* `maxLength` of `Infinity` (or any falsy/`<= 0` value skipped by the
|
|
15
|
+
* caller) disables truncation entirely.
|
|
16
|
+
*/
|
|
17
|
+
export function truncateTypeName(name, maxLength) {
|
|
18
|
+
if (maxLength === Infinity || name.length <= maxLength)
|
|
19
|
+
return name;
|
|
20
|
+
if (maxLength <= 1)
|
|
21
|
+
return '…';
|
|
22
|
+
return `${name.slice(0, maxLength - 1)}…`;
|
|
23
|
+
}
|