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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tural Hajiyev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# react-props-parser
|
|
2
|
+
|
|
3
|
+
It's a typescript-aware props parser for React projects, built on the Typescript compiler API.
|
|
4
|
+
|
|
5
|
+
## Motivation
|
|
6
|
+
|
|
7
|
+
Storybook works fine with React and Typescript, but once the types get complex, ArgTable can't parse them fully. A few issues I ran into were:
|
|
8
|
+
|
|
9
|
+
- jsdoc for props not showing up in Storybook at all
|
|
10
|
+
- some types described as a union instead of the interface name
|
|
11
|
+
- an interface name shown, but no way to see the actual structure of the type
|
|
12
|
+
- with union types, jsdocs disappearing entirely (the value itself comes through, but if a prop is optional, consumers have no way of seeing that)
|
|
13
|
+
|
|
14
|
+
It becomes a bigger issue when consumers need to see the props and end up having to open a code editor and go find the type definition in the library themselves.
|
|
15
|
+
|
|
16
|
+
While digging into this, I realized Storybook actually uses two different parsers under the hood — `react-docgen` and `react-docgen-typescript`.
|
|
17
|
+
|
|
18
|
+
They work fine up to a point, but often you hit a wall, need to start overriding argTypes by hand, and end up with two sources of truth (the type files and Storybook itself).
|
|
19
|
+
|
|
20
|
+
To fix this, I decided to use Claude Code and try writing my own parser to see if it could be solved properly.
|
|
21
|
+
|
|
22
|
+
That's how this journey started.
|
|
23
|
+
|
|
24
|
+
## Comparison with react-docgen and react-docgen-typescript
|
|
25
|
+
|
|
26
|
+
| Feature | react-docgen | react-docgen-typescript | react-props-parser |
|
|
27
|
+
| ----- | ----- | ----- | ----- |
|
|
28
|
+
| Primitive properties | renders type | renders type | renders type |
|
|
29
|
+
| Named interface | renders only name of interface (unable to see the structure) | renders only name of interface (unable to see the structure) | renders name of interface, clickable to see the structure of the interface |
|
|
30
|
+
| Array of named interface | same limitation as previous one | same limitation as previous one | works the same as previous one |
|
|
31
|
+
| Anonymous interface | renders structure of interface as text | renders structure of interface as text | renders clickable Props interface, click to see the structure |
|
|
32
|
+
| Array of anonymous interfaces | same limitation as previous one | same limitation as previous one | works the same as previous one |
|
|
33
|
+
| Union primitives | renders 'union' keyword | renders name of each type | renders name of each type |
|
|
34
|
+
| Union interfaces | renders 'union' keyword | renders name of interfaces | renders clickable names of interfaces, click to see the type of each |
|
|
35
|
+
| Nested interfaces | shows only name of interface | shows only name of interface | clickable name of interface, click to see the structure |
|
|
36
|
+
| Utility functions (Pick, Omit, Partial, Required) | renders name of utility function ('Pick', 'Omit', etc.) | renders the definition, e.g. `Pick<FirstAction, "type">` | renders clickable definition, click to see the structure |
|
|
37
|
+
| Function with interface call signature | renders only name of the interface | renders only name of the interface | renders name of the interface with popup to show arguments and return type |
|
|
38
|
+
| Named function type | renders string with arguments and return type | renders string with arguments and return type | renders name of interface, with popup to show function arguments and return type |
|
|
39
|
+
| Anonymous function | renders string with arguments and return type | renders string with arguments and return type | renders clickable string to show detailed type |
|
|
40
|
+
| Anonymous function with interface argument | renders string with name of interface | renders string with name of interface | renders clickable string with popup to show structure of interface |
|
|
41
|
+
| forwardRef | same output with above mentioned details | same output with above mentioned details | same output with above mentioned details |
|
|
42
|
+
| HOC | unable to render props | unable to render props | renders all props of the main component |
|
|
43
|
+
| HOC with extra props | renders only extra props | renders only extra props | renders all props of the main component and extra props |
|
|
44
|
+
|
|
45
|
+
Here is the visual version of comparison of these 3 tools (from left to right: react-docgen, react-docgen-typescript, react-props-parser):
|
|
46
|
+
|
|
47
|
+
Basic Component:
|
|
48
|
+
|
|
49
|
+

|
|
50
|
+
|
|
51
|
+
With HOC:
|
|
52
|
+
|
|
53
|
+

