vibe-design-system 2.9.0 → 2.9.2
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/bin/init.js +42 -2
- package/package.json +1 -1
- package/vds-core-template/scan.mjs +26 -1
- package/vds-core-template/story-generator.mjs +367 -7
package/bin/init.js
CHANGED
|
@@ -24,6 +24,7 @@ const TEMPLATE_DIR = path.join(INSTALLER_ROOT, "vds-core-template");
|
|
|
24
24
|
const STORYBOOK_MAIN_TS = `import type { StorybookConfig } from "@storybook/react-vite";
|
|
25
25
|
import { mergeConfig } from "vite";
|
|
26
26
|
import path from "path";
|
|
27
|
+
import fs from "fs";
|
|
27
28
|
|
|
28
29
|
const config: StorybookConfig = {
|
|
29
30
|
stories: ["../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
|
@@ -33,10 +34,48 @@ const config: StorybookConfig = {
|
|
|
33
34
|
options: {},
|
|
34
35
|
},
|
|
35
36
|
async viteFinal(config) {
|
|
37
|
+
// Phase C2 — read tsconfig.json paths and inject as Vite aliases
|
|
38
|
+
const extraAliases = (() => {
|
|
39
|
+
try {
|
|
40
|
+
const tsConfigPaths = [
|
|
41
|
+
path.resolve(process.cwd(), "tsconfig.json"),
|
|
42
|
+
path.resolve(process.cwd(), "tsconfig.app.json"),
|
|
43
|
+
];
|
|
44
|
+
for (const tcp of tsConfigPaths) {
|
|
45
|
+
if (!fs.existsSync(tcp)) continue;
|
|
46
|
+
const raw = JSON.parse(fs.readFileSync(tcp, "utf-8").replace(/\\/\\/[^\\n]*/g, "").replace(/,(\\s*[}\\]])/g, "$1"));
|
|
47
|
+
const paths = raw?.compilerOptions?.paths || {};
|
|
48
|
+
const aliases: Record<string, string> = {};
|
|
49
|
+
for (const [alias, targets] of Object.entries(paths) as [string, string[]][]) {
|
|
50
|
+
const cleanAlias = alias.replace(/\\/\\*$/, "");
|
|
51
|
+
const target = targets[0]?.replace(/\\/\\*$/, "") || "";
|
|
52
|
+
if (cleanAlias && target && cleanAlias !== "@") {
|
|
53
|
+
aliases[cleanAlias] = path.resolve(process.cwd(), target);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (Object.keys(aliases).length > 0) return aliases;
|
|
57
|
+
}
|
|
58
|
+
} catch (_) {}
|
|
59
|
+
return {};
|
|
60
|
+
})();
|
|
36
61
|
return mergeConfig(config, {
|
|
62
|
+
plugins: [
|
|
63
|
+
{
|
|
64
|
+
// Mock figma:asset/* imports — returns empty string so components render without crashing in Storybook
|
|
65
|
+
name: "vds-figma-asset-mock",
|
|
66
|
+
enforce: "pre" as const,
|
|
67
|
+
resolveId(id: string) {
|
|
68
|
+
if (id.startsWith("figma:asset")) return "\\0vds-figma-asset-mock";
|
|
69
|
+
},
|
|
70
|
+
load(id: string) {
|
|
71
|
+
if (id === "\\0vds-figma-asset-mock") return "export default '';";
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
],
|
|
37
75
|
resolve: {
|
|
38
76
|
alias: {
|
|
39
77
|
"@": path.resolve(process.cwd(), "src"),
|
|
78
|
+
...extraAliases,
|
|
40
79
|
},
|
|
41
80
|
},
|
|
42
81
|
});
|
|
@@ -338,6 +377,7 @@ export default config;
|
|
|
338
377
|
return `import type { StorybookConfig } from "@storybook/react-vite";
|
|
339
378
|
import { mergeConfig } from "vite";
|
|
340
379
|
import path from "path";
|
|
380
|
+
import fs from "fs";
|
|
341
381
|
|
|
342
382
|
const config: StorybookConfig = {
|
|
343
383
|
stories: [
|
|
@@ -358,8 +398,8 @@ const config: StorybookConfig = {
|
|
|
358
398
|
path.resolve(process.cwd(), "${srcPrefix}", "..", "tsconfig.json"),
|
|
359
399
|
];
|
|
360
400
|
for (const tcp of tsConfigPaths) {
|
|
361
|
-
if (!
|
|
362
|
-
const raw = JSON.parse(
|
|
401
|
+
if (!fs.existsSync(tcp)) continue;
|
|
402
|
+
const raw = JSON.parse(fs.readFileSync(tcp, "utf-8").replace(/\/\/[^\n]*/g, "").replace(/,(\s*[}\]])/g, "$1"));
|
|
363
403
|
const paths = raw?.compilerOptions?.paths || {};
|
|
364
404
|
const aliases: Record<string, string> = {};
|
|
365
405
|
for (const [alias, targets] of Object.entries(paths) as [string, string[]][]) {
|
package/package.json
CHANGED
|
@@ -1145,6 +1145,8 @@ function extractTailwindTokens(content) {
|
|
|
1145
1145
|
/className\s*=\s*\{\s*["'`]([^"'`]+)["'`]/g,
|
|
1146
1146
|
/cn\s*\(\s*["'`]([^"'`]+)["'`]/g,
|
|
1147
1147
|
/cva\s*\(\s*["'`]([^"'`]+)["'`]/g,
|
|
1148
|
+
// CVA variant values: captures `default: "bg-x text-y"`, `destructive: "border-x..."` etc.
|
|
1149
|
+
/\b\w+\s*:\s*["'`]([a-zA-Z0-9_\-\/\s\[\]&>:%.]+)["'`]/g,
|
|
1148
1150
|
/["'`]([a-zA-Z0-9_\-\/\s\[\]&:%.]+(?:hover|focus|active|disabled|sm|md|lg|xl|2xl|dark:)[a-zA-Z0-9_\-\/\s\[\]&:%.]*)["'`]/g,
|
|
1149
1151
|
];
|
|
1150
1152
|
for (const re of patterns) {
|
|
@@ -2470,6 +2472,26 @@ function isComplexPageComponent(content) {
|
|
|
2470
2472
|
return true;
|
|
2471
2473
|
}
|
|
2472
2474
|
|
|
2475
|
+
/**
|
|
2476
|
+
* Extract top-level (no indentation) PascalCase component names from a source file.
|
|
2477
|
+
* Used for isPageComponent files to find internal sub-components that can be auto-exported.
|
|
2478
|
+
* Only matches declarations at column 0 (^) to exclude nested arrow functions.
|
|
2479
|
+
*/
|
|
2480
|
+
function extractTopLevelInternalComponentNames(content) {
|
|
2481
|
+
// Match only unindented `const ComponentName = (` or `const ComponentName: React.FC = (`
|
|
2482
|
+
const re = /^(?:export\s+)?const\s+([A-Z][A-Za-z0-9]+)\s*[:=]\s*(?:\(|\bReact\.)/gm;
|
|
2483
|
+
const names = [];
|
|
2484
|
+
const seen = new Set();
|
|
2485
|
+
let m;
|
|
2486
|
+
while ((m = re.exec(content)) !== null) {
|
|
2487
|
+
if (!seen.has(m[1])) {
|
|
2488
|
+
seen.add(m[1]);
|
|
2489
|
+
names.push(m[1]);
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
return names;
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2473
2495
|
function scan() {
|
|
2474
2496
|
const relativeFiles = COMPONENTS_DIR ? getAllComponentFiles(COMPONENTS_DIR) : [];
|
|
2475
2497
|
if (!COMPONENTS_DIR) {
|
|
@@ -2495,7 +2517,10 @@ function scan() {
|
|
|
2495
2517
|
}
|
|
2496
2518
|
const tokens = extractTailwindTokens(content);
|
|
2497
2519
|
const isPageComponent = isComplexPageComponent(content);
|
|
2498
|
-
const
|
|
2520
|
+
const internalComponentNames = isPageComponent
|
|
2521
|
+
? extractTopLevelInternalComponentNames(content).filter(n => n !== name)
|
|
2522
|
+
: undefined;
|
|
2523
|
+
const comp = { file: rel, name, group, category, description, tokens, ...(isPageComponent ? { isPageComponent: true, internalComponentNames } : {}) };
|
|
2499
2524
|
// Phase D — CSS Module tokens
|
|
2500
2525
|
const cssModuleTokens = extractCssModuleTokens(COMPONENTS_DIR ? path.join(COMPONENTS_DIR, rel) : null);
|
|
2501
2526
|
if (cssModuleTokens) comp.cssModuleTokens = cssModuleTokens;
|
|
@@ -2208,8 +2208,11 @@ function buildStoryFileContent(comp) {
|
|
|
2208
2208
|
lines.push(` component: ComponentRef,`);
|
|
2209
2209
|
// SECTION: no props/args → autodocs tries to render React.lazy without Suspense → useRef crash
|
|
2210
2210
|
if (profile !== "SECTION") lines.push(` tags: ["autodocs"],`);
|
|
2211
|
-
// Center small components
|
|
2212
|
-
if (profile !== "SECTION")
|
|
2211
|
+
// Center small components; fullscreen for complex page-level components
|
|
2212
|
+
if (profile !== "SECTION") {
|
|
2213
|
+
const layout = comp.isPageComponent ? "fullscreen" : "centered";
|
|
2214
|
+
lines.push(` parameters: { layout: "${layout}" },`);
|
|
2215
|
+
}
|
|
2213
2216
|
// Wrap with QueryClientProvider for components that use @tanstack/react-query hooks
|
|
2214
2217
|
if (needsQueryClient) {
|
|
2215
2218
|
lines.push(` decorators: [(Story: any) => React.createElement(QueryClientProvider, { client: _queryClient }, React.createElement(Story))],`);
|
|
@@ -5200,11 +5203,8 @@ function main() {
|
|
|
5200
5203
|
const storyFileName = `${componentName}.stories.tsx`;
|
|
5201
5204
|
const storyPath = path.join(STORIES_DIR, storyFileName);
|
|
5202
5205
|
if (SKIP_LIST.includes(componentName)) continue;
|
|
5203
|
-
//
|
|
5204
|
-
|
|
5205
|
-
console.log(`[VDS] ${componentName} → skipped (complex page component — add to extraSkipList to suppress this message)`);
|
|
5206
|
-
continue;
|
|
5207
|
-
}
|
|
5206
|
+
// Complex page-level components (500+ lines, 4+ inline sub-components) — generate fullscreen story instead of skipping
|
|
5207
|
+
// (Previously skipped; now a simplified fullscreen story is generated so they appear in Storybook)
|
|
5208
5208
|
const requiredCount = Array.isArray(comp.props) ? comp.props.filter((p) => p.required === true).length : 0;
|
|
5209
5209
|
if (requiredCount > 3) {
|
|
5210
5210
|
console.log(`[VDS] ${componentName} → skipped (${requiredCount} required props — too complex to auto-generate)`);
|
|
@@ -5224,6 +5224,12 @@ function main() {
|
|
|
5224
5224
|
writtenCount++;
|
|
5225
5225
|
}
|
|
5226
5226
|
|
|
5227
|
+
// Phase I — Internal component sub-stories for isPageComponent files
|
|
5228
|
+
for (const comp of components) {
|
|
5229
|
+
if (!comp.isPageComponent || !Array.isArray(comp.internalComponentNames) || comp.internalComponentNames.length === 0) continue;
|
|
5230
|
+
generateInternalComponentStories(comp);
|
|
5231
|
+
}
|
|
5232
|
+
|
|
5227
5233
|
// Summary
|
|
5228
5234
|
if (writtenCount === 0 && components.length > 0) {
|
|
5229
5235
|
const hasShadcnGroup = components.some(c => {
|
|
@@ -5240,5 +5246,359 @@ function main() {
|
|
|
5240
5246
|
}
|
|
5241
5247
|
}
|
|
5242
5248
|
|
|
5249
|
+
// ─── Phase I: Internal Component Sub-Story Engine ─────────────────────────────
|
|
5250
|
+
/**
|
|
5251
|
+
* Adds `export` to top-level internal component declarations in a source file.
|
|
5252
|
+
* Non-destructive: only adds `export` keyword, no logic changes.
|
|
5253
|
+
* Returns the list of names that were newly exported.
|
|
5254
|
+
*/
|
|
5255
|
+
function autoExportInternalComponents(sourceFilePath, names) {
|
|
5256
|
+
let src = fs.readFileSync(sourceFilePath, "utf-8");
|
|
5257
|
+
const newly = [];
|
|
5258
|
+
for (const n of names) {
|
|
5259
|
+
// Already exported? skip
|
|
5260
|
+
if (new RegExp(`^export\\s+const\\s+${n}\\b`, "m").test(src)) continue;
|
|
5261
|
+
// Exists as unexported top-level const?
|
|
5262
|
+
const re = new RegExp(`^(const\\s+${n}\\b)`, "m");
|
|
5263
|
+
if (!re.test(src)) continue;
|
|
5264
|
+
src = src.replace(re, "export $1");
|
|
5265
|
+
newly.push(n);
|
|
5266
|
+
}
|
|
5267
|
+
if (newly.length > 0) fs.writeFileSync(sourceFilePath, src, "utf-8");
|
|
5268
|
+
return newly;
|
|
5269
|
+
}
|
|
5270
|
+
|
|
5271
|
+
/**
|
|
5272
|
+
* Parse top-level relative import statements (local files only).
|
|
5273
|
+
* Returns [{importPath, symbols}]
|
|
5274
|
+
*/
|
|
5275
|
+
function parseLocalDataImports(content) {
|
|
5276
|
+
const re = /^import\s*\{([^}]+)\}\s*from\s*["'](\.[^"']+)["']/gm;
|
|
5277
|
+
const results = [];
|
|
5278
|
+
let m;
|
|
5279
|
+
while ((m = re.exec(content)) !== null) {
|
|
5280
|
+
const symbols = m[1].split(",").map(s => s.trim().replace(/\s+as\s+\w+$/, "").trim()).filter(Boolean);
|
|
5281
|
+
results.push({ importPath: m[2], symbols });
|
|
5282
|
+
}
|
|
5283
|
+
return results;
|
|
5284
|
+
}
|
|
5285
|
+
|
|
5286
|
+
/**
|
|
5287
|
+
* Detect named exports from a data file: returns { exportName → 'array' | 'object' | 'scalar' }
|
|
5288
|
+
*/
|
|
5289
|
+
function parseDataFileExports(filePath) {
|
|
5290
|
+
if (!fs.existsSync(filePath)) return {};
|
|
5291
|
+
const src = fs.readFileSync(filePath, "utf-8");
|
|
5292
|
+
const result = {};
|
|
5293
|
+
const re = /^export\s+const\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*[=:]/gm;
|
|
5294
|
+
let m;
|
|
5295
|
+
while ((m = re.exec(src)) !== null) {
|
|
5296
|
+
const name = m[1];
|
|
5297
|
+
const afterIdx = src.indexOf("=", m.index) + 1;
|
|
5298
|
+
const afterSlice = src.slice(afterIdx, afterIdx + 10).trimStart();
|
|
5299
|
+
result[name] = afterSlice.startsWith("[") ? "array" : afterSlice.startsWith("{") ? "object" : "scalar";
|
|
5300
|
+
}
|
|
5301
|
+
return result;
|
|
5302
|
+
}
|
|
5303
|
+
|
|
5304
|
+
/**
|
|
5305
|
+
* Extract inline TypeScript prop types for a named component from source content.
|
|
5306
|
+
* Handles: `const Foo = ({ a, b }: { a: boolean; b: string }) =>`
|
|
5307
|
+
* Handles nested object types (e.g., `data: { subStation: SubStation; } | null`)
|
|
5308
|
+
* Returns [{name, type}]
|
|
5309
|
+
*/
|
|
5310
|
+
function extractComponentProps(content, componentName) {
|
|
5311
|
+
const esc = componentName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
5312
|
+
const declRe = new RegExp(`(?:export\\s+)?const\\s+${esc}\\s*[:=]\\s*\\(`, "g");
|
|
5313
|
+
const declMatch = declRe.exec(content);
|
|
5314
|
+
if (!declMatch) return [];
|
|
5315
|
+
const slice = content.slice(declMatch.index, declMatch.index + 5000);
|
|
5316
|
+
// Find `}: {` — start of inline type annotation
|
|
5317
|
+
const typeHeaderIdx = slice.search(/\}\s*:\s*\{/);
|
|
5318
|
+
if (typeHeaderIdx === -1) return [];
|
|
5319
|
+
const braceStart = slice.indexOf("{", typeHeaderIdx);
|
|
5320
|
+
// Walk forward tracking brace depth to find the matching closing brace
|
|
5321
|
+
let depth = 0, braceEnd = -1;
|
|
5322
|
+
for (let i = braceStart; i < slice.length; i++) {
|
|
5323
|
+
if (slice[i] === "{") depth++;
|
|
5324
|
+
else if (slice[i] === "}") { depth--; if (depth === 0) { braceEnd = i; break; } }
|
|
5325
|
+
}
|
|
5326
|
+
if (braceEnd === -1) return [];
|
|
5327
|
+
const typeBody = slice.slice(braceStart + 1, braceEnd);
|
|
5328
|
+
// Smart prop parser: tracks depth so `;` inside nested types is skipped
|
|
5329
|
+
const props = [];
|
|
5330
|
+
let i = 0;
|
|
5331
|
+
while (i < typeBody.length) {
|
|
5332
|
+
while (i < typeBody.length && /\s/.test(typeBody[i])) i++;
|
|
5333
|
+
if (i >= typeBody.length) break;
|
|
5334
|
+
const nameMatch = typeBody.slice(i).match(/^(\w+)\??(\s*:\s*)/);
|
|
5335
|
+
if (!nameMatch) { i++; continue; }
|
|
5336
|
+
const propName = nameMatch[1];
|
|
5337
|
+
i += nameMatch[0].length;
|
|
5338
|
+
let typeDepth = 0;
|
|
5339
|
+
const typeStartIdx = i;
|
|
5340
|
+
while (i < typeBody.length) {
|
|
5341
|
+
const ch = typeBody[i];
|
|
5342
|
+
if ("{([<".includes(ch)) typeDepth++;
|
|
5343
|
+
// `>` only closes angle brackets (generics), not `=>` arrow functions
|
|
5344
|
+
else if ("})]".includes(ch) || (ch === ">" && typeDepth > 0)) typeDepth--;
|
|
5345
|
+
else if (ch === ";" && typeDepth === 0) break;
|
|
5346
|
+
i++;
|
|
5347
|
+
}
|
|
5348
|
+
const type = typeBody.slice(typeStartIdx, i).trim();
|
|
5349
|
+
if (propName && type && !propName.startsWith("//") && propName !== "readonly") {
|
|
5350
|
+
props.push({ name: propName, type });
|
|
5351
|
+
}
|
|
5352
|
+
i++; // skip `;`
|
|
5353
|
+
}
|
|
5354
|
+
return props;
|
|
5355
|
+
}
|
|
5356
|
+
|
|
5357
|
+
/**
|
|
5358
|
+
* Generate a mock value string for a TypeScript type, using available data exports.
|
|
5359
|
+
* Pass sourceContent to enable local interface/type parsing and nested property lookup.
|
|
5360
|
+
*/
|
|
5361
|
+
function mockValueForType(type, localExports, sourceContent) {
|
|
5362
|
+
const t = type.trim();
|
|
5363
|
+
if (t === "boolean") return "false";
|
|
5364
|
+
if (t === "string") return '""';
|
|
5365
|
+
if (t === "number") return "0";
|
|
5366
|
+
if (t === "null" || t === "undefined") return "null";
|
|
5367
|
+
if (t.startsWith("() =>") || t === "VoidFunction") return "() => {}";
|
|
5368
|
+
if (t.startsWith("(") && t.includes("=>")) return "() => {}";
|
|
5369
|
+
// Inline object type → null (complex; user fills it in)
|
|
5370
|
+
if (t.startsWith("{")) return "null";
|
|
5371
|
+
// Union ending in null/undefined → null
|
|
5372
|
+
if (t.endsWith("| null") || t.endsWith("| undefined") || t.startsWith("null |")) return "null";
|
|
5373
|
+
// String literal union → pick first quoted value
|
|
5374
|
+
const firstLiteral = t.match(/["']([^"']+)["']/);
|
|
5375
|
+
if (firstLiteral && (t.startsWith('"') || t.startsWith("'"))) return `"${firstLiteral[1]}"`;
|
|
5376
|
+
|
|
5377
|
+
// Named PascalCase type — try multiple resolution strategies
|
|
5378
|
+
if (/^[A-Z][A-Za-z0-9]+$/.test(t)) {
|
|
5379
|
+
const typeLower = t.toLowerCase().replace(/def$|id$|key$|type$|interface$|kind$/i, "");
|
|
5380
|
+
|
|
5381
|
+
// Strategy 1: Direct top-level array match (RouteDef → ROUTES[0])
|
|
5382
|
+
if (typeLower.length >= 3) {
|
|
5383
|
+
for (const [expName, expKind] of Object.entries(localExports)) {
|
|
5384
|
+
if (expKind !== "array") continue;
|
|
5385
|
+
const expBase = expName.toLowerCase().replace(/_/g, "").replace(/s$/, "").replace(/data$/, "");
|
|
5386
|
+
if (expBase.slice(0, 4) === typeLower.slice(0, 4) || typeLower.slice(0, 4) === expBase.slice(0, 4)) {
|
|
5387
|
+
return `${expName}[0]`;
|
|
5388
|
+
}
|
|
5389
|
+
}
|
|
5390
|
+
}
|
|
5391
|
+
|
|
5392
|
+
// Strategy 2: Local interface/type alias → generate inline object
|
|
5393
|
+
// e.g. FilterState → { level1: null, level2: null }
|
|
5394
|
+
if (sourceContent) {
|
|
5395
|
+
const esc = t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
5396
|
+
const bodyRe = new RegExp(`(?:interface|type)\\s+${esc}\\s*(?:=\\s*)?\\{([^}]+)\\}`);
|
|
5397
|
+
const m = sourceContent.match(bodyRe);
|
|
5398
|
+
if (m) {
|
|
5399
|
+
const fields = m[1].split(";").map(s => s.trim()).filter(s => s.length > 0 && !s.startsWith("//"));
|
|
5400
|
+
const fieldPairs = fields.map(f => {
|
|
5401
|
+
const ci = f.indexOf(":");
|
|
5402
|
+
if (ci < 0) return null;
|
|
5403
|
+
const fname = f.slice(0, ci).trim().replace(/\?$/, "");
|
|
5404
|
+
const ftype = f.slice(ci + 1).trim();
|
|
5405
|
+
// Resolve field type (no recursion with sourceContent to prevent deep loops)
|
|
5406
|
+
const fmock = mockValueForType(ftype, localExports, null);
|
|
5407
|
+
return ` ${fname}: ${fmock}`;
|
|
5408
|
+
}).filter(Boolean);
|
|
5409
|
+
if (fieldPairs.length > 0) return `{\n${fieldPairs.join(",\n")}\n}`;
|
|
5410
|
+
}
|
|
5411
|
+
}
|
|
5412
|
+
|
|
5413
|
+
// Strategy 3: Nested array property lookup
|
|
5414
|
+
// SubStation → DATA[0]?.subStations?.[0] (camelCase plural of type name)
|
|
5415
|
+
const camel = t[0].toLowerCase() + t.slice(1);
|
|
5416
|
+
const plural = camel + "s";
|
|
5417
|
+
for (const [expName, expKind] of Object.entries(localExports)) {
|
|
5418
|
+
if (expKind !== "array") continue;
|
|
5419
|
+
return `${expName}[0]?.${plural}?.[0]`;
|
|
5420
|
+
}
|
|
5421
|
+
}
|
|
5422
|
+
|
|
5423
|
+
return "undefined as any";
|
|
5424
|
+
}
|
|
5425
|
+
|
|
5426
|
+
/**
|
|
5427
|
+
* Build the story file content for a single internal component.
|
|
5428
|
+
* Automatically uses render() format when props require complex/nested mock data.
|
|
5429
|
+
*/
|
|
5430
|
+
function buildInternalComponentStoryContent(compName, parentImportPath, dataImportPath, dataSymbols, localExports, props, parentComp, sourceContent) {
|
|
5431
|
+
const lines = [];
|
|
5432
|
+
lines.push(`// @vds-regenerate — VDS auto-generated. Remove this line to prevent overwrite.`);
|
|
5433
|
+
lines.push(`import React from "react";`);
|
|
5434
|
+
lines.push(`import type { Meta, StoryObj } from "@storybook/react";`);
|
|
5435
|
+
lines.push(`import { ${compName} } from "${parentImportPath}";`);
|
|
5436
|
+
if (dataImportPath && dataSymbols.length > 0) {
|
|
5437
|
+
lines.push(`import { ${dataSymbols.join(", ")} } from "${dataImportPath}";`);
|
|
5438
|
+
}
|
|
5439
|
+
lines.push(``);
|
|
5440
|
+
lines.push(`/**`);
|
|
5441
|
+
lines.push(` * **${compName}** — sub-component of ${parentComp.name}.`);
|
|
5442
|
+
lines.push(` * Auto-exported by VDS for isolated Storybook documentation.`);
|
|
5443
|
+
lines.push(` */`);
|
|
5444
|
+
lines.push(`const meta: Meta<typeof ${compName}> = {`);
|
|
5445
|
+
lines.push(` title: "${parentComp.group || "Screens"}/${parentComp.name}/${compName}",`);
|
|
5446
|
+
lines.push(` component: ${compName},`);
|
|
5447
|
+
lines.push(` parameters: {`);
|
|
5448
|
+
lines.push(` layout: "centered",`);
|
|
5449
|
+
lines.push(` backgrounds: { default: "dark" },`);
|
|
5450
|
+
lines.push(` },`);
|
|
5451
|
+
lines.push(`};`);
|
|
5452
|
+
lines.push(`export default meta;`);
|
|
5453
|
+
lines.push(`type Story = StoryObj<typeof meta>;`);
|
|
5454
|
+
lines.push(``);
|
|
5455
|
+
|
|
5456
|
+
if (props.length === 0) {
|
|
5457
|
+
lines.push(`export const Default: Story = {};`);
|
|
5458
|
+
return lines.join("\n") + "\n";
|
|
5459
|
+
}
|
|
5460
|
+
|
|
5461
|
+
const mockedProps = props.map(p => ({
|
|
5462
|
+
name: p.name,
|
|
5463
|
+
mock: mockValueForType(p.type, localExports, sourceContent),
|
|
5464
|
+
}));
|
|
5465
|
+
|
|
5466
|
+
// Use render() format when any mock is complex: nested path (?.),
|
|
5467
|
+
// multiline inline object (\n), or unresolved (undefined as any)
|
|
5468
|
+
const needsRender = mockedProps.some(p =>
|
|
5469
|
+
p.mock === "undefined as any" ||
|
|
5470
|
+
p.mock.includes("?.") ||
|
|
5471
|
+
p.mock.includes("\n")
|
|
5472
|
+
);
|
|
5473
|
+
|
|
5474
|
+
if (!needsRender) {
|
|
5475
|
+
// Simple args format — all values are primitives or direct data refs
|
|
5476
|
+
const argLines = mockedProps.map(p => ` ${p.name}: ${p.mock},`).join("\n");
|
|
5477
|
+
lines.push(`export const Default: Story = {`);
|
|
5478
|
+
lines.push(` args: {`);
|
|
5479
|
+
lines.push(argLines);
|
|
5480
|
+
lines.push(` },`);
|
|
5481
|
+
lines.push(`};`);
|
|
5482
|
+
} else {
|
|
5483
|
+
// render() format — declare complex vars, guard against undefined, then render
|
|
5484
|
+
const varDecls = [];
|
|
5485
|
+
const propAttrs = [];
|
|
5486
|
+
const guardVarNames = [];
|
|
5487
|
+
|
|
5488
|
+
for (const { name, mock } of mockedProps) {
|
|
5489
|
+
if (mock.includes("?.") || mock === "undefined as any") {
|
|
5490
|
+
// Nested/unknown: declare variable + add to guard list
|
|
5491
|
+
const varName = `mock${name[0].toUpperCase() + name.slice(1)}`;
|
|
5492
|
+
varDecls.push(` const ${varName} = ${mock};`);
|
|
5493
|
+
guardVarNames.push(varName);
|
|
5494
|
+
propAttrs.push(` ${name}={${varName} as any}`);
|
|
5495
|
+
} else if (mock.includes("\n")) {
|
|
5496
|
+
// Multiline inline object (e.g. interface mock)
|
|
5497
|
+
const varName = `mock${name[0].toUpperCase() + name.slice(1)}`;
|
|
5498
|
+
const indented = mock.split("\n").map((l, i) => i === 0 ? l : " " + l).join("\n");
|
|
5499
|
+
varDecls.push(` const ${varName} = ${indented} as any;`);
|
|
5500
|
+
propAttrs.push(` ${name}={${varName}}`);
|
|
5501
|
+
} else {
|
|
5502
|
+
propAttrs.push(` ${name}={${mock}}`);
|
|
5503
|
+
}
|
|
5504
|
+
}
|
|
5505
|
+
|
|
5506
|
+
lines.push(`export const Default: Story = {`);
|
|
5507
|
+
lines.push(` render: () => {`);
|
|
5508
|
+
for (const v of varDecls) lines.push(v);
|
|
5509
|
+
if (guardVarNames.length > 0) {
|
|
5510
|
+
const check = guardVarNames.map(v => `!${v}`).join(" || ");
|
|
5511
|
+
lines.push(` if (${check}) return <div style={{ color: "#888", padding: "1rem" }}>⚠️ Mock data unavailable for <strong>${compName}</strong></div>;`);
|
|
5512
|
+
}
|
|
5513
|
+
lines.push(` return (`);
|
|
5514
|
+
lines.push(` <${compName}`);
|
|
5515
|
+
for (const attr of propAttrs) lines.push(attr);
|
|
5516
|
+
lines.push(` />`);
|
|
5517
|
+
lines.push(` );`);
|
|
5518
|
+
lines.push(` },`);
|
|
5519
|
+
lines.push(`};`);
|
|
5520
|
+
}
|
|
5521
|
+
|
|
5522
|
+
return lines.join("\n") + "\n";
|
|
5523
|
+
}
|
|
5524
|
+
|
|
5525
|
+
/**
|
|
5526
|
+
* Main orchestrator: auto-exports internal components + generates individual story files.
|
|
5527
|
+
* Called for each comp with isPageComponent: true.
|
|
5528
|
+
*/
|
|
5529
|
+
function generateInternalComponentStories(comp) {
|
|
5530
|
+
const names = comp.internalComponentNames || [];
|
|
5531
|
+
if (names.length === 0) return;
|
|
5532
|
+
|
|
5533
|
+
// Locate the source file (COMPONENTS_REL_DIR is a module-level variable set in main())
|
|
5534
|
+
const sourceFile = path.join(PROJECT_ROOT, COMPONENTS_REL_DIR, comp.file);
|
|
5535
|
+
if (!fs.existsSync(sourceFile)) return;
|
|
5536
|
+
|
|
5537
|
+
const sourceContent = fs.readFileSync(sourceFile, "utf-8");
|
|
5538
|
+
|
|
5539
|
+
// Step 1: Auto-export internal components
|
|
5540
|
+
const newly = autoExportInternalComponents(sourceFile, names);
|
|
5541
|
+
if (newly.length > 0) {
|
|
5542
|
+
console.log(`[VDS] ${comp.name} → auto-exported ${newly.length} sub-components: ${newly.join(", ")}`);
|
|
5543
|
+
}
|
|
5544
|
+
|
|
5545
|
+
// Step 2: Find best local data import (most array-typed exports)
|
|
5546
|
+
const localImports = parseLocalDataImports(sourceContent);
|
|
5547
|
+
let bestImportPath = null;
|
|
5548
|
+
let bestSymbols = [];
|
|
5549
|
+
let bestExports = {};
|
|
5550
|
+
for (const li of localImports) {
|
|
5551
|
+
const resolved = path.resolve(path.dirname(sourceFile), li.importPath);
|
|
5552
|
+
const exts = ["", ".ts", ".tsx", ".js", ".jsx"];
|
|
5553
|
+
let found = null;
|
|
5554
|
+
for (const ext of exts) {
|
|
5555
|
+
const p = resolved + ext;
|
|
5556
|
+
if (fs.existsSync(p)) { found = p; break; }
|
|
5557
|
+
}
|
|
5558
|
+
if (!found) continue;
|
|
5559
|
+
const exports = parseDataFileExports(found);
|
|
5560
|
+
const arrayCount = Object.values(exports).filter(k => k === "array").length;
|
|
5561
|
+
if (arrayCount > Object.values(bestExports).filter(k => k === "array").length) {
|
|
5562
|
+
bestImportPath = li.importPath;
|
|
5563
|
+
bestSymbols = li.symbols;
|
|
5564
|
+
bestExports = exports;
|
|
5565
|
+
}
|
|
5566
|
+
}
|
|
5567
|
+
|
|
5568
|
+
// Step 3: Compute import paths relative to STORIES_DIR
|
|
5569
|
+
const storiesRelToSource = path.relative(path.dirname(sourceFile), path.join(PROJECT_ROOT, "src", "stories"));
|
|
5570
|
+
const sourceRelFromStories = path.relative(
|
|
5571
|
+
path.join(PROJECT_ROOT, "src", "stories"),
|
|
5572
|
+
sourceFile.replace(/\.(tsx?|jsx?)$/, "")
|
|
5573
|
+
).replace(/\\/g, "/");
|
|
5574
|
+
const parentImportPath = sourceRelFromStories.startsWith(".") ? sourceRelFromStories : "./" + sourceRelFromStories;
|
|
5575
|
+
|
|
5576
|
+
let dataImportFromStories = null;
|
|
5577
|
+
if (bestImportPath) {
|
|
5578
|
+
const dataAbsolute = path.resolve(path.dirname(sourceFile), bestImportPath);
|
|
5579
|
+
dataImportFromStories = path.relative(
|
|
5580
|
+
path.join(PROJECT_ROOT, "src", "stories"),
|
|
5581
|
+
dataAbsolute
|
|
5582
|
+
).replace(/\\/g, "/").replace(/\.(tsx?|jsx?)$/, "");
|
|
5583
|
+
if (!dataImportFromStories.startsWith(".")) dataImportFromStories = "./" + dataImportFromStories;
|
|
5584
|
+
}
|
|
5585
|
+
|
|
5586
|
+
// Step 4: Generate individual story files
|
|
5587
|
+
for (const name of names) {
|
|
5588
|
+
const storyFile = path.join(STORIES_DIR, `${name}.stories.tsx`);
|
|
5589
|
+
if (fs.existsSync(storyFile)) {
|
|
5590
|
+
// Preserve user-modified stories; overwrite only VDS-generated ones
|
|
5591
|
+
const existing = fs.readFileSync(storyFile, "utf-8");
|
|
5592
|
+
if (!existing.startsWith("// @vds-regenerate")) continue;
|
|
5593
|
+
}
|
|
5594
|
+
const props = extractComponentProps(sourceContent, name);
|
|
5595
|
+
const content = buildInternalComponentStoryContent(
|
|
5596
|
+
name, parentImportPath, dataImportFromStories, bestSymbols, bestExports, props, comp, sourceContent
|
|
5597
|
+
);
|
|
5598
|
+
fs.writeFileSync(storyFile, content, "utf-8");
|
|
5599
|
+
console.log(`[VDS] Wrote ${path.relative(PROJECT_ROOT, storyFile)} (sub-component of ${comp.name})`);
|
|
5600
|
+
}
|
|
5601
|
+
}
|
|
5602
|
+
|
|
5243
5603
|
main();
|
|
5244
5604
|
|