@onlook/storybook-plugin 0.3.2 → 0.4.0-beta.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/dist/cli/index.js +115 -40
- package/dist/index.d.ts +14 -1
- package/dist/index.js +257 -61
- package/dist/screenshot-service/index.js +59 -27
- package/package.json +4 -2
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
|
-
|
|
98
|
+
try {
|
|
99
|
+
await page.waitForLoadState("networkidle", { timeout: 5e3 });
|
|
100
|
+
} catch {
|
|
101
|
+
}
|
|
99
102
|
await page.evaluate(() => document.fonts.ready);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
203
|
-
console.log(`[${
|
|
209
|
+
const absoluteIndex = offset + completed;
|
|
210
|
+
console.log(`[${absoluteIndex}/${displayTotal}] Skipped (exists) ${story.id}`);
|
|
204
211
|
return;
|
|
205
212
|
}
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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", ...
|
|
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
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
|
377
|
+
reject(new Error("Storybook failed to start within 180 seconds"));
|
|
305
378
|
}
|
|
306
|
-
},
|
|
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("
|
|
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
|
|
2
|
-
import
|
|
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 =
|
|
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 =
|
|
72
|
-
var SCREENSHOTS_DIR =
|
|
73
|
-
var MANIFEST_PATH =
|
|
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 (!
|
|
82
|
-
|
|
165
|
+
if (!fs5.existsSync(CACHE_DIR)) {
|
|
166
|
+
fs5.mkdirSync(CACHE_DIR, { recursive: true });
|
|
83
167
|
}
|
|
84
|
-
if (!
|
|
85
|
-
|
|
168
|
+
if (!fs5.existsSync(SCREENSHOTS_DIR)) {
|
|
169
|
+
fs5.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
|
|
86
170
|
}
|
|
87
171
|
}
|
|
88
172
|
function computeFileHash(filePath) {
|
|
89
|
-
if (!
|
|
173
|
+
if (!fs5.existsSync(filePath)) {
|
|
90
174
|
return "";
|
|
91
175
|
}
|
|
92
|
-
const content =
|
|
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 (
|
|
97
|
-
const content =
|
|
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
|
-
|
|
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 =
|
|
131
|
-
return
|
|
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
|
-
|
|
229
|
+
try {
|
|
230
|
+
await page.waitForLoadState("networkidle", { timeout: 5e3 });
|
|
231
|
+
} catch {
|
|
232
|
+
}
|
|
146
233
|
await page.evaluate(() => document.fonts.ready);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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 =
|
|
212
|
-
if (!
|
|
213
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
610
|
+
const storyDir = path.join(
|
|
448
611
|
process.cwd(),
|
|
449
612
|
".storybook-cache",
|
|
450
613
|
"screenshots",
|
|
451
614
|
storyId
|
|
452
615
|
);
|
|
453
|
-
if (!
|
|
454
|
-
|
|
616
|
+
if (!fs5.existsSync(storyDir)) {
|
|
617
|
+
fs5.mkdirSync(storyDir, { recursive: true });
|
|
455
618
|
}
|
|
456
|
-
|
|
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
|
-
//
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
+
try {
|
|
97
|
+
await page.waitForLoadState("networkidle", { timeout: 5e3 });
|
|
98
|
+
} catch {
|
|
99
|
+
}
|
|
97
100
|
await page.evaluate(() => document.fonts.ready);
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
201
|
-
console.log(`[${
|
|
207
|
+
const absoluteIndex = offset + completed;
|
|
208
|
+
console.log(`[${absoluteIndex}/${displayTotal}] Skipped (exists) ${story.id}`);
|
|
202
209
|
return;
|
|
203
210
|
}
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
+
"version": "0.4.0-beta.0",
|
|
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
|
-
"
|
|
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:*",
|