|
|
54
|
+
|
|
55
|
+
## Quick start
|
|
56
|
+
|
|
57
|
+
Most people use this through the Vite or webpack integration below to get
|
|
58
|
+
Storybook's Controls panel working properly — jump to whichever builder you
|
|
59
|
+
use. If you just want the parsed props as JSON, call `parse()` directly:
|
|
60
|
+
|
|
61
|
+
## Using it with Storybook + Vite
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm install --save-dev react-props-parser
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
In `.storybook/main.ts`:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import type { StorybookConfig } from '@storybook/react-vite';
|
|
71
|
+
import { viteLoader } from 'react-props-parser/vite';
|
|
72
|
+
|
|
73
|
+
const config: StorybookConfig = {
|
|
74
|
+
framework: '@storybook/react-vite',
|
|
75
|
+
addons: [
|
|
76
|
+
'@storybook/addon-essentials',
|
|
77
|
+
// Registers a Storybook argTypesEnhancer that hides Controls fields
|
|
78
|
+
// from non-active union branches and fills in branch-specific
|
|
79
|
+
// descriptions — runs automatically for every story.
|
|
80
|
+
'react-props-parser/vite/preset',
|
|
81
|
+
],
|
|
82
|
+
typescript: {
|
|
83
|
+
// Turn off Storybook's built-in docgen — react-props-parser supplies
|
|
84
|
+
// __docgenInfo itself via the plugin below.
|
|
85
|
+
reactDocgen: false,
|
|
86
|
+
},
|
|
87
|
+
async viteFinal(viteConfig) {
|
|
88
|
+
viteConfig.plugins ??= [];
|
|
89
|
+
// Pass options to viteLoader() to configure parsing, e.g. raise
|
|
90
|
+
// (or Infinity to disable) the 50-char default cap on rendered
|
|
91
|
+
// type-name strings like `Pick<Foo, "a" | "b" | ...>`.
|
|
92
|
+
viteConfig.plugins.push(viteLoader({ maxTypeNameLength: 80 }));
|
|
93
|
+
return viteConfig;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export default config;
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Using it with Storybook + webpack
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
npm install --save-dev react-props-parser
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
In `.storybook/main.ts`:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import type { StorybookConfig } from '@storybook/react-webpack5';
|
|
110
|
+
|
|
111
|
+
const config: StorybookConfig = {
|
|
112
|
+
framework: '@storybook/react-webpack5',
|
|
113
|
+
addons: [
|
|
114
|
+
'@storybook/addon-essentials',
|
|
115
|
+
// Same preset used by the Vite setup — it's builder-agnostic.
|
|
116
|
+
'react-props-parser/vite/preset',
|
|
117
|
+
],
|
|
118
|
+
typescript: {
|
|
119
|
+
reactDocgen: false,
|
|
120
|
+
},
|
|
121
|
+
webpackFinal: async (webpackConfig) => {
|
|
122
|
+
webpackConfig.module ??= { rules: [] };
|
|
123
|
+
webpackConfig.module.rules ??= [];
|
|
124
|
+
webpackConfig.module.rules.push({
|
|
125
|
+
test: /\.tsx$/,
|
|
126
|
+
exclude: /node_modules/,
|
|
127
|
+
enforce: 'pre',
|
|
128
|
+
// `options` here is react-props-parser's own ParseOptions, forwarded
|
|
129
|
+
// to every parse() call — same fields as the Vite loader above.
|
|
130
|
+
use: [{ loader: 'react-props-parser/webpack', options: { maxTypeNameLength: 80 } }],
|
|
131
|
+
});
|
|
132
|
+
return webpackConfig;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export default config;
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Either way, once wired in, every exported component in a `.tsx` file gets a
|
|
140
|
+
`Component.__docgenInfo` static property attached at build time — the same
|
|
141
|
+
convention `react-docgen-typescript`-based tooling already reads — so
|
|
142
|
+
Storybook's addon-docs and Controls panel pick it up with no other changes.
|
|
143
|
+
|
|
144
|
+
## Configuration
|
|
145
|
+
|
|
146
|
+
Both `viteLoader(options)` and the webpack loader's rule `options` take the
|
|
147
|
+
same `ParseOptions` object accepted by `parse()` directly:
|
|
148
|
+
|
|
149
|
+
| Option | Default | What it does |
|
|
150
|
+
| -------------------- | ------- | -------------------------------------------------------------------------------------------------- |
|
|
151
|
+
| `maxTypeNameLength` | `50` | Caps a rendered type-name string (a prop's type, a union branch, a fallback displayName) at N characters, truncating with `…`. Set to `Infinity` to disable. |
|
|
152
|
+
| `maxDepth` | `2` | How many levels of nested object props get expanded into `type.properties`. |
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
import { parse } from 'react-props-parser';
|
|
156
|
+
|
|
157
|
+
const doc = parse('./Button.tsx', { maxTypeNameLength: 80 });
|
|
158
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import type { PropDescriptor } from './types.js';
|
|
3
|
+
import { type ResolvedParseOptions } from './options.js';
|
|
4
|
+
/**
|
|
5
|
+
* Given an already-resolved ts.Type, walks `checker.getPropertiesOfType`,
|
|
6
|
+
* converting each property symbol into a PropDescriptor.
|
|
7
|
+
* `checker.getPropertiesOfType` already flattens `extends`/intersection
|
|
8
|
+
* chains, so no separate extends/intersection handler is needed. Takes
|
|
9
|
+
* a raw ts.Type (rather than a declaration node) so it works equally
|
|
10
|
+
* for the top-level Props type, each branch of a union.ts split, and a
|
|
11
|
+
* recursive nested-object expansion below.
|
|
12
|
+
*
|
|
13
|
+
* A prop whose type is itself a plain object shape (a named interface
|
|
14
|
+
* reference like `user: AvatarUser`, or an inline object type) gets
|
|
15
|
+
* expanded into `type.properties`, recursively, up to `maxDepth`
|
|
16
|
+
* levels (default 2) — deep enough that an interface-inside-an-
|
|
17
|
+
* interface actually shows its own nested shape, but bounded so a
|
|
18
|
+
* self-referential or very deep type can't blow up the output. `seen`
|
|
19
|
+
* additionally guards against infinite recursion on a type that
|
|
20
|
+
* refers back to one already being expanded in the current chain
|
|
21
|
+
* (e.g. `interface TreeNode { children: TreeNode[] }`).
|
|
22
|
+
*/
|
|
23
|
+
export declare function extractPropertiesFromType(type: ts.Type, contextNode: ts.Node, checker: ts.TypeChecker, depth?: number, options?: ResolvedParseOptions, seen?: ReadonlySet<ts.Type>): Record<string, PropDescriptor>;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { getSymbolDescription, getDefaultValueTag } from './utils/jsdoc.js';
|
|
3
|
+
import { resolveUnionBranches } from './handlers/union.js';
|
|
4
|
+
import { truncateTypeName } from './utils/truncateTypeName.js';
|
|
5
|
+
import { DEFAULT_PARSE_OPTIONS } from './options.js';
|
|
6
|
+
/** Summary label shown in place of an inline/unnamed object type's own printed shape — see the comment at its use site below. */
|
|
7
|
+
const ANONYMOUS_OBJECT_LABEL = 'Props';
|
|
8
|
+
/**
|
|
9
|
+
* Given an already-resolved ts.Type, walks `checker.getPropertiesOfType`,
|
|
10
|
+
* converting each property symbol into a PropDescriptor.
|
|
11
|
+
* `checker.getPropertiesOfType` already flattens `extends`/intersection
|
|
12
|
+
* chains, so no separate extends/intersection handler is needed. Takes
|
|
13
|
+
* a raw ts.Type (rather than a declaration node) so it works equally
|
|
14
|
+
* for the top-level Props type, each branch of a union.ts split, and a
|
|
15
|
+
* recursive nested-object expansion below.
|
|
16
|
+
*
|
|
17
|
+
* A prop whose type is itself a plain object shape (a named interface
|
|
18
|
+
* reference like `user: AvatarUser`, or an inline object type) gets
|
|
19
|
+
* expanded into `type.properties`, recursively, up to `maxDepth`
|
|
20
|
+
* levels (default 2) — deep enough that an interface-inside-an-
|
|
21
|
+
* interface actually shows its own nested shape, but bounded so a
|
|
22
|
+
* self-referential or very deep type can't blow up the output. `seen`
|
|
23
|
+
* additionally guards against infinite recursion on a type that
|
|
24
|
+
* refers back to one already being expanded in the current chain
|
|
25
|
+
* (e.g. `interface TreeNode { children: TreeNode[] }`).
|
|
26
|
+
*/
|
|
27
|
+
export function extractPropertiesFromType(type, contextNode, checker, depth = 0, options = DEFAULT_PARSE_OPTIONS, seen = new Set()) {
|
|
28
|
+
const { maxDepth } = options;
|
|
29
|
+
const props = {};
|
|
30
|
+
for (const symbol of checker.getPropertiesOfType(type)) {
|
|
31
|
+
// Read optionality off the checker-resolved symbol flag, not the
|
|
32
|
+
// original AST declaration's `?` token. For a plain interface prop
|
|
33
|
+
// those agree, but a mapped utility type (Partial<T>, Required<T>,
|
|
34
|
+
// Pick<T, K>, ...) changes optionality on the *resolved* type
|
|
35
|
+
// without touching the original declaration node — checking
|
|
36
|
+
// questionToken there would silently report Partial<T>'s props as
|
|
37
|
+
// required and Required<T>'s props as still optional.
|
|
38
|
+
const required = !(symbol.flags & ts.SymbolFlags.Optional);
|
|
39
|
+
let propType = checker.getTypeOfSymbolAtLocation(symbol, contextNode);
|
|
40
|
+
// Optional props (`foo?: T`) resolve via the checker as `T | undefined`.
|
|
41
|
+
// `required` already conveys optionality, so strip the implicit
|
|
42
|
+
// undefined member to match react-docgen-typescript's convention and
|
|
43
|
+
// avoid redundant noise in the ArgsTable.
|
|
44
|
+
if (!required) {
|
|
45
|
+
propType = checker.getNonNullableType(propType);
|
|
46
|
+
}
|
|
47
|
+
const defaultValue = getDefaultValueTag(symbol, checker);
|
|
48
|
+
const elements = resolveUnionBranches(propType, contextNode, checker, options);
|
|
49
|
+
// For an array prop (`tags: Tag[]`), the interesting shape to expand
|
|
50
|
+
// is the *element* type, not the array itself — `checker.
|
|
51
|
+
// getPropertiesOfType` on an array type would just walk Array.prototype
|
|
52
|
+
// (length, push, map, ...), which is exactly the noise consumers
|
|
53
|
+
// don't want surfaced. Array<T>/ReadonlyArray<T> are always generic
|
|
54
|
+
// type references, so their element type is their sole type argument.
|
|
55
|
+
const arrayElementType = checker.isArrayType(propType)
|
|
56
|
+
? checker.getTypeArguments(propType)[0]
|
|
57
|
+
: undefined;
|
|
58
|
+
const expandableType = arrayElementType ?? propType;
|
|
59
|
+
// A union already gets its own per-branch breakdown via `elements`
|
|
60
|
+
// above; `properties` is for the simpler case of a single nested
|
|
61
|
+
// object shape, e.g. `user: AvatarUser` or `tags: Tag[]`. type.name
|
|
62
|
+
// stays the raw reference ("AvatarUser", "Tag[]") so the type
|
|
63
|
+
// identity is still visible — this is the expanded shape alongside
|
|
64
|
+
// it, not a replacement. For `tags: Tag[]` this is Tag's own
|
|
65
|
+
// properties, not a property named after an array index.
|
|
66
|
+
const properties = !elements &&
|
|
67
|
+
expandableType &&
|
|
68
|
+
depth < maxDepth &&
|
|
69
|
+
!seen.has(expandableType) &&
|
|
70
|
+
isExpandableObjectType(expandableType, checker)
|
|
71
|
+
? extractPropertiesFromType(expandableType, contextNode, checker, depth + 1, options, new Set(seen).add(expandableType))
|
|
72
|
+
: undefined;
|
|
73
|
+
// A function-shaped prop (named function type alias, `interface`
|
|
74
|
+
// call signature, or inline `(...) => ...`) — not expanded via
|
|
75
|
+
// `properties` above (isExpandableObjectType excludes call
|
|
76
|
+
// signatures), but its parameters and return type are still worth
|
|
77
|
+
// breaking down, same idea one level in.
|
|
78
|
+
const signature = !elements && !properties && expandableType && expandableType.getCallSignatures().length > 0
|
|
79
|
+
? extractFunctionSignature(expandableType, contextNode, checker, depth, options, seen)
|
|
80
|
+
: undefined;
|
|
81
|
+
// An inline, unnamed object type (`controls: { visible: boolean; ... }`,
|
|
82
|
+
// as opposed to a named interface/type-alias reference like `user:
|
|
83
|
+
// AvatarUser`) has no real identity to show as a summary — TS's own
|
|
84
|
+
// printed form is just the whole shape crammed onto one line, which
|
|
85
|
+
// truncateTypeName then cuts off mid-field ("{ visible: boolean;
|
|
86
|
+
// label: string; disabl…"). There's nothing useful about that as a
|
|
87
|
+
// *name*, and it duplicates `properties` below anyway, so swap it
|
|
88
|
+
// for a short, generic placeholder instead of truncating it — the
|
|
89
|
+
// real shape is still one click away via `properties`. Keeps the
|
|
90
|
+
// `[]` suffix for an anonymous-object array element so the summary
|
|
91
|
+
// doesn't silently drop that it's a list.
|
|
92
|
+
const isAnonymousObject = !!properties && isAnonymousObjectType(expandableType);
|
|
93
|
+
const typeName = isAnonymousObject
|
|
94
|
+
? arrayElementType
|
|
95
|
+
? `${ANONYMOUS_OBJECT_LABEL}[]`
|
|
96
|
+
: ANONYMOUS_OBJECT_LABEL
|
|
97
|
+
: truncateTypeName(checker.typeToString(propType), options.maxTypeNameLength);
|
|
98
|
+
props[symbol.name] = {
|
|
99
|
+
name: symbol.name,
|
|
100
|
+
required,
|
|
101
|
+
type: {
|
|
102
|
+
name: typeName,
|
|
103
|
+
...(elements ? { elements } : {}),
|
|
104
|
+
...(properties ? { properties } : {}),
|
|
105
|
+
...(signature?.parameters ? { parameters: signature.parameters } : {}),
|
|
106
|
+
...(signature?.returnType ? { returnType: signature.returnType } : {}),
|
|
107
|
+
},
|
|
108
|
+
description: getSymbolDescription(symbol, checker),
|
|
109
|
+
...(defaultValue !== undefined ? { defaultValue: { value: defaultValue } } : {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return props;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Breaks a function-shaped type's (first) call signature down into its
|
|
116
|
+
* parameters and return type, expanding either the same way a regular
|
|
117
|
+
* object prop is — but only when that type is declared in the user's
|
|
118
|
+
* own project. `checker.getPropertiesOfType` on a param or return type
|
|
119
|
+
* of `MouseEvent` would happily walk lib.dom.d.ts's ~30 fields; nobody
|
|
120
|
+
* asking about their own `onSelect: (event: SelectionEvent) => Result`
|
|
121
|
+
* wants that, but they very much do want `SelectionEvent`'s/`Result`'s
|
|
122
|
+
* own shape expanded since those are theirs.
|
|
123
|
+
*
|
|
124
|
+
* Only the first signature is used — overloaded function types are rare
|
|
125
|
+
* for a props position, and picking one deterministically beats trying
|
|
126
|
+
* to merge/pick among several.
|
|
127
|
+
*/
|
|
128
|
+
function extractFunctionSignature(type, contextNode, checker, depth, options, seen) {
|
|
129
|
+
const signature = type.getCallSignatures()[0];
|
|
130
|
+
if (!signature)
|
|
131
|
+
return undefined;
|
|
132
|
+
const expandPart = (partType) => {
|
|
133
|
+
const { maxDepth } = options;
|
|
134
|
+
const properties = depth < maxDepth &&
|
|
135
|
+
!seen.has(partType) &&
|
|
136
|
+
isExpandableObjectType(partType, checker) &&
|
|
137
|
+
isUserDefinedType(partType)
|
|
138
|
+
? extractPropertiesFromType(partType, contextNode, checker, depth + 1, options, new Set(seen).add(partType))
|
|
139
|
+
: undefined;
|
|
140
|
+
return {
|
|
141
|
+
name: truncateTypeName(checker.typeToString(partType), options.maxTypeNameLength),
|
|
142
|
+
...(properties ? { properties } : {}),
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
const parameters = signature.parameters.length === 0
|
|
146
|
+
? undefined
|
|
147
|
+
: signature.parameters.map((paramSymbol) => {
|
|
148
|
+
const paramType = checker.getTypeOfSymbolAtLocation(paramSymbol, contextNode);
|
|
149
|
+
const declaration = paramSymbol.valueDeclaration;
|
|
150
|
+
const required = !declaration?.questionToken && !declaration?.initializer;
|
|
151
|
+
return { name: paramSymbol.name, required, type: expandPart(paramType) };
|
|
152
|
+
});
|
|
153
|
+
// Always reported, even for `void`/`undefined`/`any`/`unknown` — the
|
|
154
|
+
// signature isn't complete without it, and a caller reading a
|
|
155
|
+
// function-shaped prop's full type wants to see "=> void" rather than
|
|
156
|
+
// have the return silently vanish. `expandPart` naturally adds no
|
|
157
|
+
// `properties` for these anyway, since none of them are an
|
|
158
|
+
// expandable object shape.
|
|
159
|
+
const returnType = expandPart(checker.getReturnTypeOfSignature(signature));
|
|
160
|
+
return { parameters, returnType };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* True when every declaration of this type's symbol lives outside
|
|
164
|
+
* TypeScript's own bundled lib files (lib.dom.d.ts, lib.es5.d.ts, ...) —
|
|
165
|
+
* that's how a built-in like `MouseEvent` or `Event` is told apart from
|
|
166
|
+
* an interface the user actually wrote, regardless of what it's named.
|
|
167
|
+
* A type with no declarations (an anonymous literal) counts as
|
|
168
|
+
* user-defined — there's nothing built-in about it.
|
|
169
|
+
*/
|
|
170
|
+
function isUserDefinedType(type) {
|
|
171
|
+
const symbol = type.symbol ?? type.aliasSymbol;
|
|
172
|
+
const declarations = symbol?.getDeclarations();
|
|
173
|
+
if (!declarations || declarations.length === 0)
|
|
174
|
+
return true;
|
|
175
|
+
return declarations.every((decl) => !isBuiltinLibFile(decl.getSourceFile().fileName));
|
|
176
|
+
}
|
|
177
|
+
function isBuiltinLibFile(fileName) {
|
|
178
|
+
return /[/\\]typescript[/\\]lib[/\\]lib\.[^/\\]+\.d\.ts$/.test(fileName);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* True for a plain object shape worth expanding — a named interface/
|
|
182
|
+
* type-alias reference or an inline object literal type with at least
|
|
183
|
+
* one property, and not a function, array, or other built-in with its
|
|
184
|
+
* own (usually large, uninteresting) property set.
|
|
185
|
+
*/
|
|
186
|
+
function isExpandableObjectType(type, checker) {
|
|
187
|
+
if (!(type.getFlags() & ts.TypeFlags.Object))
|
|
188
|
+
return false;
|
|
189
|
+
if (type.getCallSignatures().length > 0)
|
|
190
|
+
return false;
|
|
191
|
+
const symbolName = type.symbol?.name;
|
|
192
|
+
if (symbolName === 'Array' || symbolName === 'ReadonlyArray' || symbolName === 'Date') {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
return checker.getPropertiesOfType(type).length > 0;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* True for an object type with no name to show. A genuinely inline
|
|
199
|
+
* `{ ... }` written directly at the property position still gets a
|
|
200
|
+
* symbol from the checker — it's just the synthetic one every type
|
|
201
|
+
* literal gets, named `ts.InternalSymbolName.Type` ("__type"), not a
|
|
202
|
+
* real declared name — so checking for *a* symbol isn't enough; this
|
|
203
|
+
* checks for a *named* one. `aliasSymbol` is checked separately since
|
|
204
|
+
* `type Foo = { ... }` carries the same symbol-less underlying type but
|
|
205
|
+
* has a real name one level up, via the alias rather than the type
|
|
206
|
+
* itself — both it and a named interface reference print their own
|
|
207
|
+
* name via `checker.typeToString` and should keep it.
|
|
208
|
+
*/
|
|
209
|
+
function isAnonymousObjectType(type) {
|
|
210
|
+
const symbol = type.getSymbol();
|
|
211
|
+
const hasRealSymbolName = !!symbol && symbol.name !== ts.InternalSymbolName.Type;
|
|
212
|
+
return !hasRealSymbolName && !type.aliasSymbol;
|
|
213
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import type { UnionBranch } from '../types.js';
|
|
3
|
+
import { type ResolvedParseOptions } from '../options.js';
|
|
4
|
+
/**
|
|
5
|
+
* Detects a "union of objects" prop type (e.g.
|
|
6
|
+
* `{ type: 'a'; foo: string } | { type: 'b'; bar: number }`) and, if
|
|
7
|
+
* one is found, splits it into per-branch prop sets — this is the
|
|
8
|
+
* actual differentiator vs. react-docgen-typescript, which only
|
|
9
|
+
* stringifies the whole union and stops there.
|
|
10
|
+
*
|
|
11
|
+
* Returns undefined for anything that isn't a union of object types
|
|
12
|
+
* (primitive unions like `'a' | 'b'`, single object types, etc.) —
|
|
13
|
+
* in that case the caller just keeps the flattened `type.name` string.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveUnionBranches(type: ts.Type, contextNode: ts.Node, checker: ts.TypeChecker, options?: ResolvedParseOptions): UnionBranch[] | undefined;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
// Circular import: extractProperties.ts imports resolveUnionBranches
|
|
3
|
+
// from this file. Safe in ESM since neither side calls the other at
|
|
4
|
+
// module-evaluation time — only inside function bodies, by which
|
|
5
|
+
// point both modules have finished initializing.
|
|
6
|
+
import { extractPropertiesFromType } from '../extractProperties.js';
|
|
7
|
+
import { DEFAULT_PARSE_OPTIONS } from '../options.js';
|
|
8
|
+
import { truncateTypeName } from '../utils/truncateTypeName.js';
|
|
9
|
+
/**
|
|
10
|
+
* Detects a "union of objects" prop type (e.g.
|
|
11
|
+
* `{ type: 'a'; foo: string } | { type: 'b'; bar: number }`) and, if
|
|
12
|
+
* one is found, splits it into per-branch prop sets — this is the
|
|
13
|
+
* actual differentiator vs. react-docgen-typescript, which only
|
|
14
|
+
* stringifies the whole union and stops there.
|
|
15
|
+
*
|
|
16
|
+
* Returns undefined for anything that isn't a union of object types
|
|
17
|
+
* (primitive unions like `'a' | 'b'`, single object types, etc.) —
|
|
18
|
+
* in that case the caller just keeps the flattened `type.name` string.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveUnionBranches(type, contextNode, checker, options = DEFAULT_PARSE_OPTIONS) {
|
|
21
|
+
if (!type.isUnion())
|
|
22
|
+
return undefined;
|
|
23
|
+
const branches = type.types;
|
|
24
|
+
const isObjectUnion = branches.every((t) => !t.isLiteral() && !!(t.getFlags() & ts.TypeFlags.Object));
|
|
25
|
+
if (!isObjectUnion)
|
|
26
|
+
return undefined;
|
|
27
|
+
const discriminantName = findDiscriminant(branches, checker);
|
|
28
|
+
return branches.map((branchType) => {
|
|
29
|
+
const branchProps = extractPropertiesFromType(branchType, contextNode, checker, 0, options);
|
|
30
|
+
const discriminant = discriminantName
|
|
31
|
+
? {
|
|
32
|
+
name: discriminantName,
|
|
33
|
+
value: truncateTypeName(literalPropValue(branchType, discriminantName, checker), options.maxTypeNameLength),
|
|
34
|
+
}
|
|
35
|
+
: undefined;
|
|
36
|
+
return {
|
|
37
|
+
...(discriminant ? { discriminant } : {}),
|
|
38
|
+
props: branchProps,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A discriminant is a property present in every branch whose value is
|
|
44
|
+
* a distinct literal (string/number/boolean) in each one — the common
|
|
45
|
+
* `type: 'a'` / `type: 'b'` pattern. Picks the first property that
|
|
46
|
+
* qualifies across all branches; returns undefined if none does.
|
|
47
|
+
*/
|
|
48
|
+
function findDiscriminant(branches, checker) {
|
|
49
|
+
if (branches.length === 0)
|
|
50
|
+
return undefined;
|
|
51
|
+
const firstBranchProps = checker.getPropertiesOfType(branches[0]).map((s) => s.name);
|
|
52
|
+
for (const propName of firstBranchProps) {
|
|
53
|
+
const valuesAreAllLiteral = branches.every((branch) => {
|
|
54
|
+
const symbol = checker.getPropertyOfType(branch, propName);
|
|
55
|
+
if (!symbol)
|
|
56
|
+
return false;
|
|
57
|
+
const propType = checker.getTypeOfSymbolAtLocation(symbol, symbol.valueDeclaration ?? branch.symbol.valueDeclaration);
|
|
58
|
+
return propType.isLiteral();
|
|
59
|
+
});
|
|
60
|
+
if (valuesAreAllLiteral)
|
|
61
|
+
return propName;
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
function literalPropValue(branch, propName, checker) {
|
|
66
|
+
const symbol = checker.getPropertyOfType(branch, propName);
|
|
67
|
+
if (!symbol)
|
|
68
|
+
return '';
|
|
69
|
+
const propType = checker.getTypeOfSymbolAtLocation(symbol, symbol.valueDeclaration ?? branch.symbol.valueDeclaration);
|
|
70
|
+
return checker.typeToString(propType);
|
|
71
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ParseOptions } from './options.js';
|
|
2
|
+
import type { Documentation } from './types.js';
|
|
3
|
+
export type { Documentation, PropDescriptor, UnionBranch } from './types.js';
|
|
4
|
+
export type { ParseOptions } from './options.js';
|
|
5
|
+
/**
|
|
6
|
+
* Entry point: TS/TSX file path in, Documentation JSON out.
|
|
7
|
+
*
|
|
8
|
+
* `options` lets consumers tune output shape/size — e.g.
|
|
9
|
+
* `{ maxTypeNameLength: 80 }` to raise (or `Infinity` to disable) the
|
|
10
|
+
* default 50-character cap on rendered type-name strings, or
|
|
11
|
+
* `maxDepth` to change how many levels of nested object props get
|
|
12
|
+
* expanded. See {@link ParseOptions}.
|
|
13
|
+
*/
|
|
14
|
+
export declare function parse(filePath: string, options?: ParseOptions): Documentation;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { createProgramForFile } from './program.js';
|
|
3
|
+
import { resolvePropsType } from './resolvePropsType.js';
|
|
4
|
+
import { extractPropertiesFromType } from './extractProperties.js';
|
|
5
|
+
import { resolveUnionBranches } from './handlers/union.js';
|
|
6
|
+
import { truncateTypeName } from './utils/truncateTypeName.js';
|
|
7
|
+
import { resolveOptions } from './options.js';
|
|
8
|
+
/**
|
|
9
|
+
* Entry point: TS/TSX file path in, Documentation JSON out.
|
|
10
|
+
*
|
|
11
|
+
* `options` lets consumers tune output shape/size — e.g.
|
|
12
|
+
* `{ maxTypeNameLength: 80 }` to raise (or `Infinity` to disable) the
|
|
13
|
+
* default 50-character cap on rendered type-name strings, or
|
|
14
|
+
* `maxDepth` to change how many levels of nested object props get
|
|
15
|
+
* expanded. See {@link ParseOptions}.
|
|
16
|
+
*/
|
|
17
|
+
export function parse(filePath, options) {
|
|
18
|
+
const resolvedOptions = resolveOptions(options);
|
|
19
|
+
const program = createProgramForFile(filePath);
|
|
20
|
+
const sourceFile = program.getSourceFile(filePath);
|
|
21
|
+
if (!sourceFile) {
|
|
22
|
+
throw new Error(`Could not load source file: ${filePath}`);
|
|
23
|
+
}
|
|
24
|
+
const checker = program.getTypeChecker();
|
|
25
|
+
const resolved = resolvePropsType(sourceFile, checker, resolvedOptions);
|
|
26
|
+
if (!resolved) {
|
|
27
|
+
throw new Error(`No "Props" interface or type alias found in: ${filePath}`);
|
|
28
|
+
}
|
|
29
|
+
const { type: topLevelType, contextNode, displayName, docSymbol } = resolved;
|
|
30
|
+
// When the top-level Props type is itself a union of object shapes
|
|
31
|
+
// (`export type InputProps = AProps | BProps | ...`), the flat
|
|
32
|
+
// `props` above is only the *intersection* of all branches — usually
|
|
33
|
+
// just the discriminant, with every variant-specific field (and its
|
|
34
|
+
// jsdoc) missing entirely. That's the exact gap plain react-docgen
|
|
35
|
+
// has, and it's what leaves Storybook's Controls panel showing only
|
|
36
|
+
// one row. `elements` carries the full per-branch breakdown; when
|
|
37
|
+
// present, it's also used to build a *union* (not intersection) flat
|
|
38
|
+
// `props` map below, so every field from every branch shows up in
|
|
39
|
+
// Controls, each keeping its own description where unambiguous.
|
|
40
|
+
const elements = resolveUnionBranches(topLevelType, contextNode, checker, resolvedOptions);
|
|
41
|
+
const props = elements
|
|
42
|
+
? mergePropsAcrossBranches(elements, resolvedOptions)
|
|
43
|
+
: extractPropertiesFromType(topLevelType, contextNode, checker, 0, resolvedOptions);
|
|
44
|
+
const description = docSymbol
|
|
45
|
+
? ts.displayPartsToString(docSymbol.getDocumentationComment(checker)).trim() || undefined
|
|
46
|
+
: undefined;
|
|
47
|
+
return {
|
|
48
|
+
displayName,
|
|
49
|
+
description,
|
|
50
|
+
props,
|
|
51
|
+
...(elements ? { elements } : {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Builds the flat `props` map for a top-level union Props type as the
|
|
56
|
+
* *union* of every branch's own props (not the intersection TypeScript
|
|
57
|
+
* gives you natively) — this is what Storybook's Controls panel and
|
|
58
|
+
* other react-docgen-typescript-shaped consumers read, so it needs to
|
|
59
|
+
* carry every field, not just the shared discriminant.
|
|
60
|
+
*
|
|
61
|
+
* A field present in only one branch keeps that branch's description
|
|
62
|
+
* as-is (no ambiguity). A field present in multiple branches keeps its
|
|
63
|
+
* description only if every branch that has it agrees — otherwise it's
|
|
64
|
+
* dropped rather than concatenated into nonsense, same rule as before.
|
|
65
|
+
* `required` is true only if the field is present and required in
|
|
66
|
+
* every branch; a field that's optional or absent in even one branch
|
|
67
|
+
* isn't safe to treat as always-required.
|
|
68
|
+
*/
|
|
69
|
+
function mergePropsAcrossBranches(elements, options) {
|
|
70
|
+
const allNames = new Set();
|
|
71
|
+
for (const branch of elements) {
|
|
72
|
+
for (const name of Object.keys(branch.props))
|
|
73
|
+
allNames.add(name);
|
|
74
|
+
}
|
|
75
|
+
const result = {};
|
|
76
|
+
for (const name of allNames) {
|
|
77
|
+
const inBranches = elements
|
|
78
|
+
.map((branch) => branch.props[name])
|
|
79
|
+
.filter((p) => p !== undefined);
|
|
80
|
+
const descriptions = new Set(inBranches.map((p) => p.description));
|
|
81
|
+
const defaults = new Set(inBranches.map((p) => JSON.stringify(p.defaultValue)));
|
|
82
|
+
const typeNames = [...new Set(inBranches.map((p) => p.type.name))];
|
|
83
|
+
const required = inBranches.length === elements.length && inBranches.every((p) => p.required);
|
|
84
|
+
result[name] = {
|
|
85
|
+
name,
|
|
86
|
+
required,
|
|
87
|
+
type: { name: truncateTypeName(typeNames.join(' | '), options.maxTypeNameLength) },
|
|
88
|
+
description: descriptions.size === 1 ? [...descriptions][0] : undefined,
|
|
89
|
+
defaultValue: defaults.size === 1 ? inBranches[0]?.defaultValue : undefined,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Consumer-facing configuration for {@link parse}. */
|
|
2
|
+
export interface ParseOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Maximum characters allowed in a rendered type-name string (a prop's
|
|
5
|
+
* `type.name`, a union branch's merged type text, or the component
|
|
6
|
+
* displayName's stringified-type fallback) before it's truncated with
|
|
7
|
+
* a trailing `…`. Long utility-type chains like
|
|
8
|
+
* `JssSupportedProperty<Pick<RheaTextProps, "content" | "ariaLabel" |
|
|
9
|
+
* ...>>` otherwise render at full length and overflow fixed-width UI.
|
|
10
|
+
*
|
|
11
|
+
* Pass `Infinity` to disable truncation entirely.
|
|
12
|
+
*
|
|
13
|
+
* @default 50
|
|
14
|
+
*/
|
|
15
|
+
maxTypeNameLength?: number;
|
|
16
|
+
/** How many levels of nested object props get expanded into `type.properties`. @default 2 */
|
|
17
|
+
maxDepth?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface ResolvedParseOptions {
|
|
20
|
+
maxTypeNameLength: number;
|
|
21
|
+
maxDepth: number;
|
|
22
|
+
}
|
|
23
|
+
export declare const DEFAULT_PARSE_OPTIONS: ResolvedParseOptions;
|
|
24
|
+
export declare function resolveOptions(options?: ParseOptions): ResolvedParseOptions;
|
package/dist/options.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const DEFAULT_PARSE_OPTIONS = {
|
|
2
|
+
maxTypeNameLength: 50,
|
|
3
|
+
maxDepth: 2,
|
|
4
|
+
};
|
|
5
|
+
export function resolveOptions(options) {
|
|
6
|
+
return {
|
|
7
|
+
maxTypeNameLength: options?.maxTypeNameLength ?? DEFAULT_PARSE_OPTIONS.maxTypeNameLength,
|
|
8
|
+
maxDepth: options?.maxDepth ?? DEFAULT_PARSE_OPTIONS.maxDepth,
|
|
9
|
+
};
|
|
10
|
+
}
|