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 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
+ ![Comparison-1](./comparison-1.png)
50
+
51
+ With HOC:
52
+
53
+ ![Comparison-2](./comparison-2.png)
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
+ }
@@ -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;
@@ -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
+ }