@suss/contract-storybook 0.34.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/dist/index.cjs +183 -30
- package/dist/index.d.cts +8 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.js +192 -32
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -10,7 +10,9 @@ This package builds suss `BehavioralSummary[]` from [Storybook](https://storyboo
|
|
|
10
10
|
|
|
11
11
|
The default export gives the component under test. The reader walks the declarations of the default export's symbol, looking for an object literal with a `component` property. That covers `export default { component: Button }`, `const meta = { component: Button }; export default meta;` and `export default { ... } satisfies Meta<typeof Button>`. Parentheses, `as` expressions, and an identifier that points at a local variable are followed through to the literal. A file whose default export never resolves to such an object is skipped, and the rest of the run continues.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
The `component` identifier is followed through its import, any renames and any barrel re-exports, to the file that declares it. Each story file is read under the tsconfig nearest to it, so an import through one of its path aliases (`@ui/Chip/Chip`) resolves. The declaring file is written relative to the root an extract of that file's project measures from, with that project's workspace name, so the checker can find the extracted component by its own location. When the import leads to no file, for example because the module is generated or comes from a package that is not installed, no module is recorded, and the checker leaves the story unpaired.
|
|
14
|
+
|
|
15
|
+
Every other named export whose initializer resolves to an object literal is a story. Its `args` object literal becomes the story's arguments, and each property's value is recorded as the source text you wrote. A shorthand property (`{ disabled }`) records its own name. Storybook gives every story the `args` set on the default export, so those are recorded on each story too, and a story's own arg replaces the meta's arg of the same name.
|
|
14
16
|
|
|
15
17
|
## What it produces
|
|
16
18
|
|
|
@@ -19,16 +21,16 @@ One `component`-kind summary per named story export:
|
|
|
19
21
|
- `identity.name` is `Component.Story`, with `exportPath` set to the story's export name, and a function-call boundary binding with `transport: "in-process"`, `recognition: "react"`, and the component identifier as the export name.
|
|
20
22
|
- One input per arg. The input's `role` is the arg name, and its shape is a `ref` whose name is the arg's source text, so a reader can see the value that was written.
|
|
21
23
|
- One default transition whose output is a render of the component. The reader does not evaluate the render, so the rendered tree is left unset.
|
|
22
|
-
- `metadata.component.storybook` with the story name, the component name, the args map, and `provenance: "independent"`. The provenance is what lets a story act as a check on an inferred component summary, since it was written separately from the component.
|
|
24
|
+
- `metadata.component.storybook` with the story name, the component name, the component's module (`componentModule`, with `workspace`, `file` and `name`) and the specifier it is imported from (`componentImport`), the args map, `argReaders` when the story or its meta has `decorators` or a `render` function, `renderProps` with the props a render function passes the component as its own attributes or children, and `provenance: "independent"`. `renderProps` is left out when the render function renders the component through another element or spreads anything besides its args into it. A decorator or a render function receives the args before the component does, and the reader does not follow either, so the checker does not judge those args against the component. The provenance is what lets a story act as a check on an inferred component summary, since it was written separately from the component.
|
|
23
25
|
- Confidence is `derived` at `medium`. People write stories, and a story is authoritative about what it covers, but stories do not list everything a component does.
|
|
24
26
|
|
|
25
27
|
## What it does not read
|
|
26
28
|
|
|
27
29
|
- **`play` functions.** The sequence of events that drives an interactive story is not recorded.
|
|
28
30
|
- **`argTypes`.** Control types and option lists per arg could support stricter type checking, but they are not read today.
|
|
29
|
-
-
|
|
31
|
+
- **What `decorators`, `render` and `parameters` do.** Their presence is recorded, as above. A render function is read only for the attributes and children it gives the component's element, and the other bodies are not read. Decorators set in `.storybook/preview` apply to every story and are not seen at all.
|
|
30
32
|
- **CSF1 and MDX stories.** CSF3 is the supported format.
|
|
31
|
-
- **
|
|
33
|
+
- **A `component` that is not a plain identifier**, such as `Icons.Chip`. No module is recorded for it.
|
|
32
34
|
- **Arg values as structured shapes.** An arg's value stays as source text inside a `ref` shape, and is not parsed into a `TypeShape`.
|
|
33
35
|
|
|
34
36
|
## Worked example
|
package/dist/index.cjs
CHANGED
|
@@ -37,39 +37,141 @@ var import_node_path = __toESM(require("path"), 1);
|
|
|
37
37
|
var import_ts_morph = require("ts-morph");
|
|
38
38
|
var import_adapter_typescript = require("@suss/adapter-typescript");
|
|
39
39
|
var import_behavioral_ir = require("@suss/behavioral-ir");
|
|
40
|
+
var import_files = require("@suss/behavioral-ir/files");
|
|
40
41
|
function generateSummariesFromStories(filePaths, options = {}) {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
42
|
+
const componentModules = options.componentModules ?? true;
|
|
43
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
44
|
+
const groups = componentModules ? filesByTsconfig(filePaths) : /* @__PURE__ */ new Map([[null, filePaths]]);
|
|
45
|
+
for (const [tsconfig, files] of groups) {
|
|
46
|
+
const project = projectFor(tsconfig);
|
|
47
|
+
const resolution = new import_adapter_typescript.ResolutionStore();
|
|
48
|
+
for (const sf of files.map((fp) => project.addSourceFileAtPath(fp))) {
|
|
49
|
+
const storyFile = sf.getFilePath();
|
|
50
|
+
const relPath = (0, import_files.pathInProject)(
|
|
51
|
+
storyFile,
|
|
52
|
+
options.projectRoot ?? (0, import_files.defaultProjectRoot)(storyFile)
|
|
53
|
+
);
|
|
54
|
+
const meta = extractMeta(sf, resolution, componentModules);
|
|
55
|
+
if (meta === null) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
byFile.set(
|
|
59
|
+
sf.getFilePath(),
|
|
60
|
+
extractStories(sf, resolution, meta).map(
|
|
61
|
+
(story) => buildSummary(story, meta, relPath)
|
|
62
|
+
)
|
|
63
|
+
);
|
|
50
64
|
}
|
|
51
|
-
}
|
|
65
|
+
}
|
|
66
|
+
return filePaths.flatMap(
|
|
67
|
+
(fp) => byFile.get(import_node_path.default.resolve(fp).replace(/\\/g, "/")) ?? []
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
function filesByTsconfig(filePaths) {
|
|
71
|
+
const groups = /* @__PURE__ */ new Map();
|
|
52
72
|
for (const fp of filePaths) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
73
|
+
const tsconfig = (0, import_adapter_typescript.findNearestTsconfig)(import_node_path.default.dirname(fp));
|
|
74
|
+
const group = groups.get(tsconfig) ?? [];
|
|
75
|
+
group.push(fp);
|
|
76
|
+
groups.set(tsconfig, group);
|
|
77
|
+
}
|
|
78
|
+
return groups;
|
|
79
|
+
}
|
|
80
|
+
var COMPILER_OPTIONS = {
|
|
81
|
+
target: 99,
|
|
82
|
+
module: 99,
|
|
83
|
+
moduleResolution: 100,
|
|
84
|
+
skipLibCheck: true,
|
|
85
|
+
allowJs: true,
|
|
86
|
+
jsx: 4,
|
|
87
|
+
// Ambient types say nothing about a story, and a missing types folder
|
|
88
|
+
// makes every program build fail.
|
|
89
|
+
types: []
|
|
90
|
+
};
|
|
91
|
+
function projectFor(tsconfig) {
|
|
92
|
+
if (tsconfig !== null) {
|
|
93
|
+
try {
|
|
94
|
+
return new import_ts_morph.Project({
|
|
95
|
+
tsConfigFilePath: tsconfig,
|
|
96
|
+
skipAddingFilesFromTsConfig: true,
|
|
97
|
+
compilerOptions: { allowJs: true, jsx: 4, types: [] }
|
|
98
|
+
});
|
|
99
|
+
} catch {
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return new import_ts_morph.Project({
|
|
103
|
+
skipAddingFilesFromTsConfig: true,
|
|
104
|
+
compilerOptions: COMPILER_OPTIONS
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
var ARG_READERS = ["decorators", "render"];
|
|
108
|
+
function argReadersOf(storyOrMeta, resolution) {
|
|
109
|
+
const names = new Set(
|
|
110
|
+
(0, import_adapter_typescript.propertiesOf)(storyOrMeta, resolution).map(
|
|
111
|
+
(property) => (0, import_adapter_typescript.propertyNameOf)(property)
|
|
112
|
+
)
|
|
113
|
+
);
|
|
114
|
+
return ARG_READERS.filter((name) => names.has(name));
|
|
115
|
+
}
|
|
116
|
+
function renderPropsOf(storyOrMeta, componentName, resolution) {
|
|
117
|
+
const property = (0, import_adapter_typescript.propertiesOf)(storyOrMeta, resolution).find(
|
|
118
|
+
(candidate) => (0, import_adapter_typescript.propertyNameOf)(candidate) === "render"
|
|
119
|
+
);
|
|
120
|
+
const render = property === void 0 ? null : (0, import_adapter_typescript.propertyFunctionOf)(property, resolution);
|
|
121
|
+
if (render === null) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const argNames = argNamesOf(render.getParameters()[0]);
|
|
125
|
+
const elements = [
|
|
126
|
+
...render.getDescendantsOfKind(import_ts_morph.SyntaxKind.JsxOpeningElement),
|
|
127
|
+
...render.getDescendantsOfKind(import_ts_morph.SyntaxKind.JsxSelfClosingElement)
|
|
128
|
+
].filter((element) => element.getTagNameNode().getText() === componentName);
|
|
129
|
+
if (elements.length === 0) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const passed = /* @__PURE__ */ new Set();
|
|
133
|
+
if (elements.some(hasJsxChildren)) {
|
|
134
|
+
passed.add("children");
|
|
135
|
+
}
|
|
136
|
+
for (const attribute of elements.flatMap((e) => e.getAttributes())) {
|
|
137
|
+
if (import_ts_morph.Node.isJsxAttribute(attribute)) {
|
|
138
|
+
passed.add(attribute.getNameNode().getText());
|
|
63
139
|
continue;
|
|
64
140
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
141
|
+
if (!argNames.has(attribute.getExpression().getText())) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return [...passed].sort();
|
|
146
|
+
}
|
|
147
|
+
function hasJsxChildren(element) {
|
|
148
|
+
const parent = element.getParent();
|
|
149
|
+
if (!import_ts_morph.Node.isJsxOpeningElement(element) || !import_ts_morph.Node.isJsxElement(parent)) {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
return parent.getJsxChildren().some(
|
|
153
|
+
(child) => !(import_ts_morph.Node.isJsxText(child) && child.containsOnlyTriviaWhiteSpaces())
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
function argNamesOf(parameter) {
|
|
157
|
+
const names = /* @__PURE__ */ new Set();
|
|
158
|
+
const nameNode = parameter?.getNameNode();
|
|
159
|
+
if (nameNode === void 0) {
|
|
160
|
+
return names;
|
|
161
|
+
}
|
|
162
|
+
if (import_ts_morph.Node.isIdentifier(nameNode)) {
|
|
163
|
+
names.add(nameNode.getText());
|
|
164
|
+
return names;
|
|
165
|
+
}
|
|
166
|
+
const elements = import_ts_morph.Node.isObjectBindingPattern(nameNode) ? nameNode.getDescendantsOfKind(import_ts_morph.SyntaxKind.BindingElement).filter((element) => element.getParent() === nameNode) : [];
|
|
167
|
+
for (const element of elements) {
|
|
168
|
+
if (element.getDotDotDotToken() !== void 0) {
|
|
169
|
+
names.add(element.getName());
|
|
68
170
|
}
|
|
69
171
|
}
|
|
70
|
-
return
|
|
172
|
+
return names;
|
|
71
173
|
}
|
|
72
|
-
function extractMeta(sf, resolution) {
|
|
174
|
+
function extractMeta(sf, resolution, componentModules) {
|
|
73
175
|
for (const exported of resolution.exportsOf(sf).get("default") ?? []) {
|
|
74
176
|
const meta = objectBehind(exported, resolution);
|
|
75
177
|
if (meta === null) {
|
|
@@ -77,16 +179,58 @@ function extractMeta(sf, resolution) {
|
|
|
77
179
|
}
|
|
78
180
|
const component = (0, import_adapter_typescript.propertyOf)(meta, "component", resolution);
|
|
79
181
|
if (component !== null) {
|
|
80
|
-
return {
|
|
182
|
+
return {
|
|
183
|
+
componentName: component.getText(),
|
|
184
|
+
componentModule: componentModules ? declaredModuleOf(component) : void 0,
|
|
185
|
+
componentImport: importSpecifierOf(component),
|
|
186
|
+
args: argsOf(meta, resolution),
|
|
187
|
+
argReaders: argReadersOf(meta, resolution),
|
|
188
|
+
renderProps: renderPropsOf(meta, component.getText(), resolution)
|
|
189
|
+
};
|
|
81
190
|
}
|
|
82
191
|
}
|
|
83
192
|
return null;
|
|
84
193
|
}
|
|
194
|
+
function declaredModuleOf(component) {
|
|
195
|
+
if (!import_ts_morph.Node.isIdentifier(component)) {
|
|
196
|
+
return void 0;
|
|
197
|
+
}
|
|
198
|
+
const declaration = componentDeclarationOf(component);
|
|
199
|
+
if (declaration === void 0) {
|
|
200
|
+
return void 0;
|
|
201
|
+
}
|
|
202
|
+
const place = (0, import_adapter_typescript.projectPlaceOf)(declaration.getSourceFile().getFilePath());
|
|
203
|
+
return {
|
|
204
|
+
...place.workspace === null ? {} : { workspace: place.workspace },
|
|
205
|
+
file: place.file,
|
|
206
|
+
name: declaredNameOf(declaration) ?? component.getText()
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function componentDeclarationOf(component) {
|
|
210
|
+
return (0, import_adapter_typescript.declarationsBehind)((0, import_adapter_typescript.symbolBehind)(component)).find(
|
|
211
|
+
(declaration) => !isImportBinding(declaration)
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
function isImportBinding(node) {
|
|
215
|
+
return import_ts_morph.Node.isImportSpecifier(node) || import_ts_morph.Node.isImportClause(node) || import_ts_morph.Node.isNamespaceImport(node) || import_ts_morph.Node.isImportEqualsDeclaration(node);
|
|
216
|
+
}
|
|
217
|
+
function declaredNameOf(declaration) {
|
|
218
|
+
if (import_ts_morph.Node.isVariableDeclaration(declaration) || import_ts_morph.Node.isFunctionDeclaration(declaration) || import_ts_morph.Node.isClassDeclaration(declaration)) {
|
|
219
|
+
return declaration.getName();
|
|
220
|
+
}
|
|
221
|
+
return void 0;
|
|
222
|
+
}
|
|
223
|
+
function importSpecifierOf(component) {
|
|
224
|
+
if (!import_ts_morph.Node.isIdentifier(component)) {
|
|
225
|
+
return void 0;
|
|
226
|
+
}
|
|
227
|
+
return (0, import_adapter_typescript.specifierImportedAs)(component.getSourceFile(), component.getText()) ?? void 0;
|
|
228
|
+
}
|
|
85
229
|
function objectBehind(value, resolution) {
|
|
86
230
|
const resolved = resolution.resolveObject(value);
|
|
87
231
|
return resolved !== null && import_ts_morph.Node.isObjectLiteralExpression(resolved) ? resolved : null;
|
|
88
232
|
}
|
|
89
|
-
function extractStories(sf, resolution) {
|
|
233
|
+
function extractStories(sf, resolution, meta) {
|
|
90
234
|
const results = [];
|
|
91
235
|
for (const [name, decls] of (0, import_adapter_typescript.exportedDeclarationsOf)(sf, resolution)) {
|
|
92
236
|
if (name === "default") {
|
|
@@ -97,18 +241,23 @@ function extractStories(sf, resolution) {
|
|
|
97
241
|
if (story === null) {
|
|
98
242
|
continue;
|
|
99
243
|
}
|
|
244
|
+
const ownReaders = argReadersOf(story, resolution);
|
|
100
245
|
results.push({
|
|
101
246
|
name,
|
|
102
|
-
args:
|
|
247
|
+
args: { ...meta.args, ...argsOf(story, resolution) },
|
|
248
|
+
argReaders: ARG_READERS.filter(
|
|
249
|
+
(reader) => meta.argReaders.includes(reader) || ownReaders.includes(reader)
|
|
250
|
+
),
|
|
251
|
+
renderProps: ownReaders.includes("render") ? renderPropsOf(story, meta.componentName, resolution) : meta.renderProps,
|
|
103
252
|
line: decl.getStartLineNumber()
|
|
104
253
|
});
|
|
105
254
|
}
|
|
106
255
|
}
|
|
107
256
|
return results;
|
|
108
257
|
}
|
|
109
|
-
function
|
|
258
|
+
function argsOf(storyOrMeta, resolution) {
|
|
110
259
|
const args = {};
|
|
111
|
-
const written = (0, import_adapter_typescript.propertyOf)(
|
|
260
|
+
const written = (0, import_adapter_typescript.propertyOf)(storyOrMeta, "args", resolution);
|
|
112
261
|
const object = written === null ? null : (0, import_adapter_typescript.objectLiteralOf)(written, resolution);
|
|
113
262
|
if (object === null) {
|
|
114
263
|
return args;
|
|
@@ -167,7 +316,11 @@ function buildSummary(story, meta, filePath) {
|
|
|
167
316
|
storybook: {
|
|
168
317
|
story: story.name,
|
|
169
318
|
component: meta.componentName,
|
|
319
|
+
...meta.componentModule === void 0 ? {} : { componentModule: meta.componentModule },
|
|
320
|
+
...meta.componentImport === void 0 ? {} : { componentImport: meta.componentImport },
|
|
170
321
|
args: story.args,
|
|
322
|
+
...story.argReaders.length === 0 ? {} : { argReaders: story.argReaders },
|
|
323
|
+
...story.renderProps === null ? {} : { renderProps: story.renderProps },
|
|
171
324
|
provenance: "independent"
|
|
172
325
|
}
|
|
173
326
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -15,9 +15,16 @@ import { BehavioralSummary } from '@suss/behavioral-ir';
|
|
|
15
15
|
interface StorybookStubOptions {
|
|
16
16
|
/**
|
|
17
17
|
* Each summary's `location.file` is relative to this, so it is the same
|
|
18
|
-
* on every machine. Defaults to the
|
|
18
|
+
* on every machine. Defaults to the repository each story is in.
|
|
19
19
|
*/
|
|
20
20
|
projectRoot?: string;
|
|
21
|
+
/**
|
|
22
|
+
* False leaves out `componentModule`, the file each component is
|
|
23
|
+
* declared in. Finding it means reading the story under its tsconfig,
|
|
24
|
+
* whose path aliases make the type checker load most of the app, so a
|
|
25
|
+
* caller that only asks whether a file has stories passes false.
|
|
26
|
+
*/
|
|
27
|
+
componentModules?: boolean;
|
|
21
28
|
}
|
|
22
29
|
/**
|
|
23
30
|
* Reads `.stories.ts` and `.stories.tsx` files and returns one summary per
|
package/dist/index.d.ts
CHANGED
|
@@ -15,9 +15,16 @@ import { BehavioralSummary } from '@suss/behavioral-ir';
|
|
|
15
15
|
interface StorybookStubOptions {
|
|
16
16
|
/**
|
|
17
17
|
* Each summary's `location.file` is relative to this, so it is the same
|
|
18
|
-
* on every machine. Defaults to the
|
|
18
|
+
* on every machine. Defaults to the repository each story is in.
|
|
19
19
|
*/
|
|
20
20
|
projectRoot?: string;
|
|
21
|
+
/**
|
|
22
|
+
* False leaves out `componentModule`, the file each component is
|
|
23
|
+
* declared in. Finding it means reading the story under its tsconfig,
|
|
24
|
+
* whose path aliases make the type checker load most of the app, so a
|
|
25
|
+
* caller that only asks whether a file has stories passes false.
|
|
26
|
+
*/
|
|
27
|
+
componentModules?: boolean;
|
|
21
28
|
}
|
|
22
29
|
/**
|
|
23
30
|
* Reads `.stories.ts` and `.stories.tsx` files and returns one summary per
|
package/dist/index.js
CHANGED
|
@@ -2,52 +2,161 @@
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import {
|
|
4
4
|
Node as N,
|
|
5
|
-
Project
|
|
5
|
+
Project,
|
|
6
|
+
SyntaxKind
|
|
6
7
|
} from "ts-morph";
|
|
7
8
|
import {
|
|
9
|
+
declarationsBehind,
|
|
8
10
|
exportedDeclarationsOf,
|
|
11
|
+
findNearestTsconfig,
|
|
9
12
|
objectLiteralOf,
|
|
13
|
+
projectPlaceOf,
|
|
10
14
|
propertiesOf,
|
|
15
|
+
propertyFunctionOf,
|
|
11
16
|
propertyNameOf,
|
|
12
17
|
propertyOf,
|
|
13
18
|
propertyValueOf,
|
|
14
19
|
ResolutionStore,
|
|
15
|
-
|
|
20
|
+
specifierImportedAs,
|
|
21
|
+
stringValueOf,
|
|
22
|
+
symbolBehind
|
|
16
23
|
} from "@suss/adapter-typescript";
|
|
17
24
|
import { functionCallBinding } from "@suss/behavioral-ir";
|
|
25
|
+
import { defaultProjectRoot, pathInProject } from "@suss/behavioral-ir/files";
|
|
18
26
|
function generateSummariesFromStories(filePaths, options = {}) {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
const componentModules = options.componentModules ?? true;
|
|
28
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
29
|
+
const groups = componentModules ? filesByTsconfig(filePaths) : /* @__PURE__ */ new Map([[null, filePaths]]);
|
|
30
|
+
for (const [tsconfig, files] of groups) {
|
|
31
|
+
const project = projectFor(tsconfig);
|
|
32
|
+
const resolution = new ResolutionStore();
|
|
33
|
+
for (const sf of files.map((fp) => project.addSourceFileAtPath(fp))) {
|
|
34
|
+
const storyFile = sf.getFilePath();
|
|
35
|
+
const relPath = pathInProject(
|
|
36
|
+
storyFile,
|
|
37
|
+
options.projectRoot ?? defaultProjectRoot(storyFile)
|
|
38
|
+
);
|
|
39
|
+
const meta = extractMeta(sf, resolution, componentModules);
|
|
40
|
+
if (meta === null) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
byFile.set(
|
|
44
|
+
sf.getFilePath(),
|
|
45
|
+
extractStories(sf, resolution, meta).map(
|
|
46
|
+
(story) => buildSummary(story, meta, relPath)
|
|
47
|
+
)
|
|
48
|
+
);
|
|
28
49
|
}
|
|
29
|
-
}
|
|
50
|
+
}
|
|
51
|
+
return filePaths.flatMap(
|
|
52
|
+
(fp) => byFile.get(path.resolve(fp).replace(/\\/g, "/")) ?? []
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
function filesByTsconfig(filePaths) {
|
|
56
|
+
const groups = /* @__PURE__ */ new Map();
|
|
30
57
|
for (const fp of filePaths) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
58
|
+
const tsconfig = findNearestTsconfig(path.dirname(fp));
|
|
59
|
+
const group = groups.get(tsconfig) ?? [];
|
|
60
|
+
group.push(fp);
|
|
61
|
+
groups.set(tsconfig, group);
|
|
62
|
+
}
|
|
63
|
+
return groups;
|
|
64
|
+
}
|
|
65
|
+
var COMPILER_OPTIONS = {
|
|
66
|
+
target: 99,
|
|
67
|
+
module: 99,
|
|
68
|
+
moduleResolution: 100,
|
|
69
|
+
skipLibCheck: true,
|
|
70
|
+
allowJs: true,
|
|
71
|
+
jsx: 4,
|
|
72
|
+
// Ambient types say nothing about a story, and a missing types folder
|
|
73
|
+
// makes every program build fail.
|
|
74
|
+
types: []
|
|
75
|
+
};
|
|
76
|
+
function projectFor(tsconfig) {
|
|
77
|
+
if (tsconfig !== null) {
|
|
78
|
+
try {
|
|
79
|
+
return new Project({
|
|
80
|
+
tsConfigFilePath: tsconfig,
|
|
81
|
+
skipAddingFilesFromTsConfig: true,
|
|
82
|
+
compilerOptions: { allowJs: true, jsx: 4, types: [] }
|
|
83
|
+
});
|
|
84
|
+
} catch {
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return new Project({
|
|
88
|
+
skipAddingFilesFromTsConfig: true,
|
|
89
|
+
compilerOptions: COMPILER_OPTIONS
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
var ARG_READERS = ["decorators", "render"];
|
|
93
|
+
function argReadersOf(storyOrMeta, resolution) {
|
|
94
|
+
const names = new Set(
|
|
95
|
+
propertiesOf(storyOrMeta, resolution).map(
|
|
96
|
+
(property) => propertyNameOf(property)
|
|
97
|
+
)
|
|
98
|
+
);
|
|
99
|
+
return ARG_READERS.filter((name) => names.has(name));
|
|
100
|
+
}
|
|
101
|
+
function renderPropsOf(storyOrMeta, componentName, resolution) {
|
|
102
|
+
const property = propertiesOf(storyOrMeta, resolution).find(
|
|
103
|
+
(candidate) => propertyNameOf(candidate) === "render"
|
|
104
|
+
);
|
|
105
|
+
const render = property === void 0 ? null : propertyFunctionOf(property, resolution);
|
|
106
|
+
if (render === null) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const argNames = argNamesOf(render.getParameters()[0]);
|
|
110
|
+
const elements = [
|
|
111
|
+
...render.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
|
|
112
|
+
...render.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement)
|
|
113
|
+
].filter((element) => element.getTagNameNode().getText() === componentName);
|
|
114
|
+
if (elements.length === 0) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const passed = /* @__PURE__ */ new Set();
|
|
118
|
+
if (elements.some(hasJsxChildren)) {
|
|
119
|
+
passed.add("children");
|
|
120
|
+
}
|
|
121
|
+
for (const attribute of elements.flatMap((e) => e.getAttributes())) {
|
|
122
|
+
if (N.isJsxAttribute(attribute)) {
|
|
123
|
+
passed.add(attribute.getNameNode().getText());
|
|
41
124
|
continue;
|
|
42
125
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
126
|
+
if (!argNames.has(attribute.getExpression().getText())) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return [...passed].sort();
|
|
131
|
+
}
|
|
132
|
+
function hasJsxChildren(element) {
|
|
133
|
+
const parent = element.getParent();
|
|
134
|
+
if (!N.isJsxOpeningElement(element) || !N.isJsxElement(parent)) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
return parent.getJsxChildren().some(
|
|
138
|
+
(child) => !(N.isJsxText(child) && child.containsOnlyTriviaWhiteSpaces())
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
function argNamesOf(parameter) {
|
|
142
|
+
const names = /* @__PURE__ */ new Set();
|
|
143
|
+
const nameNode = parameter?.getNameNode();
|
|
144
|
+
if (nameNode === void 0) {
|
|
145
|
+
return names;
|
|
146
|
+
}
|
|
147
|
+
if (N.isIdentifier(nameNode)) {
|
|
148
|
+
names.add(nameNode.getText());
|
|
149
|
+
return names;
|
|
150
|
+
}
|
|
151
|
+
const elements = N.isObjectBindingPattern(nameNode) ? nameNode.getDescendantsOfKind(SyntaxKind.BindingElement).filter((element) => element.getParent() === nameNode) : [];
|
|
152
|
+
for (const element of elements) {
|
|
153
|
+
if (element.getDotDotDotToken() !== void 0) {
|
|
154
|
+
names.add(element.getName());
|
|
46
155
|
}
|
|
47
156
|
}
|
|
48
|
-
return
|
|
157
|
+
return names;
|
|
49
158
|
}
|
|
50
|
-
function extractMeta(sf, resolution) {
|
|
159
|
+
function extractMeta(sf, resolution, componentModules) {
|
|
51
160
|
for (const exported of resolution.exportsOf(sf).get("default") ?? []) {
|
|
52
161
|
const meta = objectBehind(exported, resolution);
|
|
53
162
|
if (meta === null) {
|
|
@@ -55,16 +164,58 @@ function extractMeta(sf, resolution) {
|
|
|
55
164
|
}
|
|
56
165
|
const component = propertyOf(meta, "component", resolution);
|
|
57
166
|
if (component !== null) {
|
|
58
|
-
return {
|
|
167
|
+
return {
|
|
168
|
+
componentName: component.getText(),
|
|
169
|
+
componentModule: componentModules ? declaredModuleOf(component) : void 0,
|
|
170
|
+
componentImport: importSpecifierOf(component),
|
|
171
|
+
args: argsOf(meta, resolution),
|
|
172
|
+
argReaders: argReadersOf(meta, resolution),
|
|
173
|
+
renderProps: renderPropsOf(meta, component.getText(), resolution)
|
|
174
|
+
};
|
|
59
175
|
}
|
|
60
176
|
}
|
|
61
177
|
return null;
|
|
62
178
|
}
|
|
179
|
+
function declaredModuleOf(component) {
|
|
180
|
+
if (!N.isIdentifier(component)) {
|
|
181
|
+
return void 0;
|
|
182
|
+
}
|
|
183
|
+
const declaration = componentDeclarationOf(component);
|
|
184
|
+
if (declaration === void 0) {
|
|
185
|
+
return void 0;
|
|
186
|
+
}
|
|
187
|
+
const place = projectPlaceOf(declaration.getSourceFile().getFilePath());
|
|
188
|
+
return {
|
|
189
|
+
...place.workspace === null ? {} : { workspace: place.workspace },
|
|
190
|
+
file: place.file,
|
|
191
|
+
name: declaredNameOf(declaration) ?? component.getText()
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function componentDeclarationOf(component) {
|
|
195
|
+
return declarationsBehind(symbolBehind(component)).find(
|
|
196
|
+
(declaration) => !isImportBinding(declaration)
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
function isImportBinding(node) {
|
|
200
|
+
return N.isImportSpecifier(node) || N.isImportClause(node) || N.isNamespaceImport(node) || N.isImportEqualsDeclaration(node);
|
|
201
|
+
}
|
|
202
|
+
function declaredNameOf(declaration) {
|
|
203
|
+
if (N.isVariableDeclaration(declaration) || N.isFunctionDeclaration(declaration) || N.isClassDeclaration(declaration)) {
|
|
204
|
+
return declaration.getName();
|
|
205
|
+
}
|
|
206
|
+
return void 0;
|
|
207
|
+
}
|
|
208
|
+
function importSpecifierOf(component) {
|
|
209
|
+
if (!N.isIdentifier(component)) {
|
|
210
|
+
return void 0;
|
|
211
|
+
}
|
|
212
|
+
return specifierImportedAs(component.getSourceFile(), component.getText()) ?? void 0;
|
|
213
|
+
}
|
|
63
214
|
function objectBehind(value, resolution) {
|
|
64
215
|
const resolved = resolution.resolveObject(value);
|
|
65
216
|
return resolved !== null && N.isObjectLiteralExpression(resolved) ? resolved : null;
|
|
66
217
|
}
|
|
67
|
-
function extractStories(sf, resolution) {
|
|
218
|
+
function extractStories(sf, resolution, meta) {
|
|
68
219
|
const results = [];
|
|
69
220
|
for (const [name, decls] of exportedDeclarationsOf(sf, resolution)) {
|
|
70
221
|
if (name === "default") {
|
|
@@ -75,18 +226,23 @@ function extractStories(sf, resolution) {
|
|
|
75
226
|
if (story === null) {
|
|
76
227
|
continue;
|
|
77
228
|
}
|
|
229
|
+
const ownReaders = argReadersOf(story, resolution);
|
|
78
230
|
results.push({
|
|
79
231
|
name,
|
|
80
|
-
args:
|
|
232
|
+
args: { ...meta.args, ...argsOf(story, resolution) },
|
|
233
|
+
argReaders: ARG_READERS.filter(
|
|
234
|
+
(reader) => meta.argReaders.includes(reader) || ownReaders.includes(reader)
|
|
235
|
+
),
|
|
236
|
+
renderProps: ownReaders.includes("render") ? renderPropsOf(story, meta.componentName, resolution) : meta.renderProps,
|
|
81
237
|
line: decl.getStartLineNumber()
|
|
82
238
|
});
|
|
83
239
|
}
|
|
84
240
|
}
|
|
85
241
|
return results;
|
|
86
242
|
}
|
|
87
|
-
function
|
|
243
|
+
function argsOf(storyOrMeta, resolution) {
|
|
88
244
|
const args = {};
|
|
89
|
-
const written = propertyOf(
|
|
245
|
+
const written = propertyOf(storyOrMeta, "args", resolution);
|
|
90
246
|
const object = written === null ? null : objectLiteralOf(written, resolution);
|
|
91
247
|
if (object === null) {
|
|
92
248
|
return args;
|
|
@@ -145,7 +301,11 @@ function buildSummary(story, meta, filePath) {
|
|
|
145
301
|
storybook: {
|
|
146
302
|
story: story.name,
|
|
147
303
|
component: meta.componentName,
|
|
304
|
+
...meta.componentModule === void 0 ? {} : { componentModule: meta.componentModule },
|
|
305
|
+
...meta.componentImport === void 0 ? {} : { componentImport: meta.componentImport },
|
|
148
306
|
args: story.args,
|
|
307
|
+
...story.argReaders.length === 0 ? {} : { argReaders: story.argReaders },
|
|
308
|
+
...story.renderProps === null ? {} : { renderProps: story.renderProps },
|
|
149
309
|
provenance: "independent"
|
|
150
310
|
}
|
|
151
311
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suss/contract-storybook",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Turn Storybook CSF stories into a suss contract and check the props they pass against the React components that receive them.",
|
|
6
6
|
"keywords": [
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"typecheck": "tsc --noEmit"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@suss/adapter-typescript": "0.
|
|
51
|
-
"@suss/behavioral-ir": "0.
|
|
50
|
+
"@suss/adapter-typescript": "0.35.0",
|
|
51
|
+
"@suss/behavioral-ir": "0.35.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"tsup": "*",
|