Skip to content

Commit 54588da

Browse files
committed
Story: implement form
1 parent 20a4789 commit 54588da

File tree

14 files changed

+1461
-1
lines changed

14 files changed

+1461
-1
lines changed

packages/story/css/styles.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
@import 'tailwindcss';
2+
@import 'fumadocs-ui/css/neutral.css';
3+
@import 'fumadocs-ui/css/preset.css';
4+
5+
/* do not modify this, just to trigger intelli-sense of Tailwind CSS plugin */

packages/story/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,20 @@
2525
"lint": "eslint .",
2626
"types:check": "tsc --noEmit"
2727
},
28+
"dependencies": {
29+
"@fumari/stf": "workspace:^",
30+
"@radix-ui/react-select": "^2.2.6",
31+
"class-variance-authority": "^0.7.1",
32+
"lucide-react": "^0.562.0",
33+
"tailwind-merge": "^3.4.0",
34+
"ts-morph": "^27.0.2"
35+
},
2836
"devDependencies": {
2937
"@types/node": "25.0.5",
3038
"@types/react": "^19.2.8",
3139
"eslint-config-custom": "workspace:*",
40+
"fumadocs-ui": "^16.4.7",
41+
"tailwindcss": "^4.1.18",
3242
"tsconfig": "workspace:*",
3343
"tsdown": "^0.19.0"
3444
},
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { TypeNode } from './lib/types';
2+
3+
export function getDefaultValue(node: TypeNode): unknown {
4+
switch (node.type) {
5+
case 'object':
6+
return Object.fromEntries(
7+
node.properties.map((prop) => [prop.name, getDefaultValue(prop.type)]),
8+
);
9+
case 'array':
10+
return [];
11+
case 'null':
12+
return null;
13+
case 'undefined':
14+
return undefined;
15+
case 'string':
16+
return '';
17+
case 'number':
18+
return 0;
19+
case 'boolean':
20+
return false;
21+
case 'enum':
22+
// Return first enum value as default
23+
return node.members[0]?.value ?? '';
24+
case 'literal':
25+
return node.value;
26+
case 'union':
27+
// Return default value of first union type
28+
return node.types.length > 0 ? getDefaultValue(node.types[0]!) : undefined;
29+
case 'intersection': {
30+
// For intersection, merge object properties if all are objects
31+
const objects = node.types.filter((t) => t.type === 'object') as Array<
32+
Extract<TypeNode, { type: 'object' }>
33+
>;
34+
if (objects.length > 0) {
35+
const merged: Record<string, unknown> = {};
36+
for (const obj of objects) {
37+
for (const prop of obj.properties) {
38+
merged[prop.name] = getDefaultValue(prop.type);
39+
}
40+
}
41+
return merged;
42+
}
43+
// Otherwise return default of first type
44+
return node.types.length > 0 ? getDefaultValue(node.types[0]!) : undefined;
45+
}
46+
case 'unknown':
47+
return undefined;
48+
}
49+
}

packages/story/src/index.tsx

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,106 @@
1+
import { Project, type Type } from 'ts-morph';
2+
import * as path from 'node:path';
3+
import * as fs from 'node:fs/promises';
4+
import { createHash } from 'node:crypto';
5+
import { typeToNode } from './lib/type-tree';
6+
import type { Cache } from './lib/cache';
7+
import type { TypeNode } from './lib/types';
8+
19
export interface StoryOptions {
210
from: {
311
file: string;
412
export: string;
513
};
14+
tsconfigPath?: string;
15+
cache?: Cache | false;
616
}
717

