storybook-onbook-plugin 0.2.2 → 0.2.4
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.d.ts +1 -0
- package/dist/cli/index.js +401 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +453 -0
- package/dist/screenshot-service/index.d.ts +47 -0
- package/dist/screenshot-service/index.js +213 -0
- package/dist/screenshot-service/utils/browser/index.d.ts +12 -0
- package/dist/screenshot-service/utils/browser/index.js +25 -0
- package/package.json +25 -8
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { command, string, run } from '@drizzle-team/brocli';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import path, { resolve, join, dirname } from 'path';
|
|
5
|
+
import crypto from 'crypto';
|
|
6
|
+
import fs, { existsSync, readFileSync } from 'fs';
|
|
7
|
+
import { chromium } from 'playwright';
|
|
8
|
+
|
|
9
|
+
var CACHE_DIR = path.join(process.cwd(), ".storybook-cache");
|
|
10
|
+
var SCREENSHOTS_DIR = path.join(CACHE_DIR, "screenshots");
|
|
11
|
+
var MANIFEST_PATH = path.join(CACHE_DIR, "manifest.json");
|
|
12
|
+
var VIEWPORT_WIDTH = 1920;
|
|
13
|
+
var VIEWPORT_HEIGHT = 1080;
|
|
14
|
+
var MIN_COMPONENT_WIDTH = 420;
|
|
15
|
+
var MIN_COMPONENT_HEIGHT = 280;
|
|
16
|
+
|
|
17
|
+
// src/utils/fileSystem/fileSystem.ts
|
|
18
|
+
function ensureCacheDirectories() {
|
|
19
|
+
if (!fs.existsSync(CACHE_DIR)) {
|
|
20
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
if (!fs.existsSync(SCREENSHOTS_DIR)) {
|
|
23
|
+
fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function computeFileHash(filePath) {
|
|
27
|
+
if (!fs.existsSync(filePath)) {
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
31
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
32
|
+
}
|
|
33
|
+
function loadManifest() {
|
|
34
|
+
if (fs.existsSync(MANIFEST_PATH)) {
|
|
35
|
+
const content = fs.readFileSync(MANIFEST_PATH, "utf-8");
|
|
36
|
+
return JSON.parse(content);
|
|
37
|
+
}
|
|
38
|
+
return { stories: {} };
|
|
39
|
+
}
|
|
40
|
+
function saveManifest(manifest) {
|
|
41
|
+
ensureCacheDirectories();
|
|
42
|
+
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
|
|
43
|
+
}
|
|
44
|
+
function updateManifest(storyId, sourcePath, fileHash, boundingBox) {
|
|
45
|
+
const manifest = loadManifest();
|
|
46
|
+
manifest.stories[storyId] = {
|
|
47
|
+
fileHash,
|
|
48
|
+
lastGenerated: (/* @__PURE__ */ new Date()).toISOString(),
|
|
49
|
+
sourcePath,
|
|
50
|
+
screenshots: {
|
|
51
|
+
light: `screenshots/${storyId}/light.png`,
|
|
52
|
+
dark: `screenshots/${storyId}/dark.png`
|
|
53
|
+
},
|
|
54
|
+
boundingBox: boundingBox ?? void 0
|
|
55
|
+
};
|
|
56
|
+
saveManifest(manifest);
|
|
57
|
+
}
|
|
58
|
+
var browser = null;
|
|
59
|
+
async function getBrowser() {
|
|
60
|
+
if (!browser) {
|
|
61
|
+
browser = await chromium.launch({
|
|
62
|
+
headless: true
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return browser;
|
|
66
|
+
}
|
|
67
|
+
async function closeBrowser() {
|
|
68
|
+
if (browser) {
|
|
69
|
+
try {
|
|
70
|
+
await browser.close();
|
|
71
|
+
} catch (error) {
|
|
72
|
+
console.error("Error closing browser:", error);
|
|
73
|
+
} finally {
|
|
74
|
+
browser = null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function getScreenshotPath(storyId, theme) {
|
|
79
|
+
const storyDir = path.join(SCREENSHOTS_DIR, storyId);
|
|
80
|
+
return path.join(storyDir, `${theme}.png`);
|
|
81
|
+
}
|
|
82
|
+
async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006") {
|
|
83
|
+
const browser2 = await getBrowser();
|
|
84
|
+
const context = await browser2.newContext({
|
|
85
|
+
viewport: { width, height },
|
|
86
|
+
deviceScaleFactor: 2
|
|
87
|
+
});
|
|
88
|
+
const page = await context.newPage();
|
|
89
|
+
try {
|
|
90
|
+
const url = `${storybookUrl}/iframe.html?id=${storyId}&viewMode=story&globals=theme:${theme}`;
|
|
91
|
+
await page.goto(url, { timeout: 15e3 });
|
|
92
|
+
await page.waitForLoadState("domcontentloaded");
|
|
93
|
+
await page.waitForLoadState("load");
|
|
94
|
+
await page.waitForLoadState("networkidle");
|
|
95
|
+
await page.evaluate(() => document.fonts.ready);
|
|
96
|
+
await page.evaluate(async () => {
|
|
97
|
+
const images = document.querySelectorAll("img");
|
|
98
|
+
await Promise.all(
|
|
99
|
+
Array.from(images).map((img) => {
|
|
100
|
+
if (img.complete) return Promise.resolve();
|
|
101
|
+
return new Promise((resolve3) => {
|
|
102
|
+
img.addEventListener("load", resolve3);
|
|
103
|
+
img.addEventListener("error", resolve3);
|
|
104
|
+
});
|
|
105
|
+
})
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
const contentBounds = await page.evaluate(() => {
|
|
109
|
+
const root = document.querySelector("#storybook-root");
|
|
110
|
+
if (!root) return null;
|
|
111
|
+
const children = root.querySelectorAll("*");
|
|
112
|
+
if (children.length === 0) return null;
|
|
113
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
114
|
+
children.forEach((child) => {
|
|
115
|
+
const rect = child.getBoundingClientRect();
|
|
116
|
+
if (rect.width === 0 || rect.height === 0) return;
|
|
117
|
+
minX = Math.min(minX, rect.left);
|
|
118
|
+
minY = Math.min(minY, rect.top);
|
|
119
|
+
maxX = Math.max(maxX, rect.right);
|
|
120
|
+
maxY = Math.max(maxY, rect.bottom);
|
|
121
|
+
});
|
|
122
|
+
if (minX === Infinity) return null;
|
|
123
|
+
return {
|
|
124
|
+
x: minX,
|
|
125
|
+
y: minY,
|
|
126
|
+
width: maxX - minX,
|
|
127
|
+
height: maxY - minY
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
let screenshotBuffer;
|
|
131
|
+
let resultBoundingBox = null;
|
|
132
|
+
if (contentBounds && contentBounds.width > 0 && contentBounds.height > 0) {
|
|
133
|
+
const PADDING = 20;
|
|
134
|
+
const clippedWidth = Math.min(width, contentBounds.width + PADDING);
|
|
135
|
+
const clippedHeight = Math.min(height, contentBounds.height + PADDING);
|
|
136
|
+
resultBoundingBox = {
|
|
137
|
+
width: Math.max(MIN_COMPONENT_WIDTH, Math.round(clippedWidth)),
|
|
138
|
+
height: Math.max(MIN_COMPONENT_HEIGHT, Math.round(clippedHeight))
|
|
139
|
+
};
|
|
140
|
+
screenshotBuffer = await page.screenshot({
|
|
141
|
+
type: "png",
|
|
142
|
+
clip: {
|
|
143
|
+
x: Math.max(0, contentBounds.x - 10),
|
|
144
|
+
y: Math.max(0, contentBounds.y - 10),
|
|
145
|
+
width: clippedWidth,
|
|
146
|
+
height: clippedHeight
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
} else {
|
|
150
|
+
screenshotBuffer = await page.screenshot({ type: "png" });
|
|
151
|
+
}
|
|
152
|
+
return { buffer: screenshotBuffer, boundingBox: resultBoundingBox };
|
|
153
|
+
} finally {
|
|
154
|
+
await context.close();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function generateScreenshot(storyId, theme, storybookUrl = "http://localhost:6006") {
|
|
158
|
+
try {
|
|
159
|
+
ensureCacheDirectories();
|
|
160
|
+
const storyDir = path.join(SCREENSHOTS_DIR, storyId);
|
|
161
|
+
if (!fs.existsSync(storyDir)) {
|
|
162
|
+
fs.mkdirSync(storyDir, { recursive: true });
|
|
163
|
+
}
|
|
164
|
+
const screenshotPath = getScreenshotPath(storyId, theme);
|
|
165
|
+
const { buffer, boundingBox } = await captureScreenshotBuffer(
|
|
166
|
+
storyId,
|
|
167
|
+
theme,
|
|
168
|
+
VIEWPORT_WIDTH,
|
|
169
|
+
VIEWPORT_HEIGHT,
|
|
170
|
+
storybookUrl
|
|
171
|
+
);
|
|
172
|
+
fs.writeFileSync(screenshotPath, buffer);
|
|
173
|
+
return { path: screenshotPath, boundingBox };
|
|
174
|
+
} catch (error) {
|
|
175
|
+
console.error(`Error generating screenshot for ${storyId} (${theme}):`, error);
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// src/screenshot-service/screenshot-service.ts
|
|
181
|
+
async function generateAllScreenshots(stories, storybookUrl = "http://localhost:6006") {
|
|
182
|
+
console.log(`Generating screenshots for ${stories.length} stories...`);
|
|
183
|
+
const BATCH_SIZE = 10;
|
|
184
|
+
const batches = [];
|
|
185
|
+
for (let i = 0; i < stories.length; i += BATCH_SIZE) {
|
|
186
|
+
batches.push(stories.slice(i, i + BATCH_SIZE));
|
|
187
|
+
}
|
|
188
|
+
let completed = 0;
|
|
189
|
+
for (const batch of batches) {
|
|
190
|
+
await Promise.all(
|
|
191
|
+
batch.map(async (story) => {
|
|
192
|
+
const [lightResult, darkResult] = await Promise.all([
|
|
193
|
+
generateScreenshot(story.id, "light", storybookUrl),
|
|
194
|
+
generateScreenshot(story.id, "dark", storybookUrl)
|
|
195
|
+
]);
|
|
196
|
+
if (lightResult && darkResult) {
|
|
197
|
+
const fileHash = computeFileHash(story.importPath);
|
|
198
|
+
updateManifest(story.id, story.importPath, fileHash, lightResult.boundingBox);
|
|
199
|
+
}
|
|
200
|
+
completed++;
|
|
201
|
+
console.log(
|
|
202
|
+
`[${completed}/${stories.length}] Generated screenshots for ${story.id}`
|
|
203
|
+
);
|
|
204
|
+
})
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
await closeBrowser();
|
|
208
|
+
console.log("Screenshot generation complete!");
|
|
209
|
+
}
|
|
210
|
+
var LOCKFILES = {
|
|
211
|
+
"bun.lockb": "bun",
|
|
212
|
+
"bun.lock": "bun",
|
|
213
|
+
"pnpm-lock.yaml": "pnpm",
|
|
214
|
+
"yarn.lock": "yarn",
|
|
215
|
+
"package-lock.json": "npm"
|
|
216
|
+
};
|
|
217
|
+
function findRepoRoot(startDir) {
|
|
218
|
+
let currentDir = resolve(startDir);
|
|
219
|
+
while (true) {
|
|
220
|
+
for (const lockfile of Object.keys(LOCKFILES)) {
|
|
221
|
+
if (existsSync(join(currentDir, lockfile))) {
|
|
222
|
+
return currentDir;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const parentDir = dirname(currentDir);
|
|
226
|
+
if (parentDir === currentDir) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
currentDir = parentDir;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function detectPackageManager(repoRoot) {
|
|
233
|
+
for (const [lockfile, pm] of Object.entries(LOCKFILES)) {
|
|
234
|
+
if (existsSync(join(repoRoot, lockfile))) {
|
|
235
|
+
return pm;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return "npm";
|
|
239
|
+
}
|
|
240
|
+
function getStorybookScript(storybookDir) {
|
|
241
|
+
const packageJsonPath = join(storybookDir, "package.json");
|
|
242
|
+
if (!existsSync(packageJsonPath)) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
246
|
+
return packageJson.scripts?.storybook ?? null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// src/cli/generate-screenshots/generate-screenshots.ts
|
|
250
|
+
function getStorybookCommand(storybookDir) {
|
|
251
|
+
const script = getStorybookScript(storybookDir);
|
|
252
|
+
if (script === null) {
|
|
253
|
+
const packageJsonPath = join(storybookDir, "package.json");
|
|
254
|
+
throw new Error(
|
|
255
|
+
`No "storybook" script found in ${packageJsonPath}. Use --storybook-dir to specify the correct directory or add a storybook script.`
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
const repoRoot = findRepoRoot(storybookDir);
|
|
259
|
+
const pm = repoRoot ? detectPackageManager(repoRoot) : "npm";
|
|
260
|
+
console.log(
|
|
261
|
+
`\u{1F4E6} Detected package manager: ${pm}${repoRoot ? ` (from ${repoRoot})` : ""}`
|
|
262
|
+
);
|
|
263
|
+
const execCommand = {
|
|
264
|
+
bun: "bunx",
|
|
265
|
+
pnpm: "pnpm exec",
|
|
266
|
+
yarn: "yarn",
|
|
267
|
+
npm: "npx"
|
|
268
|
+
};
|
|
269
|
+
const scriptParts = script.split(" ").filter(Boolean);
|
|
270
|
+
return {
|
|
271
|
+
command: execCommand[pm] ?? "npx",
|
|
272
|
+
args: [...scriptParts, "--no-open"]
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
async function isStorybookRunning(url) {
|
|
276
|
+
try {
|
|
277
|
+
const response = await fetch(`${url}/index.json`, {
|
|
278
|
+
signal: AbortSignal.timeout(2e3)
|
|
279
|
+
});
|
|
280
|
+
return response.ok;
|
|
281
|
+
} catch {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
async function fetchStoryIndex(url) {
|
|
286
|
+
const indexUrl = `${url}/index.json`;
|
|
287
|
+
const response = await fetch(indexUrl);
|
|
288
|
+
if (!response.ok) {
|
|
289
|
+
throw new Error(`Failed to fetch story index: ${response.statusText}`);
|
|
290
|
+
}
|
|
291
|
+
const data = await response.json();
|
|
292
|
+
return Object.values(data.entries);
|
|
293
|
+
}
|
|
294
|
+
async function startStorybook(command2, args, storybookDir) {
|
|
295
|
+
console.log(`\u{1F680} Starting Storybook with: ${command2} ${args.join(" ")} (in ${storybookDir})`);
|
|
296
|
+
const storybookProcess = spawn(command2, args, {
|
|
297
|
+
cwd: storybookDir,
|
|
298
|
+
stdio: "pipe",
|
|
299
|
+
shell: true
|
|
300
|
+
});
|
|
301
|
+
return new Promise((resolve3, reject) => {
|
|
302
|
+
let started = false;
|
|
303
|
+
const timeout = setTimeout(() => {
|
|
304
|
+
if (!started) {
|
|
305
|
+
storybookProcess.kill();
|
|
306
|
+
reject(new Error("Storybook failed to start within 60 seconds"));
|
|
307
|
+
}
|
|
308
|
+
}, 6e4);
|
|
309
|
+
storybookProcess.stdout?.on("data", (data) => {
|
|
310
|
+
const output = data.toString();
|
|
311
|
+
if (output.includes("Local:") || output.includes("localhost:")) {
|
|
312
|
+
if (!started) {
|
|
313
|
+
started = true;
|
|
314
|
+
clearTimeout(timeout);
|
|
315
|
+
console.log("\u2705 Storybook is ready!");
|
|
316
|
+
resolve3(storybookProcess);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
storybookProcess.stderr?.on("data", (data) => {
|
|
321
|
+
const output = data.toString();
|
|
322
|
+
if (output.toLowerCase().includes("error")) {
|
|
323
|
+
console.error(output);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
storybookProcess.on("error", (error) => {
|
|
327
|
+
clearTimeout(timeout);
|
|
328
|
+
reject(error);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
async function warmupStorybook(url, firstStoryId) {
|
|
333
|
+
console.log("\u{1F525} Warming up Storybook...");
|
|
334
|
+
const browser2 = await getBrowser();
|
|
335
|
+
const context = await browser2.newContext();
|
|
336
|
+
const page = await context.newPage();
|
|
337
|
+
try {
|
|
338
|
+
const warmupUrl = `${url}/iframe.html?id=${firstStoryId}&viewMode=story`;
|
|
339
|
+
await page.goto(warmupUrl, { timeout: 15e3 });
|
|
340
|
+
await page.waitForLoadState("networkidle");
|
|
341
|
+
console.log("\u2705 Storybook warmed up");
|
|
342
|
+
} catch {
|
|
343
|
+
console.log("\u26A0\uFE0F Warmup had issues, proceeding anyway");
|
|
344
|
+
} finally {
|
|
345
|
+
await context.close();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async function generateScreenshots(options = {}) {
|
|
349
|
+
const url = "http://localhost:6006";
|
|
350
|
+
const storybookDir = resolve(options.storybookDir ?? process.cwd());
|
|
351
|
+
let storybookProcess = null;
|
|
352
|
+
let weStartedStorybook = false;
|
|
353
|
+
console.log("\u{1F4F8} Generating Storybook screenshots...");
|
|
354
|
+
console.log(`\u{1F4C2} Storybook directory: ${storybookDir}`);
|
|
355
|
+
try {
|
|
356
|
+
const alreadyRunning = await isStorybookRunning(url);
|
|
357
|
+
if (alreadyRunning) {
|
|
358
|
+
console.log("\u2705 Storybook is already running");
|
|
359
|
+
} else {
|
|
360
|
+
const { command: command2, args } = getStorybookCommand(storybookDir);
|
|
361
|
+
storybookProcess = await startStorybook(command2, args, storybookDir);
|
|
362
|
+
weStartedStorybook = true;
|
|
363
|
+
}
|
|
364
|
+
const stories = await fetchStoryIndex(url);
|
|
365
|
+
console.log(`Found ${stories.length} stories`);
|
|
366
|
+
const firstStory = stories[0];
|
|
367
|
+
if (!firstStory) {
|
|
368
|
+
throw new Error("No stories found");
|
|
369
|
+
}
|
|
370
|
+
await warmupStorybook(url, firstStory.id);
|
|
371
|
+
await generateAllScreenshots(
|
|
372
|
+
stories.map((story) => ({
|
|
373
|
+
id: story.id,
|
|
374
|
+
importPath: story.importPath
|
|
375
|
+
})),
|
|
376
|
+
url
|
|
377
|
+
);
|
|
378
|
+
console.log("\u2705 Screenshot generation complete!");
|
|
379
|
+
} finally {
|
|
380
|
+
if (storybookProcess && weStartedStorybook) {
|
|
381
|
+
console.log("\u{1F6D1} Stopping Storybook...");
|
|
382
|
+
storybookProcess.kill();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// src/cli/index.ts
|
|
388
|
+
var generateScreenshotsCommand = command({
|
|
389
|
+
name: "generate-screenshots",
|
|
390
|
+
desc: "Generate screenshots for all Storybook stories",
|
|
391
|
+
options: {
|
|
392
|
+
storybookDir: string().alias("d").desc("Directory containing Storybook (defaults to current directory)")
|
|
393
|
+
},
|
|
394
|
+
handler: async (opts) => {
|
|
395
|
+
await generateScreenshots({ storybookDir: opts.storybookDir });
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
run([generateScreenshotsCommand], {
|
|
399
|
+
name: "storybook-onbook-plugin",
|
|
400
|
+
description: "Storybook plugin for Onbook - generate screenshots and more"
|
|
401
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { PluginOption } from 'vite';
|
|
2
|
+
|
|
3
|
+
type OnlookPluginOptions = {
|
|
4
|
+
/** Storybook port (default: 6006) */
|
|
5
|
+
port?: number;
|
|
6
|
+
/** Additional allowed origins for CORS (merged with defaults) */
|
|
7
|
+
allowedOrigins?: string[];
|
|
8
|
+
};
|
|
9
|
+
declare function storybookOnlookPlugin(options?: OnlookPluginOptions): PluginOption[];
|
|
10
|
+
|
|
11
|
+
export { type OnlookPluginOptions, storybookOnlookPlugin };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import fs, { existsSync } from 'fs';
|
|
2
|
+
import path2, { dirname, join, relative } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import generateModule from '@babel/generator';
|
|
5
|
+
import { parse } from '@babel/parser';
|
|
6
|
+
import traverseModule from '@babel/traverse';
|
|
7
|
+
import * as t from '@babel/types';
|
|
8
|
+
import crypto from 'crypto';
|
|
9
|
+
import { chromium } from 'playwright';
|
|
10
|
+
|
|
11
|
+
// src/storybook-onlook-plugin.ts
|
|
12
|
+
function componentLocPlugin(options = {}) {
|
|
13
|
+
const include = options.include ?? /\.(jsx|tsx)$/;
|
|
14
|
+
const traverse = traverseModule.default ?? traverseModule;
|
|
15
|
+
const generate = generateModule.default ?? generateModule;
|
|
16
|
+
let root;
|
|
17
|
+
return {
|
|
18
|
+
name: "onbook-component-loc",
|
|
19
|
+
enforce: "pre",
|
|
20
|
+
apply: "serve",
|
|
21
|
+
configResolved(config) {
|
|
22
|
+
root = config.root;
|
|
23
|
+
},
|
|
24
|
+
transform(code, id) {
|
|
25
|
+
const filepath = id.split("?", 1)[0];
|
|
26
|
+
if (!filepath || filepath.includes("node_modules")) return null;
|
|
27
|
+
if (!include.test(filepath)) return null;
|
|
28
|
+
const ast = parse(code, {
|
|
29
|
+
sourceType: "module",
|
|
30
|
+
plugins: ["jsx", "typescript"],
|
|
31
|
+
sourceFilename: filepath
|
|
32
|
+
});
|
|
33
|
+
let mutated = false;
|
|
34
|
+
const relativePath = path2.relative(root, filepath);
|
|
35
|
+
traverse(ast, {
|
|
36
|
+
JSXElement(nodePath) {
|
|
37
|
+
const opening = nodePath.node.openingElement;
|
|
38
|
+
const element = nodePath.node;
|
|
39
|
+
if (!opening.loc || !element.loc) return;
|
|
40
|
+
const alreadyTagged = opening.attributes.some(
|
|
41
|
+
(attribute) => t.isJSXAttribute(attribute) && attribute.name.name === "data-component-file"
|
|
42
|
+
);
|
|
43
|
+
if (alreadyTagged) return;
|
|
44
|
+
opening.attributes.push(
|
|
45
|
+
t.jsxAttribute(
|
|
46
|
+
t.jsxIdentifier("data-component-file"),
|
|
47
|
+
t.stringLiteral(relativePath)
|
|
48
|
+
),
|
|
49
|
+
t.jsxAttribute(
|
|
50
|
+
t.jsxIdentifier("data-component-start"),
|
|
51
|
+
t.stringLiteral(`${element.loc.start.line}:${element.loc.start.column}`)
|
|
52
|
+
),
|
|
53
|
+
t.jsxAttribute(
|
|
54
|
+
t.jsxIdentifier("data-component-end"),
|
|
55
|
+
t.stringLiteral(`${element.loc.end.line}:${element.loc.end.column}`)
|
|
56
|
+
)
|
|
57
|
+
);
|
|
58
|
+
mutated = true;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
if (!mutated) return null;
|
|
62
|
+
const output = generate(
|
|
63
|
+
ast,
|
|
64
|
+
{ retainLines: true, sourceMaps: true, sourceFileName: id },
|
|
65
|
+
code
|
|
66
|
+
);
|
|
67
|
+
return { code: output.code, map: output.map };
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
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");
|
|
74
|
+
var VIEWPORT_WIDTH = 1920;
|
|
75
|
+
var VIEWPORT_HEIGHT = 1080;
|
|
76
|
+
var MIN_COMPONENT_WIDTH = 420;
|
|
77
|
+
var MIN_COMPONENT_HEIGHT = 280;
|
|
78
|
+
|
|
79
|
+
// src/utils/fileSystem/fileSystem.ts
|
|
80
|
+
function ensureCacheDirectories() {
|
|
81
|
+
if (!fs.existsSync(CACHE_DIR)) {
|
|
82
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
83
|
+
}
|
|
84
|
+
if (!fs.existsSync(SCREENSHOTS_DIR)) {
|
|
85
|
+
fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function computeFileHash(filePath) {
|
|
89
|
+
if (!fs.existsSync(filePath)) {
|
|
90
|
+
return "";
|
|
91
|
+
}
|
|
92
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
93
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
94
|
+
}
|
|
95
|
+
function loadManifest() {
|
|
96
|
+
if (fs.existsSync(MANIFEST_PATH)) {
|
|
97
|
+
const content = fs.readFileSync(MANIFEST_PATH, "utf-8");
|
|
98
|
+
return JSON.parse(content);
|
|
99
|
+
}
|
|
100
|
+
return { stories: {} };
|
|
101
|
+
}
|
|
102
|
+
function saveManifest(manifest) {
|
|
103
|
+
ensureCacheDirectories();
|
|
104
|
+
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
|
|
105
|
+
}
|
|
106
|
+
function updateManifest(storyId, sourcePath, fileHash, boundingBox) {
|
|
107
|
+
const manifest = loadManifest();
|
|
108
|
+
manifest.stories[storyId] = {
|
|
109
|
+
fileHash,
|
|
110
|
+
lastGenerated: (/* @__PURE__ */ new Date()).toISOString(),
|
|
111
|
+
sourcePath,
|
|
112
|
+
screenshots: {
|
|
113
|
+
light: `screenshots/${storyId}/light.png`,
|
|
114
|
+
dark: `screenshots/${storyId}/dark.png`
|
|
115
|
+
},
|
|
116
|
+
boundingBox: boundingBox ?? void 0
|
|
117
|
+
};
|
|
118
|
+
saveManifest(manifest);
|
|
119
|
+
}
|
|
120
|
+
var browser = null;
|
|
121
|
+
async function getBrowser() {
|
|
122
|
+
if (!browser) {
|
|
123
|
+
browser = await chromium.launch({
|
|
124
|
+
headless: true
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return browser;
|
|
128
|
+
}
|
|
129
|
+
function getScreenshotPath(storyId, theme) {
|
|
130
|
+
const storyDir = path2.join(SCREENSHOTS_DIR, storyId);
|
|
131
|
+
return path2.join(storyDir, `${theme}.png`);
|
|
132
|
+
}
|
|
133
|
+
async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006") {
|
|
134
|
+
const browser2 = await getBrowser();
|
|
135
|
+
const context = await browser2.newContext({
|
|
136
|
+
viewport: { width, height },
|
|
137
|
+
deviceScaleFactor: 2
|
|
138
|
+
});
|
|
139
|
+
const page = await context.newPage();
|
|
140
|
+
try {
|
|
141
|
+
const url = `${storybookUrl}/iframe.html?id=${storyId}&viewMode=story&globals=theme:${theme}`;
|
|
142
|
+
await page.goto(url, { timeout: 15e3 });
|
|
143
|
+
await page.waitForLoadState("domcontentloaded");
|
|
144
|
+
await page.waitForLoadState("load");
|
|
145
|
+
await page.waitForLoadState("networkidle");
|
|
146
|
+
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
|
+
});
|
|
159
|
+
const contentBounds = await page.evaluate(() => {
|
|
160
|
+
const root = document.querySelector("#storybook-root");
|
|
161
|
+
if (!root) return null;
|
|
162
|
+
const children = root.querySelectorAll("*");
|
|
163
|
+
if (children.length === 0) return null;
|
|
164
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
165
|
+
children.forEach((child) => {
|
|
166
|
+
const rect = child.getBoundingClientRect();
|
|
167
|
+
if (rect.width === 0 || rect.height === 0) return;
|
|
168
|
+
minX = Math.min(minX, rect.left);
|
|
169
|
+
minY = Math.min(minY, rect.top);
|
|
170
|
+
maxX = Math.max(maxX, rect.right);
|
|
171
|
+
maxY = Math.max(maxY, rect.bottom);
|
|
172
|
+
});
|
|
173
|
+
if (minX === Infinity) return null;
|
|
174
|
+
return {
|
|
175
|
+
x: minX,
|
|
176
|
+
y: minY,
|
|
177
|
+
width: maxX - minX,
|
|
178
|
+
height: maxY - minY
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
let screenshotBuffer;
|
|
182
|
+
let resultBoundingBox = null;
|
|
183
|
+
if (contentBounds && contentBounds.width > 0 && contentBounds.height > 0) {
|
|
184
|
+
const PADDING = 20;
|
|
185
|
+
const clippedWidth = Math.min(width, contentBounds.width + PADDING);
|
|
186
|
+
const clippedHeight = Math.min(height, contentBounds.height + PADDING);
|
|
187
|
+
resultBoundingBox = {
|
|
188
|
+
width: Math.max(MIN_COMPONENT_WIDTH, Math.round(clippedWidth)),
|
|
189
|
+
height: Math.max(MIN_COMPONENT_HEIGHT, Math.round(clippedHeight))
|
|
190
|
+
};
|
|
191
|
+
screenshotBuffer = await page.screenshot({
|
|
192
|
+
type: "png",
|
|
193
|
+
clip: {
|
|
194
|
+
x: Math.max(0, contentBounds.x - 10),
|
|
195
|
+
y: Math.max(0, contentBounds.y - 10),
|
|
196
|
+
width: clippedWidth,
|
|
197
|
+
height: clippedHeight
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
} else {
|
|
201
|
+
screenshotBuffer = await page.screenshot({ type: "png" });
|
|
202
|
+
}
|
|
203
|
+
return { buffer: screenshotBuffer, boundingBox: resultBoundingBox };
|
|
204
|
+
} finally {
|
|
205
|
+
await context.close();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function generateScreenshot(storyId, theme, storybookUrl = "http://localhost:6006") {
|
|
209
|
+
try {
|
|
210
|
+
ensureCacheDirectories();
|
|
211
|
+
const storyDir = path2.join(SCREENSHOTS_DIR, storyId);
|
|
212
|
+
if (!fs.existsSync(storyDir)) {
|
|
213
|
+
fs.mkdirSync(storyDir, { recursive: true });
|
|
214
|
+
}
|
|
215
|
+
const screenshotPath = getScreenshotPath(storyId, theme);
|
|
216
|
+
const { buffer, boundingBox } = await captureScreenshotBuffer(
|
|
217
|
+
storyId,
|
|
218
|
+
theme,
|
|
219
|
+
VIEWPORT_WIDTH,
|
|
220
|
+
VIEWPORT_HEIGHT,
|
|
221
|
+
storybookUrl
|
|
222
|
+
);
|
|
223
|
+
fs.writeFileSync(screenshotPath, buffer);
|
|
224
|
+
return { path: screenshotPath, boundingBox };
|
|
225
|
+
} catch (error) {
|
|
226
|
+
console.error(`Error generating screenshot for ${storyId} (${theme}):`, error);
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// src/handlers/handleStoryFileChange/handleStoryFileChange.ts
|
|
232
|
+
var cachedIndex = null;
|
|
233
|
+
var indexFetchPromise = null;
|
|
234
|
+
var pendingFiles = /* @__PURE__ */ new Set();
|
|
235
|
+
var debounceTimer = null;
|
|
236
|
+
var DEBOUNCE_MS = 500;
|
|
237
|
+
async function fetchStorybookIndex() {
|
|
238
|
+
try {
|
|
239
|
+
const response = await fetch("http://localhost:6006/index.json");
|
|
240
|
+
if (response.ok) {
|
|
241
|
+
cachedIndex = await response.json();
|
|
242
|
+
console.log("[Screenshots] Cached Storybook index");
|
|
243
|
+
}
|
|
244
|
+
} catch (error) {
|
|
245
|
+
console.error("[Screenshots] Failed to fetch Storybook index:", error);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function getStoriesForFile(filePath) {
|
|
249
|
+
if (!cachedIndex) return [];
|
|
250
|
+
const fileName = path2.basename(filePath);
|
|
251
|
+
return Object.values(cachedIndex.entries).filter((entry) => entry.type === "story" && entry.importPath.endsWith(fileName)).map((entry) => entry.id);
|
|
252
|
+
}
|
|
253
|
+
async function regenerateScreenshotsForFiles(files) {
|
|
254
|
+
await fetchStorybookIndex();
|
|
255
|
+
const allStoryIds = /* @__PURE__ */ new Set();
|
|
256
|
+
const fileToStories = /* @__PURE__ */ new Map();
|
|
257
|
+
for (const file of files) {
|
|
258
|
+
const storyIds = getStoriesForFile(file);
|
|
259
|
+
fileToStories.set(file, storyIds);
|
|
260
|
+
for (const id of storyIds) {
|
|
261
|
+
allStoryIds.add(id);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (allStoryIds.size === 0) {
|
|
265
|
+
console.log("[Screenshots] No stories found for changed files");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
console.log(
|
|
269
|
+
`[Screenshots] Regenerating ${allStoryIds.size} stories from ${files.length} files`
|
|
270
|
+
);
|
|
271
|
+
const storybookUrl = "http://localhost:6006";
|
|
272
|
+
const storyBoundingBoxes = /* @__PURE__ */ new Map();
|
|
273
|
+
await Promise.all(
|
|
274
|
+
Array.from(allStoryIds).map(async (storyId) => {
|
|
275
|
+
const [lightResult, _darkResult] = await Promise.all([
|
|
276
|
+
generateScreenshot(storyId, "light", storybookUrl),
|
|
277
|
+
generateScreenshot(storyId, "dark", storybookUrl)
|
|
278
|
+
]);
|
|
279
|
+
if (lightResult) {
|
|
280
|
+
storyBoundingBoxes.set(storyId, lightResult.boundingBox);
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
);
|
|
284
|
+
for (const [file, storyIds] of fileToStories) {
|
|
285
|
+
const fileHash = computeFileHash(file);
|
|
286
|
+
storyIds.forEach((storyId) => {
|
|
287
|
+
updateManifest(storyId, file, fileHash, storyBoundingBoxes.get(storyId));
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
console.log(`[Screenshots] \u2713 Regenerated ${allStoryIds.size} stories`);
|
|
291
|
+
}
|
|
292
|
+
function handleStoryFileChange({ file, modules }) {
|
|
293
|
+
if (file.endsWith(".stories.tsx") || file.endsWith(".stories.ts")) {
|
|
294
|
+
console.log(`[Screenshots] Story file changed: ${file}`);
|
|
295
|
+
if (!cachedIndex && !indexFetchPromise) {
|
|
296
|
+
indexFetchPromise = fetchStorybookIndex();
|
|
297
|
+
}
|
|
298
|
+
pendingFiles.add(file);
|
|
299
|
+
if (debounceTimer) {
|
|
300
|
+
clearTimeout(debounceTimer);
|
|
301
|
+
}
|
|
302
|
+
debounceTimer = setTimeout(async () => {
|
|
303
|
+
const files = Array.from(pendingFiles);
|
|
304
|
+
pendingFiles.clear();
|
|
305
|
+
debounceTimer = null;
|
|
306
|
+
try {
|
|
307
|
+
await regenerateScreenshotsForFiles(files);
|
|
308
|
+
} catch (error) {
|
|
309
|
+
console.error("[Screenshots] Error regenerating screenshots:", error);
|
|
310
|
+
}
|
|
311
|
+
}, DEBOUNCE_MS);
|
|
312
|
+
return modules;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function findGitRoot(startPath) {
|
|
316
|
+
let currentPath = startPath;
|
|
317
|
+
while (currentPath !== dirname(currentPath)) {
|
|
318
|
+
if (existsSync(join(currentPath, ".git"))) {
|
|
319
|
+
return currentPath;
|
|
320
|
+
}
|
|
321
|
+
currentPath = dirname(currentPath);
|
|
322
|
+
}
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// src/storybook-onlook-plugin.ts
|
|
327
|
+
var __dirname$1 = dirname(fileURLToPath(import.meta.url));
|
|
328
|
+
var storybookDir = join(__dirname$1, "..");
|
|
329
|
+
var gitRoot = findGitRoot(storybookDir);
|
|
330
|
+
var storybookLocation = gitRoot ? relative(gitRoot, storybookDir) : "";
|
|
331
|
+
var repoRoot = gitRoot || process.cwd();
|
|
332
|
+
var DEFAULT_ALLOWED_ORIGINS = [
|
|
333
|
+
"https://app.onlook.ai",
|
|
334
|
+
"http://localhost:3000",
|
|
335
|
+
"http://localhost:6006"
|
|
336
|
+
];
|
|
337
|
+
var serveMetadataAndScreenshots = (req, res, next) => {
|
|
338
|
+
if (req.url === "/onbook-index.json") {
|
|
339
|
+
const manifestPath = path2.join(process.cwd(), ".storybook-cache", "manifest.json");
|
|
340
|
+
fetch("http://localhost:6006/index.json").then((response) => response.json()).then((indexData) => {
|
|
341
|
+
const manifest = fs.existsSync(manifestPath) ? JSON.parse(fs.readFileSync(manifestPath, "utf-8")) : { stories: {} };
|
|
342
|
+
const defaultBoundingBox = { width: 1920, height: 1080 };
|
|
343
|
+
for (const [storyId, entry] of Object.entries(indexData.entries || {})) {
|
|
344
|
+
const manifestEntry = manifest.stories?.[storyId];
|
|
345
|
+
entry.boundingBox = manifestEntry?.boundingBox || defaultBoundingBox;
|
|
346
|
+
}
|
|
347
|
+
indexData.meta = { storybookLocation, repoRoot };
|
|
348
|
+
res.setHeader("Content-Type", "application/json");
|
|
349
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
350
|
+
res.end(JSON.stringify(indexData));
|
|
351
|
+
}).catch((error) => {
|
|
352
|
+
console.error("Failed to fetch/extend index.json:", error);
|
|
353
|
+
res.statusCode = 500;
|
|
354
|
+
res.setHeader("Content-Type", "application/json");
|
|
355
|
+
res.end(
|
|
356
|
+
JSON.stringify({ error: "Failed to fetch index", details: String(error) })
|
|
357
|
+
);
|
|
358
|
+
});
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (req.url?.startsWith("/api/capture-screenshot")) {
|
|
362
|
+
const url = new URL(req.url, "http://localhost");
|
|
363
|
+
const storyId = url.searchParams.get("storyId");
|
|
364
|
+
const theme = url.searchParams.get("theme") || "light";
|
|
365
|
+
const width = parseInt(url.searchParams.get("width") || "800", 10);
|
|
366
|
+
const height = parseInt(url.searchParams.get("height") || "600", 10);
|
|
367
|
+
if (!storyId) {
|
|
368
|
+
res.statusCode = 400;
|
|
369
|
+
res.setHeader("Content-Type", "application/json");
|
|
370
|
+
res.end(JSON.stringify({ error: "storyId is required" }));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (theme !== "light" && theme !== "dark") {
|
|
374
|
+
res.statusCode = 400;
|
|
375
|
+
res.setHeader("Content-Type", "application/json");
|
|
376
|
+
res.end(JSON.stringify({ error: 'theme must be "light" or "dark"' }));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
captureScreenshotBuffer(storyId, theme, width, height).then(({ buffer }) => {
|
|
380
|
+
res.setHeader("Content-Type", "image/png");
|
|
381
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
382
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
383
|
+
res.end(buffer);
|
|
384
|
+
}).catch((error) => {
|
|
385
|
+
console.error("Screenshot capture error:", error);
|
|
386
|
+
res.statusCode = 500;
|
|
387
|
+
res.setHeader("Content-Type", "application/json");
|
|
388
|
+
res.end(
|
|
389
|
+
JSON.stringify({
|
|
390
|
+
error: "Failed to capture screenshot",
|
|
391
|
+
details: String(error)
|
|
392
|
+
})
|
|
393
|
+
);
|
|
394
|
+
});
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (req.url?.startsWith("/screenshots/")) {
|
|
398
|
+
const screenshotPath = path2.join(
|
|
399
|
+
process.cwd(),
|
|
400
|
+
".storybook-cache",
|
|
401
|
+
req.url.replace("/screenshots/", "screenshots/")
|
|
402
|
+
);
|
|
403
|
+
if (fs.existsSync(screenshotPath)) {
|
|
404
|
+
res.setHeader("Content-Type", "image/png");
|
|
405
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
406
|
+
res.setHeader("Cache-Control", "public, max-age=3600");
|
|
407
|
+
fs.createReadStream(screenshotPath).pipe(res);
|
|
408
|
+
} else {
|
|
409
|
+
res.statusCode = 404;
|
|
410
|
+
res.end("Screenshot not found");
|
|
411
|
+
}
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
next();
|
|
415
|
+
};
|
|
416
|
+
function storybookOnlookPlugin(options = {}) {
|
|
417
|
+
if (process.env.CHROMATIC || process.env.CI) {
|
|
418
|
+
return [];
|
|
419
|
+
}
|
|
420
|
+
const port = options.port ?? 6006;
|
|
421
|
+
const allowedOrigins = [...DEFAULT_ALLOWED_ORIGINS, ...options.allowedOrigins ?? []];
|
|
422
|
+
const mainPlugin = {
|
|
423
|
+
name: "storybook-onlook-plugin",
|
|
424
|
+
config() {
|
|
425
|
+
return {
|
|
426
|
+
server: {
|
|
427
|
+
// E2B sandbox HMR configuration
|
|
428
|
+
hmr: {
|
|
429
|
+
// E2B sandboxes use HTTPS, so we need secure WebSocket
|
|
430
|
+
protocol: "wss",
|
|
431
|
+
// E2B routes through standard HTTPS port 443
|
|
432
|
+
clientPort: 443,
|
|
433
|
+
// The actual Storybook server port inside the sandbox
|
|
434
|
+
port
|
|
435
|
+
},
|
|
436
|
+
cors: {
|
|
437
|
+
origin: allowedOrigins
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
},
|
|
442
|
+
configureServer(server) {
|
|
443
|
+
server.middlewares.use(serveMetadataAndScreenshots);
|
|
444
|
+
},
|
|
445
|
+
configurePreviewServer(server) {
|
|
446
|
+
server.middlewares.use(serveMetadataAndScreenshots);
|
|
447
|
+
},
|
|
448
|
+
handleHotUpdate: handleStoryFileChange
|
|
449
|
+
};
|
|
450
|
+
return [componentLocPlugin(), mainPlugin];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export { storybookOnlookPlugin };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export { closeBrowser } from './utils/browser/index.js';
|
|
2
|
+
import 'playwright';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generate screenshots for all stories (parallelized for speed)
|
|
6
|
+
*/
|
|
7
|
+
declare function generateAllScreenshots(stories: Array<{
|
|
8
|
+
id: string;
|
|
9
|
+
importPath: string;
|
|
10
|
+
}>, storybookUrl?: string): Promise<void>;
|
|
11
|
+
|
|
12
|
+
interface BoundingBox {
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
}
|
|
16
|
+
interface ScreenshotMetadata {
|
|
17
|
+
fileHash: string;
|
|
18
|
+
lastGenerated: string;
|
|
19
|
+
sourcePath: string;
|
|
20
|
+
screenshots: {
|
|
21
|
+
light: string;
|
|
22
|
+
dark: string;
|
|
23
|
+
};
|
|
24
|
+
boundingBox?: BoundingBox;
|
|
25
|
+
}
|
|
26
|
+
interface Manifest {
|
|
27
|
+
stories: Record<string, ScreenshotMetadata>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface GenerateScreenshotResult {
|
|
31
|
+
path: string;
|
|
32
|
+
boundingBox: BoundingBox | null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Get screenshot file path
|
|
36
|
+
*/
|
|
37
|
+
declare function getScreenshotPath(storyId: string, theme: 'light' | 'dark'): string;
|
|
38
|
+
/**
|
|
39
|
+
* Check if screenshot exists
|
|
40
|
+
*/
|
|
41
|
+
declare function screenshotExists(storyId: string, theme: 'light' | 'dark'): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Generate a screenshot for a story and save to disk
|
|
44
|
+
*/
|
|
45
|
+
declare function generateScreenshot(storyId: string, theme: 'light' | 'dark', storybookUrl?: string): Promise<GenerateScreenshotResult | null>;
|
|
46
|
+
|
|
47
|
+
export { type Manifest, type ScreenshotMetadata, generateAllScreenshots, generateScreenshot, getScreenshotPath, screenshotExists };
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { chromium } from 'playwright';
|
|
5
|
+
|
|
6
|
+
// src/utils/fileSystem/fileSystem.ts
|
|
7
|
+
var CACHE_DIR = path.join(process.cwd(), ".storybook-cache");
|
|
8
|
+
var SCREENSHOTS_DIR = path.join(CACHE_DIR, "screenshots");
|
|
9
|
+
var MANIFEST_PATH = path.join(CACHE_DIR, "manifest.json");
|
|
10
|
+
var VIEWPORT_WIDTH = 1920;
|
|
11
|
+
var VIEWPORT_HEIGHT = 1080;
|
|
12
|
+
var MIN_COMPONENT_WIDTH = 420;
|
|
13
|
+
var MIN_COMPONENT_HEIGHT = 280;
|
|
14
|
+
|
|
15
|
+
// src/utils/fileSystem/fileSystem.ts
|
|
16
|
+
function ensureCacheDirectories() {
|
|
17
|
+
if (!fs.existsSync(CACHE_DIR)) {
|
|
18
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
if (!fs.existsSync(SCREENSHOTS_DIR)) {
|
|
21
|
+
fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function computeFileHash(filePath) {
|
|
25
|
+
if (!fs.existsSync(filePath)) {
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
29
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
30
|
+
}
|
|
31
|
+
function loadManifest() {
|
|
32
|
+
if (fs.existsSync(MANIFEST_PATH)) {
|
|
33
|
+
const content = fs.readFileSync(MANIFEST_PATH, "utf-8");
|
|
34
|
+
return JSON.parse(content);
|
|
35
|
+
}
|
|
36
|
+
return { stories: {} };
|
|
37
|
+
}
|
|
38
|
+
function saveManifest(manifest) {
|
|
39
|
+
ensureCacheDirectories();
|
|
40
|
+
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
|
|
41
|
+
}
|
|
42
|
+
function updateManifest(storyId, sourcePath, fileHash, boundingBox) {
|
|
43
|
+
const manifest = loadManifest();
|
|
44
|
+
manifest.stories[storyId] = {
|
|
45
|
+
fileHash,
|
|
46
|
+
lastGenerated: (/* @__PURE__ */ new Date()).toISOString(),
|
|
47
|
+
sourcePath,
|
|
48
|
+
screenshots: {
|
|
49
|
+
light: `screenshots/${storyId}/light.png`,
|
|
50
|
+
dark: `screenshots/${storyId}/dark.png`
|
|
51
|
+
},
|
|
52
|
+
boundingBox: boundingBox ?? void 0
|
|
53
|
+
};
|
|
54
|
+
saveManifest(manifest);
|
|
55
|
+
}
|
|
56
|
+
var browser = null;
|
|
57
|
+
async function getBrowser() {
|
|
58
|
+
if (!browser) {
|
|
59
|
+
browser = await chromium.launch({
|
|
60
|
+
headless: true
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return browser;
|
|
64
|
+
}
|
|
65
|
+
async function closeBrowser() {
|
|
66
|
+
if (browser) {
|
|
67
|
+
try {
|
|
68
|
+
await browser.close();
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error("Error closing browser:", error);
|
|
71
|
+
} finally {
|
|
72
|
+
browser = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function getScreenshotPath(storyId, theme) {
|
|
77
|
+
const storyDir = path.join(SCREENSHOTS_DIR, storyId);
|
|
78
|
+
return path.join(storyDir, `${theme}.png`);
|
|
79
|
+
}
|
|
80
|
+
function screenshotExists(storyId, theme) {
|
|
81
|
+
const screenshotPath = getScreenshotPath(storyId, theme);
|
|
82
|
+
return fs.existsSync(screenshotPath);
|
|
83
|
+
}
|
|
84
|
+
async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006") {
|
|
85
|
+
const browser2 = await getBrowser();
|
|
86
|
+
const context = await browser2.newContext({
|
|
87
|
+
viewport: { width, height },
|
|
88
|
+
deviceScaleFactor: 2
|
|
89
|
+
});
|
|
90
|
+
const page = await context.newPage();
|
|
91
|
+
try {
|
|
92
|
+
const url = `${storybookUrl}/iframe.html?id=${storyId}&viewMode=story&globals=theme:${theme}`;
|
|
93
|
+
await page.goto(url, { timeout: 15e3 });
|
|
94
|
+
await page.waitForLoadState("domcontentloaded");
|
|
95
|
+
await page.waitForLoadState("load");
|
|
96
|
+
await page.waitForLoadState("networkidle");
|
|
97
|
+
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
|
+
});
|
|
110
|
+
const contentBounds = await page.evaluate(() => {
|
|
111
|
+
const root = document.querySelector("#storybook-root");
|
|
112
|
+
if (!root) return null;
|
|
113
|
+
const children = root.querySelectorAll("*");
|
|
114
|
+
if (children.length === 0) return null;
|
|
115
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
116
|
+
children.forEach((child) => {
|
|
117
|
+
const rect = child.getBoundingClientRect();
|
|
118
|
+
if (rect.width === 0 || rect.height === 0) return;
|
|
119
|
+
minX = Math.min(minX, rect.left);
|
|
120
|
+
minY = Math.min(minY, rect.top);
|
|
121
|
+
maxX = Math.max(maxX, rect.right);
|
|
122
|
+
maxY = Math.max(maxY, rect.bottom);
|
|
123
|
+
});
|
|
124
|
+
if (minX === Infinity) return null;
|
|
125
|
+
return {
|
|
126
|
+
x: minX,
|
|
127
|
+
y: minY,
|
|
128
|
+
width: maxX - minX,
|
|
129
|
+
height: maxY - minY
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
let screenshotBuffer;
|
|
133
|
+
let resultBoundingBox = null;
|
|
134
|
+
if (contentBounds && contentBounds.width > 0 && contentBounds.height > 0) {
|
|
135
|
+
const PADDING = 20;
|
|
136
|
+
const clippedWidth = Math.min(width, contentBounds.width + PADDING);
|
|
137
|
+
const clippedHeight = Math.min(height, contentBounds.height + PADDING);
|
|
138
|
+
resultBoundingBox = {
|
|
139
|
+
width: Math.max(MIN_COMPONENT_WIDTH, Math.round(clippedWidth)),
|
|
140
|
+
height: Math.max(MIN_COMPONENT_HEIGHT, Math.round(clippedHeight))
|
|
141
|
+
};
|
|
142
|
+
screenshotBuffer = await page.screenshot({
|
|
143
|
+
type: "png",
|
|
144
|
+
clip: {
|
|
145
|
+
x: Math.max(0, contentBounds.x - 10),
|
|
146
|
+
y: Math.max(0, contentBounds.y - 10),
|
|
147
|
+
width: clippedWidth,
|
|
148
|
+
height: clippedHeight
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
} else {
|
|
152
|
+
screenshotBuffer = await page.screenshot({ type: "png" });
|
|
153
|
+
}
|
|
154
|
+
return { buffer: screenshotBuffer, boundingBox: resultBoundingBox };
|
|
155
|
+
} finally {
|
|
156
|
+
await context.close();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async function generateScreenshot(storyId, theme, storybookUrl = "http://localhost:6006") {
|
|
160
|
+
try {
|
|
161
|
+
ensureCacheDirectories();
|
|
162
|
+
const storyDir = path.join(SCREENSHOTS_DIR, storyId);
|
|
163
|
+
if (!fs.existsSync(storyDir)) {
|
|
164
|
+
fs.mkdirSync(storyDir, { recursive: true });
|
|
165
|
+
}
|
|
166
|
+
const screenshotPath = getScreenshotPath(storyId, theme);
|
|
167
|
+
const { buffer, boundingBox } = await captureScreenshotBuffer(
|
|
168
|
+
storyId,
|
|
169
|
+
theme,
|
|
170
|
+
VIEWPORT_WIDTH,
|
|
171
|
+
VIEWPORT_HEIGHT,
|
|
172
|
+
storybookUrl
|
|
173
|
+
);
|
|
174
|
+
fs.writeFileSync(screenshotPath, buffer);
|
|
175
|
+
return { path: screenshotPath, boundingBox };
|
|
176
|
+
} catch (error) {
|
|
177
|
+
console.error(`Error generating screenshot for ${storyId} (${theme}):`, error);
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/screenshot-service/screenshot-service.ts
|
|
183
|
+
async function generateAllScreenshots(stories, storybookUrl = "http://localhost:6006") {
|
|
184
|
+
console.log(`Generating screenshots for ${stories.length} stories...`);
|
|
185
|
+
const BATCH_SIZE = 10;
|
|
186
|
+
const batches = [];
|
|
187
|
+
for (let i = 0; i < stories.length; i += BATCH_SIZE) {
|
|
188
|
+
batches.push(stories.slice(i, i + BATCH_SIZE));
|
|
189
|
+
}
|
|
190
|
+
let completed = 0;
|
|
191
|
+
for (const batch of batches) {
|
|
192
|
+
await Promise.all(
|
|
193
|
+
batch.map(async (story) => {
|
|
194
|
+
const [lightResult, darkResult] = await Promise.all([
|
|
195
|
+
generateScreenshot(story.id, "light", storybookUrl),
|
|
196
|
+
generateScreenshot(story.id, "dark", storybookUrl)
|
|
197
|
+
]);
|
|
198
|
+
if (lightResult && darkResult) {
|
|
199
|
+
const fileHash = computeFileHash(story.importPath);
|
|
200
|
+
updateManifest(story.id, story.importPath, fileHash, lightResult.boundingBox);
|
|
201
|
+
}
|
|
202
|
+
completed++;
|
|
203
|
+
console.log(
|
|
204
|
+
`[${completed}/${stories.length}] Generated screenshots for ${story.id}`
|
|
205
|
+
);
|
|
206
|
+
})
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
await closeBrowser();
|
|
210
|
+
console.log("Screenshot generation complete!");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export { closeBrowser, generateAllScreenshots, generateScreenshot, getScreenshotPath, screenshotExists };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Browser } from 'playwright';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Initialize browser instance
|
|
5
|
+
*/
|
|
6
|
+
declare function getBrowser(): Promise<Browser>;
|
|
7
|
+
/**
|
|
8
|
+
* Close browser instance
|
|
9
|
+
*/
|
|
10
|
+
declare function closeBrowser(): Promise<void>;
|
|
11
|
+
|
|
12
|
+
export { closeBrowser, getBrowser };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
|
|
3
|
+
// src/screenshot-service/utils/browser/browser.ts
|
|
4
|
+
var browser = null;
|
|
5
|
+
async function getBrowser() {
|
|
6
|
+
if (!browser) {
|
|
7
|
+
browser = await chromium.launch({
|
|
8
|
+
headless: true
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
return browser;
|
|
12
|
+
}
|
|
13
|
+
async function closeBrowser() {
|
|
14
|
+
if (browser) {
|
|
15
|
+
try {
|
|
16
|
+
await browser.close();
|
|
17
|
+
} catch (error) {
|
|
18
|
+
console.error("Error closing browser:", error);
|
|
19
|
+
} finally {
|
|
20
|
+
browser = null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export { closeBrowser, getBrowser };
|
package/package.json
CHANGED
|
@@ -1,22 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "storybook-onbook-plugin",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"storybook-onbook-plugin": "./dist/cli/index.js"
|
|
7
7
|
},
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
|
-
"types": "./
|
|
11
|
-
"
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"default": "./src/index.ts"
|
|
12
12
|
},
|
|
13
13
|
"./screenshot-service": {
|
|
14
|
-
"types": "./
|
|
15
|
-
"
|
|
14
|
+
"types": "./src/screenshot-service/index.ts",
|
|
15
|
+
"default": "./src/screenshot-service/index.ts"
|
|
16
16
|
},
|
|
17
17
|
"./screenshot-service/browser": {
|
|
18
|
-
"types": "./
|
|
19
|
-
"
|
|
18
|
+
"types": "./src/screenshot-service/utils/browser/index.ts",
|
|
19
|
+
"default": "./src/screenshot-service/utils/browser/index.ts"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"import": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./screenshot-service": {
|
|
29
|
+
"types": "./dist/screenshot-service/index.d.ts",
|
|
30
|
+
"import": "./dist/screenshot-service/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./screenshot-service/browser": {
|
|
33
|
+
"types": "./dist/screenshot-service/utils/browser/index.d.ts",
|
|
34
|
+
"import": "./dist/screenshot-service/utils/browser/index.js"
|
|
35
|
+
}
|
|
20
36
|
}
|
|
21
37
|
},
|
|
22
38
|
"files": [
|
|
@@ -24,7 +40,7 @@
|
|
|
24
40
|
"README.md"
|
|
25
41
|
],
|
|
26
42
|
"scripts": {
|
|
27
|
-
"build": "
|
|
43
|
+
"build": "tsup",
|
|
28
44
|
"clean": "git clean -xdf .cache .turbo dist node_modules",
|
|
29
45
|
"typecheck": "tsc --noEmit",
|
|
30
46
|
"prepublishOnly": "bun run build",
|
|
@@ -44,6 +60,7 @@
|
|
|
44
60
|
"@types/babel__traverse": "^7.20.6",
|
|
45
61
|
"@types/node": "^22.15.32",
|
|
46
62
|
"bun-types": "^1.3.5",
|
|
63
|
+
"tsup": "^8.5.1",
|
|
47
64
|
"typescript": "5.8.3",
|
|
48
65
|
"vite": "^6.3.5"
|
|
49
66
|
},
|