@onlook/storybook-plugin 0.3.2 → 0.4.0-beta.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/dist/cli/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { command, string, boolean, run } from '@drizzle-team/brocli';
3
3
  import { spawn } from 'child_process';
4
+ import fs, { readFileSync, existsSync } from 'fs';
4
5
  import path, { resolve, join, dirname } from 'path';
5
6
  import crypto from 'crypto';
6
- import fs, { existsSync } from 'fs';
7
7
  import { chromium } from 'playwright';
8
8
 
9
9
  var CACHE_DIR = path.join(process.cwd(), ".storybook-cache");
@@ -95,20 +95,27 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
95
95
  await page.goto(url, { timeout: timeoutMs });
96
96
  await page.waitForLoadState("domcontentloaded", { timeout: timeoutMs });
97
97
  await page.waitForLoadState("load", { timeout: timeoutMs });
98
- await page.waitForLoadState("networkidle", { timeout: timeoutMs });
98
+ try {
99
+ await page.waitForLoadState("networkidle", { timeout: 5e3 });
100
+ } catch {
101
+ }
99
102
  await page.evaluate(() => document.fonts.ready);
100
- await page.evaluate(async () => {
101
- const images = document.querySelectorAll("img");
102
- await Promise.all(
103
- Array.from(images).map((img) => {
104
- if (img.complete) return Promise.resolve();
105
- return new Promise((resolve3) => {
106
- img.addEventListener("load", resolve3);
107
- img.addEventListener("error", resolve3);
108
- });
109
- })
110
- );
111
- });
103
+ try {
104
+ await page.evaluate(async () => {
105
+ const images = document.querySelectorAll("img");
106
+ await Promise.all(
107
+ Array.from(images).map((img) => {
108
+ if (img.complete) return Promise.resolve();
109
+ return new Promise((resolve3) => {
110
+ img.addEventListener("load", resolve3);
111
+ img.addEventListener("error", resolve3);
112
+ setTimeout(resolve3, 3e3);
113
+ });
114
+ })
115
+ );
116
+ });
117
+ } catch {
118
+ }
112
119
  const contentBounds = await page.evaluate(() => {
113
120
  const root = document.querySelector("#storybook-root");
114
121
  if (!root) return null;
@@ -199,23 +206,48 @@ async function generateAllScreenshots(stories, storybookUrl = "http://localhost:
199
206
  batch.map(async (story) => {
200
207
  if (skipExisting && screenshotExists(story.id, "light") && screenshotExists(story.id, "dark")) {
201
208
  completed++;
202
- const absoluteIndex2 = offset + completed;
203
- console.log(`[${absoluteIndex2}/${displayTotal}] Skipped (exists) ${story.id}`);
209
+ const absoluteIndex = offset + completed;
210
+ console.log(`[${absoluteIndex}/${displayTotal}] Skipped (exists) ${story.id}`);
204
211
  return;
205
212
  }
206
- const [lightResult, darkResult] = await Promise.all([
207
- generateScreenshot(story.id, "light", storybookUrl, timeoutMs),
208
- generateScreenshot(story.id, "dark", storybookUrl, timeoutMs)
209
- ]);
210
- if (lightResult && darkResult) {
211
- const fileHash = computeFileHash(story.importPath);
212
- updateManifest(story.id, story.importPath, fileHash, lightResult.boundingBox);
213
+ const storyTimeout = timeoutMs * 2 + 1e4;
214
+ let timer;
215
+ try {
216
+ const result = await Promise.race([
217
+ Promise.all([
218
+ generateScreenshot(story.id, "light", storybookUrl, timeoutMs),
219
+ generateScreenshot(story.id, "dark", storybookUrl, timeoutMs)
220
+ ]),
221
+ new Promise((_, reject) => {
222
+ timer = setTimeout(
223
+ () => reject(
224
+ new Error(
225
+ `Story ${story.id} timed out after ${storyTimeout / 1e3}s`
226
+ )
227
+ ),
228
+ storyTimeout
229
+ );
230
+ })
231
+ ]);
232
+ clearTimeout(timer);
233
+ const [lightResult, darkResult] = result;
234
+ if (lightResult && darkResult) {
235
+ const fileHash = computeFileHash(story.importPath);
236
+ updateManifest(story.id, story.importPath, fileHash, lightResult.boundingBox);
237
+ }
238
+ completed++;
239
+ const absoluteIndex = offset + completed;
240
+ console.log(
241
+ `[${absoluteIndex}/${displayTotal}] Generated screenshots for ${story.id}`
242
+ );
243
+ } catch (error) {
244
+ completed++;
245
+ const absoluteIndex = offset + completed;
246
+ console.error(
247
+ `[${absoluteIndex}/${displayTotal}] \u26A0\uFE0F Failed ${story.id}:`,
248
+ error instanceof Error ? error.message : error
249
+ );
213
250
  }
214
- completed++;
215
- const absoluteIndex = offset + completed;
216
- console.log(
217
- `[${absoluteIndex}/${displayTotal}] Generated screenshots for ${story.id}`
218
- );
219
251
  })
220
252
  );
221
253
  }
@@ -260,10 +292,27 @@ function getStorybookCommand(storybookDir) {
260
292
  console.log(
261
293
  `\u{1F4E6} Detected package manager: ${pm}${repoRoot ? ` (from ${repoRoot})` : ""}`
262
294
  );
263
- const separator = pm === "npm" || pm === "pnpm" ? ["--"] : [];
295
+ let extraArgs = [];
296
+ try {
297
+ const pkgPath = resolve(storybookDir, "package.json");
298
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
299
+ const script = pkg.scripts?.storybook ?? "";
300
+ const hasPort = script.includes("-p ") || script.includes("--port");
301
+ const hasNoOpen = script.includes("--no-open");
302
+ if (!hasPort || !hasNoOpen) {
303
+ const needsSeparator = pm === "npm" || pm === "pnpm";
304
+ const flags = [];
305
+ if (!hasPort) flags.push("-p", "6006");
306
+ if (!hasNoOpen) flags.push("--no-open");
307
+ extraArgs = needsSeparator && flags.length > 0 ? ["--", ...flags] : flags;
308
+ }
309
+ } catch {
310
+ const separator = pm === "npm" || pm === "pnpm" ? ["--"] : [];
311
+ extraArgs = [...separator, "-p", "6006", "--no-open"];
312
+ }
264
313
  return {
265
314
  command: pm,
266
- args: ["run", "storybook", ...separator, "-p", "6006", "--no-open"]
315
+ args: ["run", "storybook", ...extraArgs]
267
316
  };
268
317
  }
269
318
  async function isStorybookRunning(url) {
@@ -276,14 +325,38 @@ async function isStorybookRunning(url) {
276
325
  return false;
277
326
  }
278
327
  }
279
- async function fetchStoryIndex(url) {
328
+ async function waitForStorybookReady(url, timeoutMs = 12e4) {
329
+ const start = Date.now();
330
+ const pollInterval = 2e3;
331
+ while (Date.now() - start < timeoutMs) {
332
+ if (await isStorybookRunning(url)) return;
333
+ await new Promise((r) => setTimeout(r, pollInterval));
334
+ }
335
+ throw new Error(`Storybook did not become ready within ${timeoutMs / 1e3}s`);
336
+ }
337
+ async function fetchStoryIndex(url, retries = 3) {
280
338
  const indexUrl = `${url}/index.json`;
281
- const response = await fetch(indexUrl);
282
- if (!response.ok) {
283
- throw new Error(`Failed to fetch story index: ${response.statusText}`);
339
+ for (let attempt = 1; attempt <= retries; attempt++) {
340
+ try {
341
+ const response = await fetch(indexUrl, {
342
+ signal: AbortSignal.timeout(1e4)
343
+ });
344
+ if (!response.ok) {
345
+ throw new Error(
346
+ `Failed to fetch story index: ${response.status} ${response.statusText}`
347
+ );
348
+ }
349
+ const data = await response.json();
350
+ return Object.values(data.entries);
351
+ } catch (error) {
352
+ if (attempt === retries) throw error;
353
+ console.log(
354
+ `\u26A0\uFE0F Fetch story index failed (attempt ${attempt}/${retries}), retrying in 3s...`
355
+ );
356
+ await new Promise((r) => setTimeout(r, 3e3));
357
+ }
284
358
  }
285
- const data = await response.json();
286
- return Object.values(data.entries);
359
+ throw new Error("Unreachable");
287
360
  }
288
361
  async function startStorybook(command2, args, storybookDir) {
289
362
  console.log(
@@ -301,12 +374,12 @@ async function startStorybook(command2, args, storybookDir) {
301
374
  const timeout = setTimeout(() => {
302
375
  if (!started) {
303
376
  storybookProcess.kill();
304
- reject(new Error("Storybook failed to start within 60 seconds"));
377
+ reject(new Error("Storybook failed to start within 180 seconds"));
305
378
  }
306
- }, 6e4);
379
+ }, 18e4);
307
380
  storybookProcess.stdout?.on("data", (data) => {
308
381
  const output = data.toString();
309
- if (output.includes("Local:") || output.includes("localhost:")) {
382
+ if (output.includes("Local:") || output.includes("localhost:") || output.includes("local:")) {
310
383
  if (!started) {
311
384
  started = true;
312
385
  clearTimeout(timeout);
@@ -335,7 +408,7 @@ async function warmupStorybook(url, firstStoryId) {
335
408
  try {
336
409
  const warmupUrl = `${url}/iframe.html?id=${firstStoryId}&viewMode=story`;
337
410
  await page.goto(warmupUrl, { timeout: 15e3 });
338
- await page.waitForLoadState("networkidle");
411
+ await page.waitForLoadState("domcontentloaded", { timeout: 15e3 });
339
412
  console.log("\u2705 Storybook warmed up");
340
413
  } catch {
341
414
  console.log("\u26A0\uFE0F Warmup had issues, proceeding anyway");
@@ -358,6 +431,8 @@ async function generateScreenshots(options = {}) {
358
431
  const { command: command2, args } = getStorybookCommand(storybookDir);
359
432
  storybookProcess = await startStorybook(command2, args, storybookDir);
360
433
  weStartedStorybook = true;
434
+ console.log("\u23F3 Waiting for Storybook to finish compiling...");
435
+ await waitForStorybookReady(url);
361
436
  }
362
437
  const allStories = await fetchStoryIndex(url);
363
438
  const total = allStories.length;
package/dist/index.d.ts CHANGED
@@ -1,11 +1,24 @@
1
1
  import { PluginOption } from 'vite';
2
2
 
3
+ /** Folder name for auto-generated stories (created next to each component) */
4
+ declare const AUTO_STORIES_FOLDER = ".onlook-stories";
3
5
  type OnlookPluginOptions = {
4
6
  /** Storybook port (default: 6006) */
5
7
  port?: number;
6
8
  /** Additional allowed origins for CORS (merged with defaults) */
7
9
  allowedOrigins?: string[];
10
+ /**
11
+ * Auto-generate stories for all components.
12
+ * Glob patterns for component file discovery.
13
+ * Set to false to disable. (default: ['src\/\*\*\/*.tsx'])
14
+ */
15
+ autoStories?: string[] | false;
16
+ /**
17
+ * Glob patterns to exclude from auto-story generation.
18
+ * (default: stories, tests, and node_modules)
19
+ */
20
+ autoStoriesIgnore?: string[];
8
21
  };
9
22
  declare function storybookOnlookPlugin(options?: OnlookPluginOptions): PluginOption[];
10
23
 
11
- export { type OnlookPluginOptions, storybookOnlookPlugin };
24
+ export { AUTO_STORIES_FOLDER, type OnlookPluginOptions, storybookOnlookPlugin };
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
- import fs4, { existsSync } from 'fs';
2
- import path2, { dirname, join, relative } from 'path';
1
+ import fs5, { existsSync } from 'fs';
2
+ import path, { dirname, join, relative } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
+ import autoStoryGenerator from '@takuma-ru/auto-story-generator';
5
+ import { withDefaultConfig } from 'react-docgen-typescript';
4
6
  import generateModule from '@babel/generator';
5
7
  import { parse } from '@babel/parser';
6
8
  import traverseModule from '@babel/traverse';
@@ -9,6 +11,88 @@ import crypto from 'crypto';
9
11
  import { chromium } from 'playwright';
10
12
 
11
13
  // src/storybook-onlook-plugin.ts
14
+ var FIXED_MARKER = "// @onlook-fixed";
15
+ var parser = withDefaultConfig({
16
+ shouldExtractLiteralValuesFromEnum: true,
17
+ shouldRemoveUndefinedFromOptional: true,
18
+ propFilter: (prop) => {
19
+ if (prop.parent?.fileName.includes("node_modules")) return false;
20
+ return true;
21
+ }
22
+ });
23
+ function resolveComponentPath(storyFilePath) {
24
+ const dir = path.dirname(storyFilePath);
25
+ const parentDir = path.dirname(dir);
26
+ const storyName = path.basename(storyFilePath);
27
+ const componentName = storyName.replace(".stories.tsx", ".tsx");
28
+ const componentPath = path.join(parentDir, componentName);
29
+ return fs5.existsSync(componentPath) ? componentPath : null;
30
+ }
31
+ function generateArgTypes(componentPath) {
32
+ try {
33
+ const docs = parser.parse(componentPath);
34
+ if (docs.length === 0) return null;
35
+ const doc = docs[0];
36
+ if (!doc) return null;
37
+ const argTypes = {};
38
+ for (const [name, prop] of Object.entries(doc.props)) {
39
+ if (prop.type.name.includes("=>") || prop.type.name === "Function") {
40
+ continue;
41
+ }
42
+ const argType = {};
43
+ if (prop.description) {
44
+ argType.description = prop.description;
45
+ }
46
+ if (prop.type.name === "enum" && prop.type.value) {
47
+ argType.control = { type: "select" };
48
+ argType.options = prop.type.value.map(
49
+ (v) => v.value
50
+ );
51
+ }
52
+ if (prop.type.name === "boolean") {
53
+ argType.control = { type: "boolean" };
54
+ }
55
+ if (prop.type.name === "number") {
56
+ argType.control = { type: "number" };
57
+ }
58
+ if (Object.keys(argType).length > 0) {
59
+ argTypes[name] = argType;
60
+ }
61
+ }
62
+ return Object.keys(argTypes).length > 0 ? argTypes : null;
63
+ } catch (err) {
64
+ console.error(`[AutoStories] Failed to parse component: ${componentPath}`, err);
65
+ return null;
66
+ }
67
+ }
68
+ function enrichStoryFile(storyFilePath) {
69
+ try {
70
+ const content = fs5.readFileSync(storyFilePath, "utf-8");
71
+ if (content.includes(FIXED_MARKER)) {
72
+ return;
73
+ }
74
+ if (content.includes("argTypes:")) {
75
+ return;
76
+ }
77
+ const componentPath = resolveComponentPath(storyFilePath);
78
+ if (!componentPath) return;
79
+ const argTypes = generateArgTypes(componentPath);
80
+ if (!argTypes) return;
81
+ const argTypesStr = JSON.stringify(argTypes, null, 2).split("\n").map((line, i) => i === 0 ? line : ` ${line}`).join("\n");
82
+ const enriched = content.replace(
83
+ /};\s*\nexport default meta;/,
84
+ ` argTypes: ${argTypesStr},
85
+ };
86
+ export default meta;`
87
+ );
88
+ if (enriched !== content) {
89
+ fs5.writeFileSync(storyFilePath, enriched);
90
+ console.log(`[AutoStories] Enriched ${path.basename(storyFilePath)} with argTypes`);
91
+ }
92
+ } catch (err) {
93
+ console.error(`[AutoStories] Failed to enrich story: ${storyFilePath}`, err);
94
+ }
95
+ }
12
96
  function componentLocPlugin(options = {}) {
13
97
  const include = options.include ?? /\.(jsx|tsx)$/;
14
98
  const traverse = traverseModule.default ?? traverseModule;
@@ -31,7 +115,7 @@ function componentLocPlugin(options = {}) {
31
115
  sourceFilename: filepath
32
116
  });
33
117
  let mutated = false;
34
- const relativePath = path2.relative(root, filepath);
118
+ const relativePath = path.relative(root, filepath);
35
119
  traverse(ast, {
36
120
  JSXElement(nodePath) {
37
121
  const opening = nodePath.node.openingElement;
@@ -68,9 +152,9 @@ function componentLocPlugin(options = {}) {
68
152
  }
69
153
  };
70
154
  }
71
- var CACHE_DIR = path2.join(process.cwd(), ".storybook-cache");
72
- var SCREENSHOTS_DIR = path2.join(CACHE_DIR, "screenshots");
73
- var MANIFEST_PATH = path2.join(CACHE_DIR, "manifest.json");
155
+ var CACHE_DIR = path.join(process.cwd(), ".storybook-cache");
156
+ var SCREENSHOTS_DIR = path.join(CACHE_DIR, "screenshots");
157
+ var MANIFEST_PATH = path.join(CACHE_DIR, "manifest.json");
74
158
  var VIEWPORT_WIDTH = 1920;
75
159
  var VIEWPORT_HEIGHT = 1080;
76
160
  var MIN_COMPONENT_WIDTH = 420;
@@ -78,30 +162,30 @@ var MIN_COMPONENT_HEIGHT = 280;
78
162
 
79
163
  // src/utils/fileSystem/fileSystem.ts
80
164
  function ensureCacheDirectories() {
81
- if (!fs4.existsSync(CACHE_DIR)) {
82
- fs4.mkdirSync(CACHE_DIR, { recursive: true });
165
+ if (!fs5.existsSync(CACHE_DIR)) {
166
+ fs5.mkdirSync(CACHE_DIR, { recursive: true });
83
167
  }
84
- if (!fs4.existsSync(SCREENSHOTS_DIR)) {
85
- fs4.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
168
+ if (!fs5.existsSync(SCREENSHOTS_DIR)) {
169
+ fs5.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
86
170
  }
87
171
  }
88
172
  function computeFileHash(filePath) {
89
- if (!fs4.existsSync(filePath)) {
173
+ if (!fs5.existsSync(filePath)) {
90
174
  return "";
91
175
  }
92
- const content = fs4.readFileSync(filePath, "utf-8");
176
+ const content = fs5.readFileSync(filePath, "utf-8");
93
177
  return crypto.createHash("sha256").update(content).digest("hex");
94
178
  }
95
179
  function loadManifest() {
96
- if (fs4.existsSync(MANIFEST_PATH)) {
97
- const content = fs4.readFileSync(MANIFEST_PATH, "utf-8");
180
+ if (fs5.existsSync(MANIFEST_PATH)) {
181
+ const content = fs5.readFileSync(MANIFEST_PATH, "utf-8");
98
182
  return JSON.parse(content);
99
183
  }
100
184
  return { stories: {} };
101
185
  }
102
186
  function saveManifest(manifest) {
103
187
  ensureCacheDirectories();
104
- fs4.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
188
+ fs5.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
105
189
  }
106
190
  function updateManifest(storyId, sourcePath, fileHash, boundingBox) {
107
191
  const manifest = loadManifest();
@@ -127,8 +211,8 @@ async function getBrowser() {
127
211
  return browser;
128
212
  }
129
213
  function getScreenshotPath(storyId, theme) {
130
- const storyDir = path2.join(SCREENSHOTS_DIR, storyId);
131
- return path2.join(storyDir, `${theme}.png`);
214
+ const storyDir = path.join(SCREENSHOTS_DIR, storyId);
215
+ return path.join(storyDir, `${theme}.png`);
132
216
  }
133
217
  async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006", timeoutMs = 3e4) {
134
218
  const browser2 = await getBrowser();
@@ -142,20 +226,27 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
142
226
  await page.goto(url, { timeout: timeoutMs });
143
227
  await page.waitForLoadState("domcontentloaded", { timeout: timeoutMs });
144
228
  await page.waitForLoadState("load", { timeout: timeoutMs });
145
- await page.waitForLoadState("networkidle", { timeout: timeoutMs });
229
+ try {
230
+ await page.waitForLoadState("networkidle", { timeout: 5e3 });
231
+ } catch {
232
+ }
146
233
  await page.evaluate(() => document.fonts.ready);
147
- await page.evaluate(async () => {
148
- const images = document.querySelectorAll("img");
149
- await Promise.all(
150
- Array.from(images).map((img) => {
151
- if (img.complete) return Promise.resolve();
152
- return new Promise((resolve) => {
153
- img.addEventListener("load", resolve);
154
- img.addEventListener("error", resolve);
155
- });
156
- })
157
- );
158
- });
234
+ try {
235
+ await page.evaluate(async () => {
236
+ const images = document.querySelectorAll("img");
237
+ await Promise.all(
238
+ Array.from(images).map((img) => {
239
+ if (img.complete) return Promise.resolve();
240
+ return new Promise((resolve) => {
241
+ img.addEventListener("load", resolve);
242
+ img.addEventListener("error", resolve);
243
+ setTimeout(resolve, 3e3);
244
+ });
245
+ })
246
+ );
247
+ });
248
+ } catch {
249
+ }
159
250
  const contentBounds = await page.evaluate(() => {
160
251
  const root = document.querySelector("#storybook-root");
161
252
  if (!root) return null;
@@ -208,9 +299,9 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
208
299
  async function generateScreenshot(storyId, theme, storybookUrl = "http://localhost:6006", timeoutMs = 3e4) {
209
300
  try {
210
301
  ensureCacheDirectories();
211
- const storyDir = path2.join(SCREENSHOTS_DIR, storyId);
212
- if (!fs4.existsSync(storyDir)) {
213
- fs4.mkdirSync(storyDir, { recursive: true });
302
+ const storyDir = path.join(SCREENSHOTS_DIR, storyId);
303
+ if (!fs5.existsSync(storyDir)) {
304
+ fs5.mkdirSync(storyDir, { recursive: true });
214
305
  }
215
306
  const screenshotPath = getScreenshotPath(storyId, theme);
216
307
  const { buffer, boundingBox } = await captureScreenshotBuffer(
@@ -221,7 +312,7 @@ async function generateScreenshot(storyId, theme, storybookUrl = "http://localho
221
312
  storybookUrl,
222
313
  timeoutMs
223
314
  );
224
- fs4.writeFileSync(screenshotPath, buffer);
315
+ fs5.writeFileSync(screenshotPath, buffer);
225
316
  return { path: screenshotPath, boundingBox };
226
317
  } catch (error) {
227
318
  console.error(`Error generating screenshot for ${storyId} (${theme}):`, error);
@@ -248,7 +339,7 @@ async function fetchStorybookIndex() {
248
339
  }
249
340
  function getStoriesForFile(filePath) {
250
341
  if (!cachedIndex) return [];
251
- const fileName = path2.basename(filePath);
342
+ const fileName = path.basename(filePath);
252
343
  return Object.values(cachedIndex.entries).filter((entry) => entry.type === "story" && entry.importPath.endsWith(fileName)).map((entry) => entry.id);
253
344
  }
254
345
  async function regenerateScreenshotsForFiles(files) {
@@ -335,10 +426,82 @@ var DEFAULT_ALLOWED_ORIGINS = [
335
426
  "http://localhost:3000",
336
427
  "http://localhost:6006"
337
428
  ];
429
+ var AUTO_STORIES_FOLDER = ".onlook-stories";
430
+ var storyRuntimeErrors = /* @__PURE__ */ new Map();
338
431
  var serveMetadataAndScreenshots = (req, res, next) => {
432
+ if (req.url === "/onbook-health.json") {
433
+ const cacheBuster = Date.now();
434
+ fetch(`http://localhost:6006/index.json?_t=${cacheBuster}`, {
435
+ cache: "no-store"
436
+ }).then((response) => response.json()).then((indexData) => {
437
+ const entries = indexData.entries || {};
438
+ const autoStories = [];
439
+ const userStories = [];
440
+ for (const [storyId, entry] of Object.entries(entries)) {
441
+ const importPath = entry.importPath || "";
442
+ if (importPath.includes(AUTO_STORIES_FOLDER)) {
443
+ autoStories.push(storyId);
444
+ } else {
445
+ userStories.push(storyId);
446
+ }
447
+ }
448
+ const healthy = autoStories.filter((id) => !storyRuntimeErrors.has(id));
449
+ const broken = autoStories.filter((id) => storyRuntimeErrors.has(id)).map((id) => ({
450
+ storyId: id,
451
+ // biome-ignore lint/style/noNonNullAssertion: filtered above
452
+ error: storyRuntimeErrors.get(id)
453
+ }));
454
+ res.setHeader("Content-Type", "application/json");
455
+ res.setHeader("Access-Control-Allow-Origin", "*");
456
+ res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
457
+ res.end(
458
+ JSON.stringify({
459
+ autoStoryCount: autoStories.length,
460
+ userStoryCount: userStories.length,
461
+ healthy,
462
+ broken
463
+ })
464
+ );
465
+ }).catch((error) => {
466
+ res.statusCode = 500;
467
+ res.setHeader("Content-Type", "application/json");
468
+ res.end(
469
+ JSON.stringify({
470
+ error: "Failed to fetch story index",
471
+ details: String(error)
472
+ })
473
+ );
474
+ });
475
+ return;
476
+ }
477
+ if (req.url === "/onbook-report-error" && req.method === "POST") {
478
+ let body = "";
479
+ req.on("data", (chunk) => {
480
+ body += chunk.toString();
481
+ });
482
+ req.on("end", () => {
483
+ try {
484
+ const { storyId, error } = JSON.parse(body);
485
+ if (storyId && error) {
486
+ storyRuntimeErrors.set(storyId, error);
487
+ console.log(
488
+ `[STORYBOOK_PLUGIN] Story runtime error reported: ${storyId}`,
489
+ error
490
+ );
491
+ }
492
+ res.setHeader("Access-Control-Allow-Origin", "*");
493
+ res.statusCode = 200;
494
+ res.end("ok");
495
+ } catch {
496
+ res.statusCode = 400;
497
+ res.end("Invalid JSON");
498
+ }
499
+ });
500
+ return;
501
+ }
339
502
  if (req.url === "/onbook-index.json") {
340
503
  console.log("[STORYBOOK_PLUGIN] Serving /onbook-index.json endpoint");
341
- const manifestPath = path2.join(process.cwd(), ".storybook-cache", "manifest.json");
504
+ const manifestPath = path.join(process.cwd(), ".storybook-cache", "manifest.json");
342
505
  const cacheBuster = Date.now();
343
506
  console.log("[STORYBOOK_PLUGIN] Fetching http://localhost:6006/index.json");
344
507
  fetch(`http://localhost:6006/index.json?_t=${cacheBuster}`, {
@@ -355,7 +518,7 @@ var serveMetadataAndScreenshots = (req, res, next) => {
355
518
  });
356
519
  return response.json();
357
520
  }).then((indexData) => {
358
- const manifest = fs4.existsSync(manifestPath) ? JSON.parse(fs4.readFileSync(manifestPath, "utf-8")) : { stories: {} };
521
+ const manifest = fs5.existsSync(manifestPath) ? JSON.parse(fs5.readFileSync(manifestPath, "utf-8")) : { stories: {} };
359
522
  const defaultBoundingBox = { width: 1920, height: 1080 };
360
523
  for (const [storyId, entry] of Object.entries(indexData.entries || {})) {
361
524
  const manifestEntry = manifest.stories?.[storyId];
@@ -423,7 +586,7 @@ var serveMetadataAndScreenshots = (req, res, next) => {
423
586
  return;
424
587
  }
425
588
  if (req.url?.startsWith("/screenshots/")) {
426
- const screenshotPath = path2.join(
589
+ const screenshotPath = path.join(
427
590
  process.cwd(),
428
591
  ".storybook-cache",
429
592
  req.url.replace("/screenshots/", "screenshots/")
@@ -432,11 +595,11 @@ var serveMetadataAndScreenshots = (req, res, next) => {
432
595
  const storyId = urlParts[0];
433
596
  const themeFile = urlParts[1];
434
597
  const theme = themeFile?.replace(".png", "");
435
- if (fs4.existsSync(screenshotPath)) {
598
+ if (fs5.existsSync(screenshotPath)) {
436
599
  res.setHeader("Content-Type", "image/png");
437
600
  res.setHeader("Access-Control-Allow-Origin", "*");
438
601
  res.setHeader("Cache-Control", "public, max-age=3600");
439
- fs4.createReadStream(screenshotPath).pipe(res);
602
+ fs5.createReadStream(screenshotPath).pipe(res);
440
603
  return;
441
604
  }
442
605
  if (storyId && theme && (theme === "light" || theme === "dark")) {
@@ -444,16 +607,16 @@ var serveMetadataAndScreenshots = (req, res, next) => {
444
607
  `[STORYBOOK_PLUGIN] Generating screenshot on-demand: ${storyId}/${theme}`
445
608
  );
446
609
  captureScreenshotBuffer(storyId, theme).then(({ buffer }) => {
447
- const storyDir = path2.join(
610
+ const storyDir = path.join(
448
611
  process.cwd(),
449
612
  ".storybook-cache",
450
613
  "screenshots",
451
614
  storyId
452
615
  );
453
- if (!fs4.existsSync(storyDir)) {
454
- fs4.mkdirSync(storyDir, { recursive: true });
616
+ if (!fs5.existsSync(storyDir)) {
617
+ fs5.mkdirSync(storyDir, { recursive: true });
455
618
  }
456
- fs4.writeFileSync(screenshotPath, buffer);
619
+ fs5.writeFileSync(screenshotPath, buffer);
457
620
  res.setHeader("Content-Type", "image/png");
458
621
  res.setHeader("Access-Control-Allow-Origin", "*");
459
622
  res.setHeader("Cache-Control", "public, max-age=3600");
@@ -496,17 +659,16 @@ function storybookOnlookPlugin(options = {}) {
496
659
  const mainPlugin = {
497
660
  name: "storybook-onlook-plugin",
498
661
  config() {
499
- const isE2B = !!process.env.E2B_SANDBOX;
500
662
  return {
501
663
  server: {
502
- // Only override HMR for E2B sandboxes (wss + port 443)
503
- // Local dev uses Vite's default HMR which works out of the box
504
- ...isE2B && {
505
- hmr: {
506
- protocol: "wss",
507
- clientPort: 443,
508
- port
509
- }
664
+ // E2B sandbox HMR configuration
665
+ hmr: {
666
+ // E2B sandboxes use HTTPS, so we need secure WebSocket
667
+ protocol: "wss",
668
+ // E2B routes through standard HTTPS port 443
669
+ clientPort: 443,
670
+ // The actual Storybook server port inside the sandbox
671
+ port
510
672
  },
511
673
  cors: {
512
674
  origin: allowedOrigins
@@ -528,15 +690,49 @@ function storybookOnlookPlugin(options = {}) {
528
690
  console.log("[STORYBOOK_PLUGIN] Configuring preview server middleware");
529
691
  server.middlewares.use(serveMetadataAndScreenshots);
530
692
  },
531
- // Only auto-regenerate screenshots in E2B sandboxes where Onbook
532
- // consumes them. In local dev this blocks HMR with 36 Playwright
533
- // page loads per save. Screenshots are still available on-demand
534
- // via /api/capture-screenshot and the CLI.
535
- ...process.env.E2B_SANDBOX && {
536
- handleHotUpdate: handleStoryFileChange
693
+ handleHotUpdate(ctx) {
694
+ if (ctx.file.includes(AUTO_STORIES_FOLDER) && ctx.file.endsWith(".stories.tsx")) {
695
+ enrichStoryFile(ctx.file);
696
+ }
697
+ return handleStoryFileChange(ctx);
537
698
  }
538
699
  };
539
- return [componentLocPlugin(), mainPlugin];
700
+ const plugins = [componentLocPlugin(), mainPlugin];
701
+ if (options.autoStories !== false) {
702
+ const imports = options.autoStories ?? ["src/**/*.tsx"];
703
+ const ignores = options.autoStoriesIgnore ?? [
704
+ "src/**/*.stories.tsx",
705
+ "src/**/*.stories.ts",
706
+ "src/**/*.test.tsx",
707
+ "src/**/*.test.ts",
708
+ "src/**/*.spec.tsx",
709
+ "src/**/*.spec.ts",
710
+ "node_modules/**",
711
+ "**/.onlook-stories/**"
712
+ ];
713
+ console.log("[STORYBOOK_PLUGIN] Auto-story generation enabled", {
714
+ imports,
715
+ ignores,
716
+ storiesFolder: AUTO_STORIES_FOLDER
717
+ });
718
+ try {
719
+ plugins.push(
720
+ autoStoryGenerator.vite({
721
+ preset: "react",
722
+ imports,
723
+ ignores,
724
+ storiesFolder: AUTO_STORIES_FOLDER,
725
+ isGenerateStoriesFileAtBuild: true
726
+ })
727
+ );
728
+ } catch (err) {
729
+ console.error(
730
+ "[STORYBOOK_PLUGIN] ASG plugin failed to initialize, continuing without auto-stories",
731
+ err
732
+ );
733
+ }
734
+ }
735
+ return plugins;
540
736
  }
541
737
 
542
- export { storybookOnlookPlugin };
738
+ export { AUTO_STORIES_FOLDER, storybookOnlookPlugin };
@@ -93,20 +93,27 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
93
93
  await page.goto(url, { timeout: timeoutMs });
94
94
  await page.waitForLoadState("domcontentloaded", { timeout: timeoutMs });
95
95
  await page.waitForLoadState("load", { timeout: timeoutMs });
96
- await page.waitForLoadState("networkidle", { timeout: timeoutMs });
96
+ try {
97
+ await page.waitForLoadState("networkidle", { timeout: 5e3 });
98
+ } catch {
99
+ }
97
100
  await page.evaluate(() => document.fonts.ready);
98
- await page.evaluate(async () => {
99
- const images = document.querySelectorAll("img");
100
- await Promise.all(
101
- Array.from(images).map((img) => {
102
- if (img.complete) return Promise.resolve();
103
- return new Promise((resolve) => {
104
- img.addEventListener("load", resolve);
105
- img.addEventListener("error", resolve);
106
- });
107
- })
108
- );
109
- });
101
+ try {
102
+ await page.evaluate(async () => {
103
+ const images = document.querySelectorAll("img");
104
+ await Promise.all(
105
+ Array.from(images).map((img) => {
106
+ if (img.complete) return Promise.resolve();
107
+ return new Promise((resolve) => {
108
+ img.addEventListener("load", resolve);
109
+ img.addEventListener("error", resolve);
110
+ setTimeout(resolve, 3e3);
111
+ });
112
+ })
113
+ );
114
+ });
115
+ } catch {
116
+ }
110
117
  const contentBounds = await page.evaluate(() => {
111
118
  const root = document.querySelector("#storybook-root");
112
119
  if (!root) return null;
@@ -197,23 +204,48 @@ async function generateAllScreenshots(stories, storybookUrl = "http://localhost:
197
204
  batch.map(async (story) => {
198
205
  if (skipExisting && screenshotExists(story.id, "light") && screenshotExists(story.id, "dark")) {
199
206
  completed++;
200
- const absoluteIndex2 = offset + completed;
201
- console.log(`[${absoluteIndex2}/${displayTotal}] Skipped (exists) ${story.id}`);
207
+ const absoluteIndex = offset + completed;
208
+ console.log(`[${absoluteIndex}/${displayTotal}] Skipped (exists) ${story.id}`);
202
209
  return;
203
210
  }
204
- const [lightResult, darkResult] = await Promise.all([
205
- generateScreenshot(story.id, "light", storybookUrl, timeoutMs),
206
- generateScreenshot(story.id, "dark", storybookUrl, timeoutMs)
207
- ]);
208
- if (lightResult && darkResult) {
209
- const fileHash = computeFileHash(story.importPath);
210
- updateManifest(story.id, story.importPath, fileHash, lightResult.boundingBox);
211
+ const storyTimeout = timeoutMs * 2 + 1e4;
212
+ let timer;
213
+ try {
214
+ const result = await Promise.race([
215
+ Promise.all([
216
+ generateScreenshot(story.id, "light", storybookUrl, timeoutMs),
217
+ generateScreenshot(story.id, "dark", storybookUrl, timeoutMs)
218
+ ]),
219
+ new Promise((_, reject) => {
220
+ timer = setTimeout(
221
+ () => reject(
222
+ new Error(
223
+ `Story ${story.id} timed out after ${storyTimeout / 1e3}s`
224
+ )
225
+ ),
226
+ storyTimeout
227
+ );
228
+ })
229
+ ]);
230
+ clearTimeout(timer);
231
+ const [lightResult, darkResult] = result;
232
+ if (lightResult && darkResult) {
233
+ const fileHash = computeFileHash(story.importPath);
234
+ updateManifest(story.id, story.importPath, fileHash, lightResult.boundingBox);
235
+ }
236
+ completed++;
237
+ const absoluteIndex = offset + completed;
238
+ console.log(
239
+ `[${absoluteIndex}/${displayTotal}] Generated screenshots for ${story.id}`
240
+ );
241
+ } catch (error) {
242
+ completed++;
243
+ const absoluteIndex = offset + completed;
244
+ console.error(
245
+ `[${absoluteIndex}/${displayTotal}] \u26A0\uFE0F Failed ${story.id}:`,
246
+ error instanceof Error ? error.message : error
247
+ );
211
248
  }
212
- completed++;
213
- const absoluteIndex = offset + completed;
214
- console.log(
215
- `[${absoluteIndex}/${displayTotal}] Generated screenshots for ${story.id}`
216
- );
217
249
  })
218
250
  );
219
251
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlook/storybook-plugin",
3
- "version": "0.3.2",
3
+ "version": "0.4.0-beta.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "onlook-storybook": "./dist/cli/index.js"
@@ -37,7 +37,9 @@
37
37
  "@babel/traverse": "^7.26.9",
38
38
  "@babel/types": "^7.26.9",
39
39
  "@drizzle-team/brocli": "^0.11.0",
40
- "playwright": "^1.52.0"
40
+ "@takuma-ru/auto-story-generator": "^0.4.0",
41
+ "playwright": "^1.52.0",
42
+ "react-docgen-typescript": "^2.4.0"
41
43
  },
42
44
  "devDependencies": {
43
45
  "@onbook/tsconfig": "workspace:*",