8-
export function defineStory(options: StoryOptions) {}
18+
export * from './lib/types';
19+
export * from './lib/cache';
20+
21+
export interface StoryResult {
22+
props: TypeNode;
23+
}
24+
25+
export async function defineStory(options: StoryOptions): Promise<StoryResult> {
26+
const filePath = path.resolve(options.from.file);
27+
const fileContent = await fs.readFile(filePath, 'utf-8');
28+
29+
// Generate cache key based on file path, export name, and content hash
30+
const contentHash = createHash('MD5').update(fileContent).digest('hex').slice(0, 12);
31+
const cacheKey = `${filePath}:${options.from.export}:${contentHash}`;
32+
33+
// Try to read from cache
34+
if (options.cache !== false) {
35+
const cached = await options.cache?.read(cacheKey);
36+
if (cached) {
37+
return cached as StoryResult;
38+
}
39+
}
40+
41+
const project = new Project({
42+
tsConfigFilePath: options.tsconfigPath ?? './tsconfig.json',
43+
skipAddingFilesFromTsConfig: true,
44+
});
45+
46+
const sourceFile = project.addSourceFileAtPath(filePath);
47+
48+
const exportedDeclarations = sourceFile.getExportedDeclarations();
49+
const declaration = exportedDeclarations.get(options.from.export)?.[0];
50+
51+
if (!declaration) {
52+
throw new Error(`Export "${options.from.export}" not found in file "${options.from.file}"`);
53+
}
54+
55+
const type = declaration.getType();
56+
const checker = project.getTypeChecker();
57+
58+
// Extract props type from React component
59+
let propsType: Type | undefined;
60+
61+
// Check if it's a function component
62+
const callSignatures = type.getCallSignatures();
63+
if (callSignatures.length > 0) {
64+
// Function component: props are the first parameter
65+
const firstParam = callSignatures[0]?.getParameters()[0];
66+
if (firstParam) {
67+
propsType = firstParam.getTypeAtLocation(declaration);
68+
}
69+
} else if (type.isClassOrInterface()) {
70+
// Class component: look for props property or constructor parameter
71+
const propsProperty = type.getProperty('props');
72+
if (propsProperty) {
73+
propsType = propsProperty.getTypeAtLocation(declaration);
74+
} else {
75+
// Try to get from constructor
76+
const constructSignatures = type.getConstructSignatures();
77+
if (constructSignatures.length > 0) {
78+
const firstParam = constructSignatures[0]?.getParameters()[0];
79+
if (firstParam) {
80+
propsType = firstParam.getTypeAtLocation(declaration);
81+
}
82+
}
83+
}
84+
} else if (type.isObject()) {
85+
// Already an object type, use it directly
86+
propsType = type;
87+
}
88+
89+
if (!propsType) {
90+
// Fallback: use the type itself
91+
propsType = type;
92+
}
93+
94+
const propsNode = typeToNode(propsType, checker, declaration);
95+
96+
const result: StoryResult = {
97+
props: propsNode,
98+
};
99+
100+
// Write to cache
101+
if (options.cache !== false) {
102+
await options.cache?.write(cacheKey, result);
103+
}
104+
105+
return result;
106+
}

packages/story/src/lib/cache.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import fs from 'node:fs/promises';
2+
import * as path from 'node:path';
3+
4+
export interface Cache {
5+
read: (key: string) => unknown | undefined | Promise<unknown | undefined>;
6+
write: (key: string, value: unknown) => void | Promise<void>;
7+
}
8+
9+
export function createFileSystemCache(dir: string): Cache {
10+
// call `path.resolve` so Vercel NFT will include the cache directory in production.
11+
dir = path.resolve(dir);
12+
const initDirPromise = fs.mkdir(dir, { recursive: true }).catch(() => {
13+
// it fails on Vercel as of 2025 12 May, we can skip it
14+
});
15+
16+
return {
17+
async write(key, data) {
18+
await initDirPromise;
19+
await fs.writeFile(path.join(dir, `${key}.json`), JSON.stringify(data));
20+
},
21+
async read(key) {
22+
try {
23+
return JSON.parse((await fs.readFile(path.join(dir, `${key}.json`))).toString());
24+
} catch {
25+
return;
26+
}
27+
},
28+
};
29+
}

0 commit comments

Comments
 (0)