@onlook/storybook-plugin 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/dist/cli/index.js +80 -24
- package/dist/index.js +110 -28
- package/dist/screenshot-service/index.d.ts +5 -2
- package/dist/screenshot-service/index.js +24 -13
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -61,10 +61,16 @@ Generate screenshots for all stories:
|
|
|
61
61
|
|
|
62
62
|
```bash
|
|
63
63
|
# With Storybook already running
|
|
64
|
-
npx
|
|
64
|
+
npx generate-screenshots
|
|
65
65
|
|
|
66
|
-
#
|
|
67
|
-
npx
|
|
66
|
+
# Start Storybook automatically
|
|
67
|
+
npx generate-screenshots --start
|
|
68
|
+
|
|
69
|
+
# Custom Storybook URL
|
|
70
|
+
npx generate-screenshots --url http://localhost:9009
|
|
71
|
+
|
|
72
|
+
# Custom start command
|
|
73
|
+
npx generate-screenshots --start --cmd "bun run storybook"
|
|
68
74
|
```
|
|
69
75
|
|
|
70
76
|
Screenshots are saved to `.storybook-cache/screenshots/`.
|
package/dist/cli/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { command, string, run } from '@drizzle-team/brocli';
|
|
2
|
+
import { command, string, boolean, run } from '@drizzle-team/brocli';
|
|
3
3
|
import { spawn } from 'child_process';
|
|
4
4
|
import path, { resolve, join, dirname } from 'path';
|
|
5
5
|
import crypto from 'crypto';
|
|
@@ -79,7 +79,11 @@ function getScreenshotPath(storyId, theme) {
|
|
|
79
79
|
const storyDir = path.join(SCREENSHOTS_DIR, storyId);
|
|
80
80
|
return path.join(storyDir, `${theme}.png`);
|
|
81
81
|
}
|
|
82
|
-
|
|
82
|
+
function screenshotExists(storyId, theme) {
|
|
83
|
+
const screenshotPath = getScreenshotPath(storyId, theme);
|
|
84
|
+
return fs.existsSync(screenshotPath);
|
|
85
|
+
}
|
|
86
|
+
async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006", timeoutMs = 3e4) {
|
|
83
87
|
const browser2 = await getBrowser();
|
|
84
88
|
const context = await browser2.newContext({
|
|
85
89
|
viewport: { width, height },
|
|
@@ -88,10 +92,10 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
|
|
|
88
92
|
const page = await context.newPage();
|
|
89
93
|
try {
|
|
90
94
|
const url = `${storybookUrl}/iframe.html?id=${storyId}&viewMode=story&globals=theme:${theme}`;
|
|
91
|
-
await page.goto(url, { timeout:
|
|
92
|
-
await page.waitForLoadState("domcontentloaded");
|
|
93
|
-
await page.waitForLoadState("load");
|
|
94
|
-
await page.waitForLoadState("networkidle");
|
|
95
|
+
await page.goto(url, { timeout: timeoutMs });
|
|
96
|
+
await page.waitForLoadState("domcontentloaded", { timeout: timeoutMs });
|
|
97
|
+
await page.waitForLoadState("load", { timeout: timeoutMs });
|
|
98
|
+
await page.waitForLoadState("networkidle", { timeout: timeoutMs });
|
|
95
99
|
await page.evaluate(() => document.fonts.ready);
|
|
96
100
|
await page.evaluate(async () => {
|
|
97
101
|
const images = document.querySelectorAll("img");
|
|
@@ -154,7 +158,7 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
|
|
|
154
158
|
await context.close();
|
|
155
159
|
}
|
|
156
160
|
}
|
|
157
|
-
async function generateScreenshot(storyId, theme, storybookUrl = "http://localhost:6006") {
|
|
161
|
+
async function generateScreenshot(storyId, theme, storybookUrl = "http://localhost:6006", timeoutMs = 3e4) {
|
|
158
162
|
try {
|
|
159
163
|
ensureCacheDirectories();
|
|
160
164
|
const storyDir = path.join(SCREENSHOTS_DIR, storyId);
|
|
@@ -167,7 +171,8 @@ async function generateScreenshot(storyId, theme, storybookUrl = "http://localho
|
|
|
167
171
|
theme,
|
|
168
172
|
VIEWPORT_WIDTH,
|
|
169
173
|
VIEWPORT_HEIGHT,
|
|
170
|
-
storybookUrl
|
|
174
|
+
storybookUrl,
|
|
175
|
+
timeoutMs
|
|
171
176
|
);
|
|
172
177
|
fs.writeFileSync(screenshotPath, buffer);
|
|
173
178
|
return { path: screenshotPath, boundingBox };
|
|
@@ -178,9 +183,12 @@ async function generateScreenshot(storyId, theme, storybookUrl = "http://localho
|
|
|
178
183
|
}
|
|
179
184
|
|
|
180
185
|
// src/screenshot-service/screenshot-service.ts
|
|
181
|
-
async function generateAllScreenshots(stories, storybookUrl = "http://localhost:6006") {
|
|
182
|
-
|
|
183
|
-
|
|
186
|
+
async function generateAllScreenshots(stories, storybookUrl = "http://localhost:6006", concurrency = 10, offset = 0, total, skipExisting = false, timeoutMs = 3e4) {
|
|
187
|
+
const displayTotal = total ?? offset + stories.length;
|
|
188
|
+
console.log(
|
|
189
|
+
`Generating screenshots for ${stories.length} stories (concurrency: ${concurrency})...`
|
|
190
|
+
);
|
|
191
|
+
const BATCH_SIZE = concurrency;
|
|
184
192
|
const batches = [];
|
|
185
193
|
for (let i = 0; i < stories.length; i += BATCH_SIZE) {
|
|
186
194
|
batches.push(stories.slice(i, i + BATCH_SIZE));
|
|
@@ -189,17 +197,24 @@ async function generateAllScreenshots(stories, storybookUrl = "http://localhost:
|
|
|
189
197
|
for (const batch of batches) {
|
|
190
198
|
await Promise.all(
|
|
191
199
|
batch.map(async (story) => {
|
|
200
|
+
if (skipExisting && screenshotExists(story.id, "light") && screenshotExists(story.id, "dark")) {
|
|
201
|
+
completed++;
|
|
202
|
+
const absoluteIndex2 = offset + completed;
|
|
203
|
+
console.log(`[${absoluteIndex2}/${displayTotal}] Skipped (exists) ${story.id}`);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
192
206
|
const [lightResult, darkResult] = await Promise.all([
|
|
193
|
-
generateScreenshot(story.id, "light", storybookUrl),
|
|
194
|
-
generateScreenshot(story.id, "dark", storybookUrl)
|
|
207
|
+
generateScreenshot(story.id, "light", storybookUrl, timeoutMs),
|
|
208
|
+
generateScreenshot(story.id, "dark", storybookUrl, timeoutMs)
|
|
195
209
|
]);
|
|
196
210
|
if (lightResult && darkResult) {
|
|
197
211
|
const fileHash = computeFileHash(story.importPath);
|
|
198
212
|
updateManifest(story.id, story.importPath, fileHash, lightResult.boundingBox);
|
|
199
213
|
}
|
|
200
214
|
completed++;
|
|
215
|
+
const absoluteIndex = offset + completed;
|
|
201
216
|
console.log(
|
|
202
|
-
`[${
|
|
217
|
+
`[${absoluteIndex}/${displayTotal}] Generated screenshots for ${story.id}`
|
|
203
218
|
);
|
|
204
219
|
})
|
|
205
220
|
);
|
|
@@ -277,7 +292,9 @@ async function startStorybook(command2, args, storybookDir) {
|
|
|
277
292
|
const storybookProcess = spawn(command2, args, {
|
|
278
293
|
cwd: storybookDir,
|
|
279
294
|
stdio: "pipe",
|
|
280
|
-
shell: true
|
|
295
|
+
shell: true,
|
|
296
|
+
detached: true
|
|
297
|
+
// Create new process group so we can kill all children
|
|
281
298
|
});
|
|
282
299
|
return new Promise((resolve3, reject) => {
|
|
283
300
|
let started = false;
|
|
@@ -342,11 +359,20 @@ async function generateScreenshots(options = {}) {
|
|
|
342
359
|
storybookProcess = await startStorybook(command2, args, storybookDir);
|
|
343
360
|
weStartedStorybook = true;
|
|
344
361
|
}
|
|
345
|
-
const
|
|
346
|
-
|
|
362
|
+
const allStories = await fetchStoryIndex(url);
|
|
363
|
+
const total = allStories.length;
|
|
364
|
+
console.log(`Found ${total} stories`);
|
|
365
|
+
const skipN = options.skip ?? 0;
|
|
366
|
+
const end = options.limit ? skipN + options.limit : total;
|
|
367
|
+
const stories = allStories.slice(skipN, end);
|
|
368
|
+
if (skipN > 0 || options.limit) {
|
|
369
|
+
console.log(
|
|
370
|
+
`Processing stories ${skipN + 1}\u2013${skipN + stories.length} of ${total}`
|
|
371
|
+
);
|
|
372
|
+
}
|
|
347
373
|
const firstStory = stories[0];
|
|
348
374
|
if (!firstStory) {
|
|
349
|
-
throw new Error("No stories found");
|
|
375
|
+
throw new Error("No stories found in selected range");
|
|
350
376
|
}
|
|
351
377
|
await warmupStorybook(url, firstStory.id);
|
|
352
378
|
await generateAllScreenshots(
|
|
@@ -354,14 +380,32 @@ async function generateScreenshots(options = {}) {
|
|
|
354
380
|
id: story.id,
|
|
355
381
|
importPath: story.importPath
|
|
356
382
|
})),
|
|
357
|
-
url
|
|
383
|
+
url,
|
|
384
|
+
options.concurrency,
|
|
385
|
+
skipN,
|
|
386
|
+
total,
|
|
387
|
+
options.skipExisting,
|
|
388
|
+
options.timeout
|
|
358
389
|
);
|
|
359
390
|
console.log("\u2705 Screenshot generation complete!");
|
|
391
|
+
} catch (error) {
|
|
392
|
+
console.error("\u274C Screenshot generation failed:", error);
|
|
393
|
+
process.exit(1);
|
|
360
394
|
} finally {
|
|
361
|
-
|
|
395
|
+
await closeBrowser();
|
|
396
|
+
if (storybookProcess && weStartedStorybook && storybookProcess.pid) {
|
|
362
397
|
console.log("\u{1F6D1} Stopping Storybook...");
|
|
363
|
-
|
|
398
|
+
try {
|
|
399
|
+
process.kill(-storybookProcess.pid, "SIGKILL");
|
|
400
|
+
} catch {
|
|
401
|
+
try {
|
|
402
|
+
storybookProcess.kill("SIGKILL");
|
|
403
|
+
} catch {
|
|
404
|
+
}
|
|
405
|
+
}
|
|
364
406
|
}
|
|
407
|
+
console.log("\u2705 Cleanup complete, exiting...");
|
|
408
|
+
process.exit(0);
|
|
365
409
|
}
|
|
366
410
|
}
|
|
367
411
|
|
|
@@ -370,13 +414,25 @@ var generateScreenshotsCommand = command({
|
|
|
370
414
|
name: "generate-screenshots",
|
|
371
415
|
desc: "Generate screenshots for all Storybook stories",
|
|
372
416
|
options: {
|
|
373
|
-
storybookDir: string().alias("d").desc("Directory containing Storybook (defaults to current directory)")
|
|
417
|
+
storybookDir: string().alias("d").desc("Directory containing Storybook (defaults to current directory)"),
|
|
418
|
+
limit: string().alias("l").desc("Limit number of stories to process"),
|
|
419
|
+
skip: string().alias("s").desc("Skip first N stories"),
|
|
420
|
+
concurrency: string().alias("c").desc("Number of concurrent screenshots (default: 10)"),
|
|
421
|
+
skipExisting: boolean().alias("e").desc("Skip stories that already have screenshots on disk"),
|
|
422
|
+
timeout: string().alias("t").desc("Timeout per screenshot in ms (default: 30000)")
|
|
374
423
|
},
|
|
375
424
|
handler: async (opts) => {
|
|
376
|
-
await generateScreenshots({
|
|
425
|
+
await generateScreenshots({
|
|
426
|
+
storybookDir: opts.storybookDir,
|
|
427
|
+
limit: opts.limit ? Number.parseInt(opts.limit, 10) : void 0,
|
|
428
|
+
skip: opts.skip ? Number.parseInt(opts.skip, 10) : void 0,
|
|
429
|
+
concurrency: opts.concurrency ? Number.parseInt(opts.concurrency, 10) : void 0,
|
|
430
|
+
skipExisting: opts.skipExisting ?? false,
|
|
431
|
+
timeout: opts.timeout ? Number.parseInt(opts.timeout, 10) : void 0
|
|
432
|
+
});
|
|
377
433
|
}
|
|
378
434
|
});
|
|
379
435
|
run([generateScreenshotsCommand], {
|
|
380
436
|
name: "onlook-storybook",
|
|
381
|
-
description: "Storybook plugin for
|
|
437
|
+
description: "Storybook plugin for Onbook - generate screenshots and more"
|
|
382
438
|
});
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import fs4, { existsSync } from 'fs';
|
|
2
2
|
import path2, { dirname, join, relative } from 'path';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
import generateModule from '@babel/generator';
|
|
@@ -78,30 +78,30 @@ var MIN_COMPONENT_HEIGHT = 280;
|
|
|
78
78
|
|
|
79
79
|
// src/utils/fileSystem/fileSystem.ts
|
|
80
80
|
function ensureCacheDirectories() {
|
|
81
|
-
if (!
|
|
82
|
-
|
|
81
|
+
if (!fs4.existsSync(CACHE_DIR)) {
|
|
82
|
+
fs4.mkdirSync(CACHE_DIR, { recursive: true });
|
|
83
83
|
}
|
|
84
|
-
if (!
|
|
85
|
-
|
|
84
|
+
if (!fs4.existsSync(SCREENSHOTS_DIR)) {
|
|
85
|
+
fs4.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
function computeFileHash(filePath) {
|
|
89
|
-
if (!
|
|
89
|
+
if (!fs4.existsSync(filePath)) {
|
|
90
90
|
return "";
|
|
91
91
|
}
|
|
92
|
-
const content =
|
|
92
|
+
const content = fs4.readFileSync(filePath, "utf-8");
|
|
93
93
|
return crypto.createHash("sha256").update(content).digest("hex");
|
|
94
94
|
}
|
|
95
95
|
function loadManifest() {
|
|
96
|
-
if (
|
|
97
|
-
const content =
|
|
96
|
+
if (fs4.existsSync(MANIFEST_PATH)) {
|
|
97
|
+
const content = fs4.readFileSync(MANIFEST_PATH, "utf-8");
|
|
98
98
|
return JSON.parse(content);
|
|
99
99
|
}
|
|
100
100
|
return { stories: {} };
|
|
101
101
|
}
|
|
102
102
|
function saveManifest(manifest) {
|
|
103
103
|
ensureCacheDirectories();
|
|
104
|
-
|
|
104
|
+
fs4.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
|
|
105
105
|
}
|
|
106
106
|
function updateManifest(storyId, sourcePath, fileHash, boundingBox) {
|
|
107
107
|
const manifest = loadManifest();
|
|
@@ -130,7 +130,7 @@ function getScreenshotPath(storyId, theme) {
|
|
|
130
130
|
const storyDir = path2.join(SCREENSHOTS_DIR, storyId);
|
|
131
131
|
return path2.join(storyDir, `${theme}.png`);
|
|
132
132
|
}
|
|
133
|
-
async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006") {
|
|
133
|
+
async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006", timeoutMs = 3e4) {
|
|
134
134
|
const browser2 = await getBrowser();
|
|
135
135
|
const context = await browser2.newContext({
|
|
136
136
|
viewport: { width, height },
|
|
@@ -139,10 +139,10 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
|
|
|
139
139
|
const page = await context.newPage();
|
|
140
140
|
try {
|
|
141
141
|
const url = `${storybookUrl}/iframe.html?id=${storyId}&viewMode=story&globals=theme:${theme}`;
|
|
142
|
-
await page.goto(url, { timeout:
|
|
143
|
-
await page.waitForLoadState("domcontentloaded");
|
|
144
|
-
await page.waitForLoadState("load");
|
|
145
|
-
await page.waitForLoadState("networkidle");
|
|
142
|
+
await page.goto(url, { timeout: timeoutMs });
|
|
143
|
+
await page.waitForLoadState("domcontentloaded", { timeout: timeoutMs });
|
|
144
|
+
await page.waitForLoadState("load", { timeout: timeoutMs });
|
|
145
|
+
await page.waitForLoadState("networkidle", { timeout: timeoutMs });
|
|
146
146
|
await page.evaluate(() => document.fonts.ready);
|
|
147
147
|
await page.evaluate(async () => {
|
|
148
148
|
const images = document.querySelectorAll("img");
|
|
@@ -205,12 +205,12 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
|
|
|
205
205
|
await context.close();
|
|
206
206
|
}
|
|
207
207
|
}
|
|
208
|
-
async function generateScreenshot(storyId, theme, storybookUrl = "http://localhost:6006") {
|
|
208
|
+
async function generateScreenshot(storyId, theme, storybookUrl = "http://localhost:6006", timeoutMs = 3e4) {
|
|
209
209
|
try {
|
|
210
210
|
ensureCacheDirectories();
|
|
211
211
|
const storyDir = path2.join(SCREENSHOTS_DIR, storyId);
|
|
212
|
-
if (!
|
|
213
|
-
|
|
212
|
+
if (!fs4.existsSync(storyDir)) {
|
|
213
|
+
fs4.mkdirSync(storyDir, { recursive: true });
|
|
214
214
|
}
|
|
215
215
|
const screenshotPath = getScreenshotPath(storyId, theme);
|
|
216
216
|
const { buffer, boundingBox } = await captureScreenshotBuffer(
|
|
@@ -218,9 +218,10 @@ async function generateScreenshot(storyId, theme, storybookUrl = "http://localho
|
|
|
218
218
|
theme,
|
|
219
219
|
VIEWPORT_WIDTH,
|
|
220
220
|
VIEWPORT_HEIGHT,
|
|
221
|
-
storybookUrl
|
|
221
|
+
storybookUrl,
|
|
222
|
+
timeoutMs
|
|
222
223
|
);
|
|
223
|
-
|
|
224
|
+
fs4.writeFileSync(screenshotPath, buffer);
|
|
224
225
|
return { path: screenshotPath, boundingBox };
|
|
225
226
|
} catch (error) {
|
|
226
227
|
console.error(`Error generating screenshot for ${storyId} (${theme}):`, error);
|
|
@@ -336,20 +337,47 @@ var DEFAULT_ALLOWED_ORIGINS = [
|
|
|
336
337
|
];
|
|
337
338
|
var serveMetadataAndScreenshots = (req, res, next) => {
|
|
338
339
|
if (req.url === "/onbook-index.json") {
|
|
340
|
+
console.log("[STORYBOOK_PLUGIN] Serving /onbook-index.json endpoint");
|
|
339
341
|
const manifestPath = path2.join(process.cwd(), ".storybook-cache", "manifest.json");
|
|
340
|
-
|
|
341
|
-
|
|
342
|
+
const cacheBuster = Date.now();
|
|
343
|
+
console.log("[STORYBOOK_PLUGIN] Fetching http://localhost:6006/index.json");
|
|
344
|
+
fetch(`http://localhost:6006/index.json?_t=${cacheBuster}`, {
|
|
345
|
+
cache: "no-store",
|
|
346
|
+
headers: {
|
|
347
|
+
"Cache-Control": "no-cache",
|
|
348
|
+
Pragma: "no-cache"
|
|
349
|
+
}
|
|
350
|
+
}).then((response) => {
|
|
351
|
+
console.log("[STORYBOOK_PLUGIN] Storybook index fetch response", {
|
|
352
|
+
status: response.status,
|
|
353
|
+
ok: response.ok,
|
|
354
|
+
statusText: response.statusText
|
|
355
|
+
});
|
|
356
|
+
return response.json();
|
|
357
|
+
}).then((indexData) => {
|
|
358
|
+
const manifest = fs4.existsSync(manifestPath) ? JSON.parse(fs4.readFileSync(manifestPath, "utf-8")) : { stories: {} };
|
|
342
359
|
const defaultBoundingBox = { width: 1920, height: 1080 };
|
|
343
360
|
for (const [storyId, entry] of Object.entries(indexData.entries || {})) {
|
|
344
361
|
const manifestEntry = manifest.stories?.[storyId];
|
|
345
362
|
entry.boundingBox = manifestEntry?.boundingBox || defaultBoundingBox;
|
|
346
363
|
}
|
|
347
364
|
indexData.meta = { storybookLocation, repoRoot };
|
|
365
|
+
console.log("[STORYBOOK_PLUGIN] Successfully enriched index.json", {
|
|
366
|
+
entryCount: Object.keys(indexData.entries || {}).length,
|
|
367
|
+
hasMetadata: !!indexData.meta
|
|
368
|
+
});
|
|
348
369
|
res.setHeader("Content-Type", "application/json");
|
|
349
370
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
371
|
+
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
|
|
372
|
+
res.setHeader("Pragma", "no-cache");
|
|
373
|
+
res.setHeader("Expires", "0");
|
|
350
374
|
res.end(JSON.stringify(indexData));
|
|
351
375
|
}).catch((error) => {
|
|
352
|
-
console.error("Failed to fetch/extend index.json
|
|
376
|
+
console.error("[STORYBOOK_PLUGIN] Failed to fetch/extend index.json", {
|
|
377
|
+
error: error instanceof Error ? error.message : String(error),
|
|
378
|
+
errorType: error instanceof Error ? error.constructor.name : typeof error,
|
|
379
|
+
stack: error instanceof Error ? error.stack : void 0
|
|
380
|
+
});
|
|
353
381
|
res.statusCode = 500;
|
|
354
382
|
res.setHeader("Content-Type", "application/json");
|
|
355
383
|
res.end(
|
|
@@ -400,25 +428,71 @@ var serveMetadataAndScreenshots = (req, res, next) => {
|
|
|
400
428
|
".storybook-cache",
|
|
401
429
|
req.url.replace("/screenshots/", "screenshots/")
|
|
402
430
|
);
|
|
403
|
-
|
|
431
|
+
const urlParts = req.url.replace("/screenshots/", "").split("/");
|
|
432
|
+
const storyId = urlParts[0];
|
|
433
|
+
const themeFile = urlParts[1];
|
|
434
|
+
const theme = themeFile?.replace(".png", "");
|
|
435
|
+
if (fs4.existsSync(screenshotPath)) {
|
|
404
436
|
res.setHeader("Content-Type", "image/png");
|
|
405
437
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
406
438
|
res.setHeader("Cache-Control", "public, max-age=3600");
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
res.statusCode = 404;
|
|
410
|
-
res.end("Screenshot not found");
|
|
439
|
+
fs4.createReadStream(screenshotPath).pipe(res);
|
|
440
|
+
return;
|
|
411
441
|
}
|
|
442
|
+
if (storyId && theme && (theme === "light" || theme === "dark")) {
|
|
443
|
+
console.log(
|
|
444
|
+
`[STORYBOOK_PLUGIN] Generating screenshot on-demand: ${storyId}/${theme}`
|
|
445
|
+
);
|
|
446
|
+
captureScreenshotBuffer(storyId, theme).then(({ buffer }) => {
|
|
447
|
+
const storyDir = path2.join(
|
|
448
|
+
process.cwd(),
|
|
449
|
+
".storybook-cache",
|
|
450
|
+
"screenshots",
|
|
451
|
+
storyId
|
|
452
|
+
);
|
|
453
|
+
if (!fs4.existsSync(storyDir)) {
|
|
454
|
+
fs4.mkdirSync(storyDir, { recursive: true });
|
|
455
|
+
}
|
|
456
|
+
fs4.writeFileSync(screenshotPath, buffer);
|
|
457
|
+
res.setHeader("Content-Type", "image/png");
|
|
458
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
459
|
+
res.setHeader("Cache-Control", "public, max-age=3600");
|
|
460
|
+
res.end(buffer);
|
|
461
|
+
}).catch((error) => {
|
|
462
|
+
console.error(
|
|
463
|
+
`[STORYBOOK_PLUGIN] Failed to generate screenshot: ${storyId}/${theme}`,
|
|
464
|
+
error
|
|
465
|
+
);
|
|
466
|
+
res.statusCode = 500;
|
|
467
|
+
res.setHeader("Content-Type", "application/json");
|
|
468
|
+
res.end(
|
|
469
|
+
JSON.stringify({
|
|
470
|
+
error: "Failed to generate screenshot",
|
|
471
|
+
details: String(error)
|
|
472
|
+
})
|
|
473
|
+
);
|
|
474
|
+
});
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
res.statusCode = 404;
|
|
478
|
+
res.end("Screenshot not found");
|
|
412
479
|
return;
|
|
413
480
|
}
|
|
414
481
|
next();
|
|
415
482
|
};
|
|
416
483
|
function storybookOnlookPlugin(options = {}) {
|
|
417
484
|
if (process.env.CHROMATIC || process.env.CI) {
|
|
485
|
+
console.log("[STORYBOOK_PLUGIN] Disabled in CI/Chromatic environment");
|
|
418
486
|
return [];
|
|
419
487
|
}
|
|
420
488
|
const port = options.port ?? 6006;
|
|
421
489
|
const allowedOrigins = [...DEFAULT_ALLOWED_ORIGINS, ...options.allowedOrigins ?? []];
|
|
490
|
+
console.log("[STORYBOOK_PLUGIN] Plugin initialized", {
|
|
491
|
+
port,
|
|
492
|
+
allowedOrigins,
|
|
493
|
+
storybookLocation,
|
|
494
|
+
repoRoot
|
|
495
|
+
});
|
|
422
496
|
const mainPlugin = {
|
|
423
497
|
name: "storybook-onlook-plugin",
|
|
424
498
|
config() {
|
|
@@ -440,9 +514,17 @@ function storybookOnlookPlugin(options = {}) {
|
|
|
440
514
|
};
|
|
441
515
|
},
|
|
442
516
|
configureServer(server) {
|
|
517
|
+
console.log("[STORYBOOK_PLUGIN] Configuring server middleware");
|
|
443
518
|
server.middlewares.use(serveMetadataAndScreenshots);
|
|
519
|
+
server.httpServer?.once("listening", () => {
|
|
520
|
+
console.log("[STORYBOOK_PLUGIN] Server is listening", {
|
|
521
|
+
port: server.config.server.port,
|
|
522
|
+
host: server.config.server.host
|
|
523
|
+
});
|
|
524
|
+
});
|
|
444
525
|
},
|
|
445
526
|
configurePreviewServer(server) {
|
|
527
|
+
console.log("[STORYBOOK_PLUGIN] Configuring preview server middleware");
|
|
446
528
|
server.middlewares.use(serveMetadataAndScreenshots);
|
|
447
529
|
},
|
|
448
530
|
handleHotUpdate: handleStoryFileChange
|
|
@@ -3,11 +3,14 @@ import 'playwright';
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Generate screenshots for all stories (parallelized for speed)
|
|
6
|
+
*
|
|
7
|
+
* @param offset - Absolute index offset for logging (e.g. skip=200 → offset=200)
|
|
8
|
+
* @param total - Total number of stories across all runs (for [n/total] logging)
|
|
6
9
|
*/
|
|
7
10
|
declare function generateAllScreenshots(stories: Array<{
|
|
8
11
|
id: string;
|
|
9
12
|
importPath: string;
|
|
10
|
-
}>, storybookUrl?: string): Promise<void>;
|
|
13
|
+
}>, storybookUrl?: string, concurrency?: number, offset?: number, total?: number, skipExisting?: boolean, timeoutMs?: number): Promise<void>;
|
|
11
14
|
|
|
12
15
|
interface BoundingBox {
|
|
13
16
|
width: number;
|
|
@@ -42,6 +45,6 @@ declare function screenshotExists(storyId: string, theme: 'light' | 'dark'): boo
|
|
|
42
45
|
/**
|
|
43
46
|
* Generate a screenshot for a story and save to disk
|
|
44
47
|
*/
|
|
45
|
-
declare function generateScreenshot(storyId: string, theme: 'light' | 'dark', storybookUrl?: string): Promise<GenerateScreenshotResult | null>;
|
|
48
|
+
declare function generateScreenshot(storyId: string, theme: 'light' | 'dark', storybookUrl?: string, timeoutMs?: number): Promise<GenerateScreenshotResult | null>;
|
|
46
49
|
|
|
47
50
|
export { type Manifest, type ScreenshotMetadata, generateAllScreenshots, generateScreenshot, getScreenshotPath, screenshotExists };
|
|
@@ -81,7 +81,7 @@ function screenshotExists(storyId, theme) {
|
|
|
81
81
|
const screenshotPath = getScreenshotPath(storyId, theme);
|
|
82
82
|
return fs.existsSync(screenshotPath);
|
|
83
83
|
}
|
|
84
|
-
async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006") {
|
|
84
|
+
async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006", timeoutMs = 3e4) {
|
|
85
85
|
const browser2 = await getBrowser();
|
|
86
86
|
const context = await browser2.newContext({
|
|
87
87
|
viewport: { width, height },
|
|
@@ -90,10 +90,10 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
|
|
|
90
90
|
const page = await context.newPage();
|
|
91
91
|
try {
|
|
92
92
|
const url = `${storybookUrl}/iframe.html?id=${storyId}&viewMode=story&globals=theme:${theme}`;
|
|
93
|
-
await page.goto(url, { timeout:
|
|
94
|
-
await page.waitForLoadState("domcontentloaded");
|
|
95
|
-
await page.waitForLoadState("load");
|
|
96
|
-
await page.waitForLoadState("networkidle");
|
|
93
|
+
await page.goto(url, { timeout: timeoutMs });
|
|
94
|
+
await page.waitForLoadState("domcontentloaded", { timeout: timeoutMs });
|
|
95
|
+
await page.waitForLoadState("load", { timeout: timeoutMs });
|
|
96
|
+
await page.waitForLoadState("networkidle", { timeout: timeoutMs });
|
|
97
97
|
await page.evaluate(() => document.fonts.ready);
|
|
98
98
|
await page.evaluate(async () => {
|
|
99
99
|
const images = document.querySelectorAll("img");
|
|
@@ -156,7 +156,7 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
|
|
|
156
156
|
await context.close();
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
|
-
async function generateScreenshot(storyId, theme, storybookUrl = "http://localhost:6006") {
|
|
159
|
+
async function generateScreenshot(storyId, theme, storybookUrl = "http://localhost:6006", timeoutMs = 3e4) {
|
|
160
160
|
try {
|
|
161
161
|
ensureCacheDirectories();
|
|
162
162
|
const storyDir = path.join(SCREENSHOTS_DIR, storyId);
|
|
@@ -169,7 +169,8 @@ async function generateScreenshot(storyId, theme, storybookUrl = "http://localho
|
|
|
169
169
|
theme,
|
|
170
170
|
VIEWPORT_WIDTH,
|
|
171
171
|
VIEWPORT_HEIGHT,
|
|
172
|
-
storybookUrl
|
|
172
|
+
storybookUrl,
|
|
173
|
+
timeoutMs
|
|
173
174
|
);
|
|
174
175
|
fs.writeFileSync(screenshotPath, buffer);
|
|
175
176
|
return { path: screenshotPath, boundingBox };
|
|
@@ -180,9 +181,12 @@ async function generateScreenshot(storyId, theme, storybookUrl = "http://localho
|
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
// src/screenshot-service/screenshot-service.ts
|
|
183
|
-
async function generateAllScreenshots(stories, storybookUrl = "http://localhost:6006") {
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
async function generateAllScreenshots(stories, storybookUrl = "http://localhost:6006", concurrency = 10, offset = 0, total, skipExisting = false, timeoutMs = 3e4) {
|
|
185
|
+
const displayTotal = total ?? offset + stories.length;
|
|
186
|
+
console.log(
|
|
187
|
+
`Generating screenshots for ${stories.length} stories (concurrency: ${concurrency})...`
|
|
188
|
+
);
|
|
189
|
+
const BATCH_SIZE = concurrency;
|
|
186
190
|
const batches = [];
|
|
187
191
|
for (let i = 0; i < stories.length; i += BATCH_SIZE) {
|
|
188
192
|
batches.push(stories.slice(i, i + BATCH_SIZE));
|
|
@@ -191,17 +195,24 @@ async function generateAllScreenshots(stories, storybookUrl = "http://localhost:
|
|
|
191
195
|
for (const batch of batches) {
|
|
192
196
|
await Promise.all(
|
|
193
197
|
batch.map(async (story) => {
|
|
198
|
+
if (skipExisting && screenshotExists(story.id, "light") && screenshotExists(story.id, "dark")) {
|
|
199
|
+
completed++;
|
|
200
|
+
const absoluteIndex2 = offset + completed;
|
|
201
|
+
console.log(`[${absoluteIndex2}/${displayTotal}] Skipped (exists) ${story.id}`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
194
204
|
const [lightResult, darkResult] = await Promise.all([
|
|
195
|
-
generateScreenshot(story.id, "light", storybookUrl),
|
|
196
|
-
generateScreenshot(story.id, "dark", storybookUrl)
|
|
205
|
+
generateScreenshot(story.id, "light", storybookUrl, timeoutMs),
|
|
206
|
+
generateScreenshot(story.id, "dark", storybookUrl, timeoutMs)
|
|
197
207
|
]);
|
|
198
208
|
if (lightResult && darkResult) {
|
|
199
209
|
const fileHash = computeFileHash(story.importPath);
|
|
200
210
|
updateManifest(story.id, story.importPath, fileHash, lightResult.boundingBox);
|
|
201
211
|
}
|
|
202
212
|
completed++;
|
|
213
|
+
const absoluteIndex = offset + completed;
|
|
203
214
|
console.log(
|
|
204
|
-
`[${
|
|
215
|
+
`[${absoluteIndex}/${displayTotal}] Generated screenshots for ${story.id}`
|
|
205
216
|
);
|
|
206
217
|
})
|
|
207
218
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlook/storybook-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"onlook-storybook": "./dist/cli/index.js"
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"typecheck": "tsc --noEmit",
|
|
30
30
|
"prepublishOnly": "bun run build && bun scripts/prepublish.ts",
|
|
31
31
|
"postpublish": "bun scripts/postpublish.ts",
|
|
32
|
-
"publish-pkg": "npm publish
|
|
32
|
+
"publish-pkg": "npm publish"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@babel/generator": "^7.26.9",
|
|
@@ -49,6 +49,9 @@
|
|
|
49
49
|
"typescript": "5.8.3",
|
|
50
50
|
"vite": "^6.3.5"
|
|
51
51
|
},
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public"
|
|
54
|
+
},
|
|
52
55
|
"peerDependencies": {
|
|
53
56
|
"vite": "^5.0.0 || ^6.0.0"
|
|
54
57
|
}
|