@lioneltay/component-shot 0.1.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/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +310 -0
- package/dist/build-types.d.ts +15 -0
- package/dist/build-types.d.ts.map +1 -0
- package/dist/build-types.js +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +3 -0
- package/dist/gallery.d.ts +38 -0
- package/dist/gallery.d.ts.map +1 -0
- package/dist/gallery.js +1939 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +680 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +247 -0
- package/dist/rspack-runner.d.ts +2 -0
- package/dist/rspack-runner.d.ts.map +1 -0
- package/dist/rspack-runner.js +295 -0
- package/dist/rspack.d.ts +11 -0
- package/dist/rspack.d.ts.map +1 -0
- package/dist/rspack.js +11 -0
- package/dist/runtime/default-setup.d.ts +4 -0
- package/dist/runtime/default-setup.d.ts.map +1 -0
- package/dist/runtime/default-setup.js +2 -0
- package/dist/runtime/entry.d.ts +8 -0
- package/dist/runtime/entry.d.ts.map +1 -0
- package/dist/runtime/entry.js +76 -0
- package/dist/runtime/types.d.ts +23 -0
- package/dist/runtime/types.d.ts.map +1 -0
- package/dist/runtime/types.js +1 -0
- package/dist/skill.d.ts +17 -0
- package/dist/skill.d.ts.map +1 -0
- package/dist/skill.js +217 -0
- package/package.json +78 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import http from 'node:http';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { runComponentShotGalleryCli } from './gallery.js';
|
|
8
|
+
import { createRspackBuild } from './rspack.js';
|
|
9
|
+
import { runComponentShotSkillCli } from './skill.js';
|
|
10
|
+
export { createRspackBuild } from './rspack.js';
|
|
11
|
+
export { installComponentShotSkill, runComponentShotSkillCli } from './skill.js';
|
|
12
|
+
const defaultOptions = {
|
|
13
|
+
debug: false,
|
|
14
|
+
errorGlobal: '__COMPONENT_SHOT_ERROR__',
|
|
15
|
+
fullPage: false,
|
|
16
|
+
keepTemp: false,
|
|
17
|
+
outputDirName: 'component-shots',
|
|
18
|
+
readyGlobal: '__COMPONENT_SHOT_READY__',
|
|
19
|
+
save: false,
|
|
20
|
+
screenshotsDir: 'component-shot/screenshots',
|
|
21
|
+
selector: '[data-component-shot-root]',
|
|
22
|
+
tempDirPrefix: 'component-shot-',
|
|
23
|
+
timeoutMs: 15_000,
|
|
24
|
+
viewport: {
|
|
25
|
+
height: 900,
|
|
26
|
+
width: 1440,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
const defaultCliEnv = {
|
|
30
|
+
publicDirEnv: 'COMPONENT_SHOT_PUBLIC_DIR',
|
|
31
|
+
scenarioDir: 'component-shot/scenarios',
|
|
32
|
+
scenarioEnv: 'COMPONENT_SHOT_SCENARIO',
|
|
33
|
+
};
|
|
34
|
+
const defaultSetupPaths = [
|
|
35
|
+
'component-shot/setup.tsx',
|
|
36
|
+
'component-shot/setup.ts',
|
|
37
|
+
'component-shot/setup.jsx',
|
|
38
|
+
'component-shot/setup.js',
|
|
39
|
+
];
|
|
40
|
+
const setupFilenames = ['setup.tsx', 'setup.ts', 'setup.jsx', 'setup.js'];
|
|
41
|
+
const contentTypes = {
|
|
42
|
+
'.css': 'text/css; charset=utf-8',
|
|
43
|
+
'.gif': 'image/gif',
|
|
44
|
+
'.html': 'text/html; charset=utf-8',
|
|
45
|
+
'.jpeg': 'image/jpeg',
|
|
46
|
+
'.jpg': 'image/jpeg',
|
|
47
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
48
|
+
'.json': 'application/json; charset=utf-8',
|
|
49
|
+
'.map': 'application/json; charset=utf-8',
|
|
50
|
+
'.png': 'image/png',
|
|
51
|
+
'.svg': 'image/svg+xml',
|
|
52
|
+
'.webp': 'image/webp',
|
|
53
|
+
};
|
|
54
|
+
const readFlagValue = (args, index, flag) => {
|
|
55
|
+
const inlineValue = flag.includes('=') ? flag.slice(flag.indexOf('=') + 1) : undefined;
|
|
56
|
+
if (inlineValue) {
|
|
57
|
+
return [inlineValue, index];
|
|
58
|
+
}
|
|
59
|
+
const value = args[index + 1];
|
|
60
|
+
if (!value || value.startsWith('--')) {
|
|
61
|
+
throw new Error(`Missing value for ${flag}`);
|
|
62
|
+
}
|
|
63
|
+
return [value, index + 1];
|
|
64
|
+
};
|
|
65
|
+
const parseViewport = (value) => {
|
|
66
|
+
const match = value.match(/^(\d+)x(\d+)$/i);
|
|
67
|
+
if (!match) {
|
|
68
|
+
throw new Error(`Invalid viewport "${value}". Use WIDTHxHEIGHT, for example 1440x900.`);
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
height: Number(match[2]),
|
|
72
|
+
width: Number(match[1]),
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
const createUsage = (usageCommand) => `Usage:
|
|
76
|
+
${usageCommand}
|
|
77
|
+
component-shot gallery [options]
|
|
78
|
+
component-shot skill [options]
|
|
79
|
+
|
|
80
|
+
Options:
|
|
81
|
+
--scenario <path> Scenario module to render. With --source, writes source to this path.
|
|
82
|
+
--source <tsx> Write a scenario module source string before capture.
|
|
83
|
+
--name <name> Scenario filename stem for --source when --scenario is omitted.
|
|
84
|
+
--scenario-dir <path> Directory for --source scenarios. Defaults to component-shot/scenarios.
|
|
85
|
+
--overwrite Allow --source to replace an existing scenario file.
|
|
86
|
+
--setup <path> App setup module exporting providers. Defaults to component-shot/setup.* when present.
|
|
87
|
+
--build-command <command> Shell build command escape hatch. Defaults to built-in Rspack.
|
|
88
|
+
--output <path> PNG output path. Defaults to a temp PNG path.
|
|
89
|
+
--save Write latest.png and history/<timestamp>.png for the scenario.
|
|
90
|
+
--save-name <name> Name to use under the screenshots directory. Defaults to scenario filename.
|
|
91
|
+
--screenshots-dir <path> Directory used by --save. Defaults to component-shot/screenshots.
|
|
92
|
+
--selector <selector> Element selector to screenshot. Defaults to [data-component-shot-root].
|
|
93
|
+
--viewport <WxH> Browser viewport. Defaults to 1440x900.
|
|
94
|
+
--wait-for <selector> Extra selector to wait for before capture.
|
|
95
|
+
--timeout <ms> Navigation/render timeout. Defaults to 15000.
|
|
96
|
+
--browser-channel <id> Optional system browser channel, for example chrome.
|
|
97
|
+
--ready-global <name> Window global that becomes true when rendering is ready.
|
|
98
|
+
--error-global <name> Window global containing a render error message.
|
|
99
|
+
--scenario-env <name> Env var used for the scenario path in --build-command.
|
|
100
|
+
--public-dir-env <name> Env var used for the temp public dir in --build-command.
|
|
101
|
+
--cwd <path> Working directory. Defaults to the current directory.
|
|
102
|
+
--full-page Capture the whole page instead of the selector.
|
|
103
|
+
--json Print machine-readable JSON.
|
|
104
|
+
--debug Print build/browser diagnostics.
|
|
105
|
+
--keep-temp Keep the temporary bundle directory.`;
|
|
106
|
+
const parseCliArgs = ({ argv, defaults, usageCommand, }) => {
|
|
107
|
+
const options = {
|
|
108
|
+
...defaultOptions,
|
|
109
|
+
...defaultCliEnv,
|
|
110
|
+
...defaults,
|
|
111
|
+
viewport: {
|
|
112
|
+
...defaultOptions.viewport,
|
|
113
|
+
...defaults?.viewport,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
const usage = createUsage(usageCommand);
|
|
117
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
118
|
+
const arg = argv[index];
|
|
119
|
+
const flag = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg;
|
|
120
|
+
switch (flag) {
|
|
121
|
+
case '--':
|
|
122
|
+
break;
|
|
123
|
+
case '--browser-channel': {
|
|
124
|
+
const [value, nextIndex] = readFlagValue(argv, index, arg);
|
|
125
|
+
options.browserChannel = value;
|
|
126
|
+
index = nextIndex;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
case '--build-command': {
|
|
130
|
+
const [value, nextIndex] = readFlagValue(argv, index, arg);
|
|
131
|
+
options.buildCommand = value;
|
|
132
|
+
index = nextIndex;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case '--cwd': {
|
|
136
|
+
const [value, nextIndex] = readFlagValue(argv, index, arg);
|
|
137
|
+
options.cwd = value;
|
|
138
|
+
index = nextIndex;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case '--debug':
|
|
142
|
+
options.debug = true;
|
|
143
|
+
break;
|
|
144
|
+
case '--error-global': {
|
|
145
|
+
const [value, nextIndex] = readFlagValue(argv, index, arg);
|
|
146
|
+
options.errorGlobal = value;
|
|
147
|
+
index = nextIndex;
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
case '--full-page':
|
|
151
|
+
options.fullPage = true;
|
|
152
|
+
break;
|
|
153
|
+
case '--help':
|
|
154
|
+
case '-h':
|
|
155
|
+
process.stdout.write(`${usage}\n`);
|
|
156
|
+
process.exit(0);
|
|
157
|
+
break;
|
|
158
|
+
case '--json':
|
|
159
|
+
options.json = true;
|
|
160
|
+
break;
|
|
161
|
+
case '--keep-temp':
|
|
162
|
+
options.keepTemp = true;
|
|
163
|
+
break;
|
|
164
|
+
case '--name': {
|
|
165
|
+
const [value, nextIndex] = readFlagValue(argv, index, arg);
|
|
166
|
+
options.name = value;
|
|
167
|
+
index = nextIndex;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
case '--output': {
|
|
171
|
+
const [value, nextIndex] = readFlagValue(argv, index, arg);
|
|
172
|
+
options.output = value;
|
|
173
|
+
index = nextIndex;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
case '--overwrite':
|
|
177
|
+
options.overwrite = true;
|
|
178
|
+
break;
|
|
179
|
+
case '--public-dir-env': {
|
|
180
|
+
const [value, nextIndex] = readFlagValue(argv, index, arg);
|
|
181
|
+
options.publicDirEnv = value;
|
|
182
|
+
index = nextIndex;
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
case '--ready-global': {
|
|
186
|
+
const [value, nextIndex] = readFlagValue(argv, index, arg);
|
|
187
|
+
options.readyGlobal = value;
|
|
188
|
+
index = nextIndex;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
case '--save':
|
|
192
|
+
options.save = true;
|
|
193
|
+
break;
|
|
194
|
+
case '--save-name': {
|
|
195
|
+
const [value, nextIndex] = readFlagValue(argv, index, arg);
|
|
196
|
+
options.saveName = value;
|
|
197
|
+
index = nextIndex;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
case '--scenario': {
|
|
201
|
+
const [value, nextIndex] = readFlagValue(argv, index, arg);
|
|
202
|
+
options.scenario = value;
|
|
203
|
+
index = nextIndex;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
case '--scenario-dir': {
|
|
207
|
+
const [value, nextIndex] = readFlagValue(argv, index, arg);
|
|
208
|
+
options.scenarioDir = value;
|
|
209
|
+
index = nextIndex;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case '--screenshots-dir': {
|
|
213
|
+
const [value, nextIndex] = readFlagValue(argv, index, arg);
|
|
214
|
+
options.screenshotsDir = value;
|
|
215
|
+
index = nextIndex;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
case '--scenario-env': {
|
|
219
|
+
const [value, nextIndex] = readFlagValue(argv, index, arg);
|
|
220
|
+
options.scenarioEnv = value;
|
|
221
|
+
index = nextIndex;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case '--setup': {
|
|
225
|
+
const [value, nextIndex] = readFlagValue(argv, index, arg);
|
|
226
|
+
options.setup = value;
|
|
227
|
+
index = nextIndex;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
case '--source': {
|
|
231
|
+
const [value, nextIndex] = readFlagValue(argv, index, arg);
|
|
232
|
+
options.source = value;
|
|
233
|
+
index = nextIndex;
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
case '--selector': {
|
|
237
|
+
const [value, nextIndex] = readFlagValue(argv, index, arg);
|
|
238
|
+
options.selector = value;
|
|
239
|
+
index = nextIndex;
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
case '--timeout': {
|
|
243
|
+
const [value, nextIndex] = readFlagValue(argv, index, arg);
|
|
244
|
+
options.timeoutMs = Number(value);
|
|
245
|
+
index = nextIndex;
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
case '--viewport': {
|
|
249
|
+
const [value, nextIndex] = readFlagValue(argv, index, arg);
|
|
250
|
+
options.viewport = parseViewport(value);
|
|
251
|
+
index = nextIndex;
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
case '--wait-for': {
|
|
255
|
+
const [value, nextIndex] = readFlagValue(argv, index, arg);
|
|
256
|
+
options.waitFor = value;
|
|
257
|
+
index = nextIndex;
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
default:
|
|
261
|
+
throw new Error(`Unknown option "${arg}"\n\n${usage}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (!options.scenario && !options.source) {
|
|
265
|
+
throw new Error(`Missing required --scenario or --source option\n\n${usage}`);
|
|
266
|
+
}
|
|
267
|
+
const timeoutMs = options.timeoutMs ?? defaultOptions.timeoutMs;
|
|
268
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
269
|
+
throw new Error('--timeout must be a positive number of milliseconds');
|
|
270
|
+
}
|
|
271
|
+
options.timeoutMs = timeoutMs;
|
|
272
|
+
return options;
|
|
273
|
+
};
|
|
274
|
+
const sanitizeFilename = (value) => value
|
|
275
|
+
.replace(/[^a-z0-9_.-]+/gi, '-')
|
|
276
|
+
.replace(/^-+|-+$/g, '')
|
|
277
|
+
.toLowerCase() || 'component-shot';
|
|
278
|
+
const createTimestamp = () => new Date().toISOString().replace(/[:.]/g, '-');
|
|
279
|
+
const pathExists = async (filePath) => {
|
|
280
|
+
try {
|
|
281
|
+
await fs.access(filePath);
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
const code = typeof error === 'object' && error && 'code' in error ? error.code : undefined;
|
|
286
|
+
if (code === 'ENOENT') {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
const findComponentShotDir = (scenarioPath) => {
|
|
293
|
+
let current = path.dirname(scenarioPath);
|
|
294
|
+
while (true) {
|
|
295
|
+
if (path.basename(current) === 'component-shot') {
|
|
296
|
+
return current;
|
|
297
|
+
}
|
|
298
|
+
const parent = path.dirname(current);
|
|
299
|
+
if (parent === current) {
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
current = parent;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
const findSetupPath = async (componentShotDir) => {
|
|
306
|
+
for (const filename of setupFilenames) {
|
|
307
|
+
const candidate = path.join(componentShotDir, filename);
|
|
308
|
+
if (await pathExists(candidate)) {
|
|
309
|
+
return candidate;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return undefined;
|
|
313
|
+
};
|
|
314
|
+
const resolveSetupPath = async ({ cwd, scenarioPath, setup, }) => {
|
|
315
|
+
if (setup) {
|
|
316
|
+
return setup;
|
|
317
|
+
}
|
|
318
|
+
const scenarioComponentShotDir = findComponentShotDir(scenarioPath);
|
|
319
|
+
if (scenarioComponentShotDir) {
|
|
320
|
+
const scenarioSetupPath = await findSetupPath(scenarioComponentShotDir);
|
|
321
|
+
if (scenarioSetupPath) {
|
|
322
|
+
return scenarioSetupPath;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
for (const candidate of defaultSetupPaths) {
|
|
326
|
+
if (await pathExists(path.resolve(cwd, candidate))) {
|
|
327
|
+
return candidate;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return undefined;
|
|
331
|
+
};
|
|
332
|
+
const getScenarioName = (scenarioPath) => {
|
|
333
|
+
const basename = path.basename(scenarioPath, path.extname(scenarioPath));
|
|
334
|
+
return basename === 'index' ? path.basename(path.dirname(scenarioPath)) : basename;
|
|
335
|
+
};
|
|
336
|
+
const sanitizeScenarioName = (value) => sanitizeFilename(value.replace(/\.[jt]sx?$/i, ''));
|
|
337
|
+
const createScenarioPathForSource = ({ cwd, options, }) => {
|
|
338
|
+
if (options.scenario) {
|
|
339
|
+
return path.resolve(cwd, options.scenario);
|
|
340
|
+
}
|
|
341
|
+
const name = options.name ?? `source-${new Date().toISOString().replace(/[:.]/g, '-').toLowerCase()}`;
|
|
342
|
+
return path.resolve(cwd, options.scenarioDir ?? defaultCliEnv.scenarioDir, `${sanitizeScenarioName(name)}.tsx`);
|
|
343
|
+
};
|
|
344
|
+
const writeScenarioSource = async ({ overwrite = false, scenarioPath, source, }) => {
|
|
345
|
+
if (!overwrite && (await pathExists(scenarioPath))) {
|
|
346
|
+
throw new Error(`${scenarioPath} already exists. Pass --overwrite to replace it.`);
|
|
347
|
+
}
|
|
348
|
+
await fs.mkdir(path.dirname(scenarioPath), { recursive: true });
|
|
349
|
+
await fs.writeFile(scenarioPath, source.endsWith('\n') ? source : `${source}\n`, 'utf8');
|
|
350
|
+
};
|
|
351
|
+
const resolveScreenshotsDir = ({ cwd, options, scenarioPath, }) => {
|
|
352
|
+
if (options.screenshotsDir !== defaultOptions.screenshotsDir) {
|
|
353
|
+
return path.resolve(cwd, options.screenshotsDir);
|
|
354
|
+
}
|
|
355
|
+
const scenarioComponentShotDir = findComponentShotDir(scenarioPath);
|
|
356
|
+
if (scenarioComponentShotDir) {
|
|
357
|
+
return path.join(scenarioComponentShotDir, 'screenshots');
|
|
358
|
+
}
|
|
359
|
+
return path.resolve(cwd, defaultOptions.screenshotsDir);
|
|
360
|
+
};
|
|
361
|
+
const createSavedShotPaths = ({ cwd, options, scenarioPath, }) => {
|
|
362
|
+
const scenarioName = sanitizeFilename(options.saveName ?? getScenarioName(scenarioPath));
|
|
363
|
+
const scenarioDir = path.join(resolveScreenshotsDir({ cwd, options, scenarioPath }), scenarioName);
|
|
364
|
+
return {
|
|
365
|
+
historyPath: path.join(scenarioDir, 'history', `${createTimestamp()}.png`),
|
|
366
|
+
latestPath: path.join(scenarioDir, 'latest.png'),
|
|
367
|
+
};
|
|
368
|
+
};
|
|
369
|
+
const createOutputPath = async ({ cwd, options, scenarioPath, }) => {
|
|
370
|
+
const savedShotPaths = options.save ? createSavedShotPaths({ cwd, options, scenarioPath }) : undefined;
|
|
371
|
+
const outputPath = options.output ??
|
|
372
|
+
savedShotPaths?.latestPath ??
|
|
373
|
+
path.join(os.tmpdir(), options.outputDirName, `${sanitizeFilename(getScenarioName(scenarioPath))}-${Date.now()}.png`);
|
|
374
|
+
const resolvedOutputPath = path.resolve(cwd, outputPath);
|
|
375
|
+
await fs.mkdir(path.dirname(resolvedOutputPath), { recursive: true });
|
|
376
|
+
return {
|
|
377
|
+
...savedShotPaths,
|
|
378
|
+
outputPath: resolvedOutputPath,
|
|
379
|
+
};
|
|
380
|
+
};
|
|
381
|
+
const removeUndefinedValues = (value) => Object.fromEntries(Object.entries(value).filter(([, entryValue]) => entryValue !== undefined));
|
|
382
|
+
const resolveOptions = (options) => {
|
|
383
|
+
const definedOptions = removeUndefinedValues(options);
|
|
384
|
+
return {
|
|
385
|
+
...defaultOptions,
|
|
386
|
+
...definedOptions,
|
|
387
|
+
viewport: {
|
|
388
|
+
...defaultOptions.viewport,
|
|
389
|
+
...options.viewport,
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
};
|
|
393
|
+
const resolveBuildCommand = async (build, context) => typeof build === 'function' ? await build(context) : build;
|
|
394
|
+
const buildBundle = async ({ build, context, }) => {
|
|
395
|
+
const command = await resolveBuildCommand(build, context);
|
|
396
|
+
if (!command) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
await new Promise((resolve, reject) => {
|
|
400
|
+
const child = spawn(command.command, command.args ?? [], {
|
|
401
|
+
cwd: command.cwd ?? context.cwd,
|
|
402
|
+
env: {
|
|
403
|
+
...process.env,
|
|
404
|
+
...command.env,
|
|
405
|
+
},
|
|
406
|
+
shell: command.shell,
|
|
407
|
+
stdio: context.debug ? 'inherit' : ['ignore', 'pipe', 'pipe'],
|
|
408
|
+
});
|
|
409
|
+
const output = [];
|
|
410
|
+
child.stdout?.on('data', (chunk) => output.push(String(chunk)));
|
|
411
|
+
child.stderr?.on('data', (chunk) => output.push(String(chunk)));
|
|
412
|
+
child.on('error', reject);
|
|
413
|
+
child.on('exit', (code) => {
|
|
414
|
+
if (code === 0) {
|
|
415
|
+
resolve();
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
reject(new Error(output.join('').trim() || `${command.command} exited with code ${code}`));
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
};
|
|
422
|
+
const saveShot = async ({ historyPath, latestPath, outputPath, }) => {
|
|
423
|
+
await fs.mkdir(path.dirname(latestPath), { recursive: true });
|
|
424
|
+
await fs.mkdir(path.dirname(historyPath), { recursive: true });
|
|
425
|
+
if (path.resolve(outputPath) !== path.resolve(latestPath)) {
|
|
426
|
+
await fs.copyFile(outputPath, latestPath);
|
|
427
|
+
}
|
|
428
|
+
await fs.copyFile(outputPath, historyPath);
|
|
429
|
+
};
|
|
430
|
+
const sendNotFound = (response) => {
|
|
431
|
+
response.statusCode = 404;
|
|
432
|
+
response.end('Not found');
|
|
433
|
+
};
|
|
434
|
+
const sendStaticFile = async ({ filePath, response, }) => {
|
|
435
|
+
try {
|
|
436
|
+
const content = await fs.readFile(filePath);
|
|
437
|
+
response.statusCode = 200;
|
|
438
|
+
response.setHeader('Content-Type', contentTypes[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream');
|
|
439
|
+
response.end(content);
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
const code = typeof error === 'object' && error && 'code' in error ? error.code : undefined;
|
|
443
|
+
if (code === 'ENOENT' || code === 'EISDIR') {
|
|
444
|
+
sendNotFound(response);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
const startServer = async (publicDir) => {
|
|
451
|
+
const resolvedPublicDir = path.resolve(publicDir);
|
|
452
|
+
const server = http.createServer((request, response) => {
|
|
453
|
+
void (async () => {
|
|
454
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
455
|
+
const decodedPathname = decodeURIComponent(url.pathname);
|
|
456
|
+
const relativePath = decodedPathname === '/' ? 'index.html' : decodedPathname.slice(1);
|
|
457
|
+
const filePath = path.resolve(resolvedPublicDir, relativePath);
|
|
458
|
+
if (filePath !== resolvedPublicDir && !filePath.startsWith(`${resolvedPublicDir}${path.sep}`)) {
|
|
459
|
+
response.statusCode = 403;
|
|
460
|
+
response.end('Forbidden');
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
await sendStaticFile({ filePath, response });
|
|
464
|
+
})().catch((error) => {
|
|
465
|
+
response.statusCode = 500;
|
|
466
|
+
response.end(error instanceof Error ? error.message : String(error));
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
await new Promise((resolve) => {
|
|
470
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
471
|
+
});
|
|
472
|
+
const address = server.address();
|
|
473
|
+
if (!address || typeof address === 'string') {
|
|
474
|
+
throw new Error('Unable to read component-shot server address');
|
|
475
|
+
}
|
|
476
|
+
return {
|
|
477
|
+
server,
|
|
478
|
+
url: `http://127.0.0.1:${address.port}`,
|
|
479
|
+
};
|
|
480
|
+
};
|
|
481
|
+
const closeServer = (server) => new Promise((resolve, reject) => {
|
|
482
|
+
if (!server) {
|
|
483
|
+
resolve();
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
server.close((error) => {
|
|
487
|
+
if (error) {
|
|
488
|
+
reject(error);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
resolve();
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
const captureShot = async ({ options, outputPath, url, }) => {
|
|
495
|
+
const browser = await chromium.launch({
|
|
496
|
+
channel: options.browserChannel,
|
|
497
|
+
headless: true,
|
|
498
|
+
});
|
|
499
|
+
try {
|
|
500
|
+
const page = await browser.newPage({ viewport: options.viewport });
|
|
501
|
+
const pageErrors = [];
|
|
502
|
+
page.on('pageerror', (error) => {
|
|
503
|
+
pageErrors.push(error.stack ?? error.message);
|
|
504
|
+
});
|
|
505
|
+
if (options.debug) {
|
|
506
|
+
page.on('console', (message) => {
|
|
507
|
+
process.stderr.write(`[browser:${message.type()}] ${message.text()}\n`);
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
await page.goto(url, {
|
|
511
|
+
timeout: options.timeoutMs,
|
|
512
|
+
waitUntil: 'domcontentloaded',
|
|
513
|
+
});
|
|
514
|
+
await page.waitForFunction(({ errorGlobal, readyGlobal }) => globalThis[readyGlobal] === true ||
|
|
515
|
+
Boolean(globalThis[errorGlobal]), {
|
|
516
|
+
errorGlobal: options.errorGlobal,
|
|
517
|
+
readyGlobal: options.readyGlobal,
|
|
518
|
+
}, { timeout: options.timeoutMs });
|
|
519
|
+
const renderError = await page.evaluate((errorGlobal) => globalThis[errorGlobal], options.errorGlobal);
|
|
520
|
+
if (renderError) {
|
|
521
|
+
throw new Error(String(renderError));
|
|
522
|
+
}
|
|
523
|
+
if (options.waitFor) {
|
|
524
|
+
await page.locator(options.waitFor).waitFor({
|
|
525
|
+
state: 'visible',
|
|
526
|
+
timeout: options.timeoutMs,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
await page.evaluate(async () => {
|
|
530
|
+
await document.fonts?.ready;
|
|
531
|
+
});
|
|
532
|
+
if (pageErrors.length > 0) {
|
|
533
|
+
throw new Error(pageErrors.join('\n\n'));
|
|
534
|
+
}
|
|
535
|
+
if (options.fullPage) {
|
|
536
|
+
await page.screenshot({ fullPage: true, path: outputPath });
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
const target = page.locator(options.selector).first();
|
|
540
|
+
await target.waitFor({
|
|
541
|
+
state: 'visible',
|
|
542
|
+
timeout: options.timeoutMs,
|
|
543
|
+
});
|
|
544
|
+
await target.screenshot({ path: outputPath, timeout: options.timeoutMs });
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
finally {
|
|
548
|
+
await browser.close();
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
export const captureComponentShot = async (optionsInput) => {
|
|
552
|
+
const options = resolveOptions(optionsInput);
|
|
553
|
+
if (!options.build && options.rspack === false) {
|
|
554
|
+
throw new Error('A custom build is required when rspack is false');
|
|
555
|
+
}
|
|
556
|
+
const rspackOptions = typeof options.rspack === 'object' ? options.rspack : {};
|
|
557
|
+
const cwd = path.resolve(process.cwd(), options.cwd ?? '.');
|
|
558
|
+
const scenarioPath = path.resolve(cwd, options.scenario);
|
|
559
|
+
await fs.access(scenarioPath);
|
|
560
|
+
const setup = await resolveSetupPath({ cwd, scenarioPath, setup: options.setup });
|
|
561
|
+
const build = options.build ?? createRspackBuild({ ...rspackOptions, setup });
|
|
562
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), options.tempDirPrefix));
|
|
563
|
+
const publicDir = path.join(tempDir, 'public');
|
|
564
|
+
await fs.mkdir(publicDir, { recursive: true });
|
|
565
|
+
let server;
|
|
566
|
+
let url = '';
|
|
567
|
+
try {
|
|
568
|
+
const { historyPath, latestPath, outputPath } = await createOutputPath({
|
|
569
|
+
cwd,
|
|
570
|
+
options,
|
|
571
|
+
scenarioPath,
|
|
572
|
+
});
|
|
573
|
+
await buildBundle({
|
|
574
|
+
build,
|
|
575
|
+
context: {
|
|
576
|
+
cwd,
|
|
577
|
+
debug: options.debug,
|
|
578
|
+
publicDir,
|
|
579
|
+
scenarioPath,
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
const serverDetails = await startServer(publicDir);
|
|
583
|
+
server = serverDetails.server;
|
|
584
|
+
url = serverDetails.url;
|
|
585
|
+
await captureShot({ options, outputPath, url });
|
|
586
|
+
if (historyPath && latestPath) {
|
|
587
|
+
await saveShot({ historyPath, latestPath, outputPath });
|
|
588
|
+
}
|
|
589
|
+
return {
|
|
590
|
+
historyPath,
|
|
591
|
+
latestPath,
|
|
592
|
+
outputPath,
|
|
593
|
+
tempDir: options.keepTemp ? tempDir : undefined,
|
|
594
|
+
url,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
finally {
|
|
598
|
+
await closeServer(server);
|
|
599
|
+
if (!options.keepTemp) {
|
|
600
|
+
await fs.rm(tempDir, { force: true, recursive: true });
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
export const captureComponentSource = async (optionsInput) => {
|
|
605
|
+
const cwd = path.resolve(process.cwd(), optionsInput.cwd ?? '.');
|
|
606
|
+
const scenarioPath = createScenarioPathForSource({ cwd, options: optionsInput });
|
|
607
|
+
await writeScenarioSource({
|
|
608
|
+
overwrite: optionsInput.overwrite,
|
|
609
|
+
scenarioPath,
|
|
610
|
+
source: optionsInput.source,
|
|
611
|
+
});
|
|
612
|
+
const { name: _name, overwrite: _overwrite, scenario: _scenario, scenarioDir: _scenarioDir, source: _source, ...captureOptions } = optionsInput;
|
|
613
|
+
const result = await captureComponentShot({
|
|
614
|
+
...captureOptions,
|
|
615
|
+
cwd,
|
|
616
|
+
scenario: path.relative(cwd, scenarioPath),
|
|
617
|
+
});
|
|
618
|
+
return {
|
|
619
|
+
...result,
|
|
620
|
+
scenarioPath,
|
|
621
|
+
};
|
|
622
|
+
};
|
|
623
|
+
export const runComponentShotCli = async ({ argv = process.argv.slice(2), build, defaults, rspack, setup, usageCommand = 'component-shot --scenario <file.tsx> [--setup setup.tsx] [options]', } = {}) => {
|
|
624
|
+
try {
|
|
625
|
+
if (argv[0] === 'gallery') {
|
|
626
|
+
await runComponentShotGalleryCli({
|
|
627
|
+
argv: argv.slice(1),
|
|
628
|
+
usageCommand: 'component-shot gallery [options]',
|
|
629
|
+
});
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (argv[0] === 'skill') {
|
|
633
|
+
await runComponentShotSkillCli({
|
|
634
|
+
argv: argv.slice(1),
|
|
635
|
+
usageCommand: 'component-shot skill [options]',
|
|
636
|
+
});
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const options = parseCliArgs({ argv, defaults, usageCommand });
|
|
640
|
+
const cliBuild = build ??
|
|
641
|
+
(options.buildCommand
|
|
642
|
+
? ((context) => ({
|
|
643
|
+
command: options.buildCommand ?? '',
|
|
644
|
+
env: {
|
|
645
|
+
[options.publicDirEnv]: context.publicDir,
|
|
646
|
+
[options.scenarioEnv]: context.scenarioPath,
|
|
647
|
+
},
|
|
648
|
+
shell: true,
|
|
649
|
+
}))
|
|
650
|
+
: undefined);
|
|
651
|
+
const captureOptions = {
|
|
652
|
+
...options,
|
|
653
|
+
build: cliBuild,
|
|
654
|
+
rspack: options.rspack ?? rspack,
|
|
655
|
+
setup: options.setup ?? setup,
|
|
656
|
+
};
|
|
657
|
+
const result = options.source
|
|
658
|
+
? await captureComponentSource({
|
|
659
|
+
...captureOptions,
|
|
660
|
+
source: options.source,
|
|
661
|
+
})
|
|
662
|
+
: await captureComponentShot({
|
|
663
|
+
...captureOptions,
|
|
664
|
+
scenario: options.scenario ?? '',
|
|
665
|
+
});
|
|
666
|
+
if (options.json) {
|
|
667
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
process.stdout.write(`${result.outputPath}\n`);
|
|
671
|
+
if (result.tempDir) {
|
|
672
|
+
process.stderr.write(`Kept temp bundle: ${result.tempDir}\n`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
catch (error) {
|
|
676
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
677
|
+
process.stderr.write(`${message}\n`);
|
|
678
|
+
process.exitCode = 1;
|
|
679
|
+
}
|
|
680
|
+
};
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mcp.d.ts","sourceRoot":"","sources":["../src/mcp.ts"],"names":[],"mappings":""}
|