@opendatalabs/darshana 1.2.1 → 1.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 CHANGED
@@ -27,6 +27,11 @@ npm install -g @opendatalabs/darshana
27
27
 
28
28
  Chromium is installed automatically. Or skip the install entirely and use `npx @opendatalabs/darshana`.
29
29
 
30
+ **Linux only:** if Chromium fails to launch, you may be missing system libraries. Fix with:
31
+ ```bash
32
+ sudo npx playwright install chromium --with-deps
33
+ ```
34
+
30
35
  ## CLI reference
31
36
 
32
37
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendatalabs/darshana",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Crawl any web app and generate a labeled PDF, HTML viewer, or image set for design review.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/capture.mjs CHANGED
@@ -7,6 +7,8 @@ export async function captureAll(browser, config, urls) {
7
7
  const results = [];
8
8
  const themes = config.capture.themes;
9
9
  const viewportNames = config.capture.viewports;
10
+ const total = urls.length * themes.length * viewportNames.length;
11
+ const counter = { n: 0 };
10
12
 
11
13
  for (const viewportName of viewportNames) {
12
14
  for (const theme of themes) {
@@ -20,7 +22,7 @@ export async function captureAll(browser, config, urls) {
20
22
  ...(storageStatePath ? { storageState: storageStatePath } : {}),
21
23
  };
22
24
 
23
- console.log(`\n[capture] Segment: ${viewportName} / ${theme} (${urls.length} URLs)`);
25
+ console.log(`\n[capture] Starting segment ${viewportName}/${theme} (${urls.length} URLs)`);
24
26
  const context = await browser.newContext(contextOpts);
25
27
 
26
28
  for (const url of urls) {
@@ -31,20 +33,23 @@ export async function captureAll(browser, config, urls) {
31
33
 
32
34
  const effectiveThemes = override?.themes;
33
35
  if (effectiveThemes && !effectiveThemes.includes(theme)) {
34
- console.log(` [capture] skip ${pathname} [${theme}] (override)`);
36
+ counter.n++;
37
+ console.log(` [capture] ${counter.n}/${total} skip ${pathname} [${theme}] (override)`);
35
38
  continue;
36
39
  }
37
40
  const effectiveViewports = override?.viewports;
38
41
  if (effectiveViewports && !effectiveViewports.includes(viewportName)) {
39
- console.log(` [capture] skip ${pathname} [${viewportName}] (override)`);
42
+ counter.n++;
43
+ console.log(` [capture] ${counter.n}/${total} skip ${pathname} [${viewportName}] (override)`);
40
44
  continue;
41
45
  }
42
46
 
43
47
  const delay = override?.delay ?? config.capture.delay ?? 400;
44
48
  const waitFor = override?.waitFor ?? config.capture.waitFor ?? null;
45
49
  const label = makeLabel(pathname, viewportName, theme);
50
+ counter.n++;
46
51
 
47
- console.log(` [capture] ${label}`);
52
+ console.log(` [capture] ${counter.n}/${total} ${label}`);
48
53
  const page = await context.newPage();
49
54
 
50
55
  try {
@@ -96,14 +101,14 @@ export async function captureAll(browser, config, urls) {
96
101
 
97
102
  results.push({ url, pathname, theme, viewport: viewportName, imageBuffer, label });
98
103
  } catch (err) {
99
- console.error(` [capture] FAILED ${pathname}: ${err.message}`);
104
+ console.error(` [capture] ${counter.n}/${total} FAILED ${pathname}: ${err.message}`);
100
105
  } finally {
101
106
  await page.close();
102
107
  }
103
108
  }
104
109
 
105
110
  await context.close();
106
- console.log(`[capture] Segment done: ${viewportName} / ${theme}`);
111
+ console.log(`[capture] Segment done: ${viewportName}/${theme}`);
107
112
  }
108
113
  }
109
114
 
package/src/config.mjs CHANGED
@@ -6,6 +6,33 @@ export const VIEWPORT_PRESETS = {
6
6
  mobile: { width: 390, height: 844, deviceScaleFactor: 2 },
7
7
  };
8
8
 
9
+ const KNOWN_TOP_LEVEL_KEYS = ['title', 'url', 'start', 'public', 'authStorage', 'authScript', 'crawl', 'capture', 'pdf', 'outputs', 'outputDir'];
10
+ const KNOWN_CRAWL_KEYS = ['include', 'exclude', 'maxDepth', 'maxPages', 'extraRoutes', 'routes'];
11
+ const KNOWN_CAPTURE_KEYS = ['themes', 'viewports', 'fullPage', 'delay', 'waitFor', 'contextOptions', 'launchOptions', 'playwrightOptions', 'routeOptions', 'overrides'];
12
+
13
+ function levenshtein(a, b) {
14
+ const m = a.length, n = b.length;
15
+ const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_, j) => i === 0 ? j : j === 0 ? i : 0));
16
+ for (let i = 1; i <= m; i++) {
17
+ for (let j = 1; j <= n; j++) {
18
+ dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
19
+ }
20
+ }
21
+ return dp[m][n];
22
+ }
23
+
24
+ function warnUnknownKeys(obj, knownKeys, section) {
25
+ if (!obj || typeof obj !== 'object') return;
26
+ for (const key of Object.keys(obj)) {
27
+ if (!knownKeys.includes(key)) {
28
+ const best = knownKeys.reduce((acc, k) => { const d = levenshtein(key, k); return d < acc.d ? { k, d } : acc; }, { k: null, d: Infinity });
29
+ const prefix = section ? `[darshana] Warning: unknown config field "${section}.${key}"` : `[darshana] Warning: unknown config field "${key}"`;
30
+ const suffix = best.d <= 3 ? ` — did you mean "${section ? section + '.' : ''}${best.k}"?` : '';
31
+ console.warn(prefix + suffix);
32
+ }
33
+ }
34
+ }
35
+
9
36
  export function loadConfig(configPath) {
10
37
  const absConfigPath = path.resolve(configPath);
11
38
  const configDir = path.dirname(absConfigPath);
@@ -25,6 +52,10 @@ export function loadConfig(configPath) {
25
52
  // Build a config object from a plain object (used by both loadConfig and CLI --url mode).
26
53
  // configDir is used to resolve relative paths; defaults to cwd when not loading from a file.
27
54
  export function buildConfig(raw, configDir = process.cwd()) {
55
+ // Validate unknown keys and emit typo hints
56
+ warnUnknownKeys(raw, KNOWN_TOP_LEVEL_KEYS, null);
57
+ warnUnknownKeys(raw.crawl, KNOWN_CRAWL_KEYS, 'crawl');
58
+ warnUnknownKeys(raw.capture, KNOWN_CAPTURE_KEYS, 'capture');
28
59
  const config = {
29
60
  title: raw.title ?? 'Design Review',
30
61
  url: raw.url.replace(/\/$/, ''),
package/src/index.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import readline from 'node:readline';
5
+ import { createRequire } from 'node:module';
4
6
  import { chromium } from 'playwright';
5
7
  import { loadConfig, buildConfig } from './config.mjs';
6
8
  import { ensureAuth } from './auth.mjs';
@@ -9,6 +11,9 @@ import { captureAll } from './capture.mjs';
9
11
  import { assemblePdf } from './pdf.mjs';
10
12
  import { assembleHtml } from './html.mjs';
11
13
 
14
+ const require = createRequire(import.meta.url);
15
+ const pkg = require('../package.json');
16
+
12
17
  const USAGE = `
13
18
  Usage:
14
19
  darshana --url <url> [options] # zero-config mode
@@ -36,6 +41,155 @@ Options:
36
41
  --auth-only Save auth session and exit
37
42
  `.trim();
38
43
 
44
+ // ── Config summary box ────────────────────────────────────────────────────────
45
+
46
+ function printConfigSummary(config) {
47
+ const INNER = 48;
48
+ const authLabel = config.public
49
+ ? 'public'
50
+ : config.authScript
51
+ ? `script: ${path.basename(config.authScript)}`
52
+ : 'session file';
53
+
54
+ const rows = [
55
+ ['url', config.url],
56
+ ['start', config.start],
57
+ ['auth', authLabel],
58
+ ['themes', (config.capture.themes ?? ['system']).join(', ')],
59
+ ['viewports', (config.capture.viewports ?? ['desktop']).join(', ')],
60
+ ['maxPages', `${config.crawl.maxPages} maxDepth ${config.crawl.maxDepth}`],
61
+ ['outputs', (config.outputs ?? ['pdf', 'html']).join(', ')],
62
+ ['outputDir', config.outputDir ?? './darshana-output'],
63
+ ];
64
+
65
+ // Compute column widths
66
+ const keyWidth = Math.max(...rows.map(([k]) => k.length));
67
+ const valWidth = INNER - keyWidth - 2; // 2 = space + space
68
+
69
+ function trunc(s, max) {
70
+ const str = String(s);
71
+ return str.length > max ? str.slice(0, max - 1) + '…' : str;
72
+ }
73
+
74
+ function pad(s, len) { return s + ' '.repeat(Math.max(0, len - s.length)); }
75
+
76
+ const topLabel = ' darshana ';
77
+ const topLineLen = INNER + 2; // borders
78
+ const dashLen = topLineLen - topLabel.length - 2; // -2 for corner chars
79
+ const dashLeft = Math.floor(dashLen / 2);
80
+ const dashRight = dashLen - dashLeft;
81
+
82
+ console.log(`┌${'─'.repeat(dashLeft)}${topLabel}${'─'.repeat(dashRight)}┐`);
83
+ for (const [k, v] of rows) {
84
+ const paddedKey = pad(k, keyWidth);
85
+ const truncVal = trunc(v, valWidth);
86
+ const paddedVal = pad(truncVal, valWidth);
87
+ console.log(`│ ${paddedKey} ${paddedVal} │`);
88
+ }
89
+ console.log(`└${'─'.repeat(INNER)}┘`);
90
+ }
91
+
92
+ // ── init subcommand ───────────────────────────────────────────────────────────
93
+
94
+ async function runInit() {
95
+ const configPath = path.join(process.cwd(), 'review.config.json');
96
+
97
+ // Build an `ask` function that works for both TTY and piped stdin.
98
+ // For piped input: readline fires 'close' as soon as the pipe ends, breaking
99
+ // sequential question() calls. We buffer all lines upfront and serve them.
100
+ // For interactive TTY: use readline.question() normally.
101
+ const ask = await makeAsker();
102
+
103
+ if (fs.existsSync(configPath)) {
104
+ const overwrite = await ask('review.config.json already exists. Overwrite? (y/N): ');
105
+ if (overwrite.trim().toLowerCase() !== 'y') {
106
+ console.log('Aborted.');
107
+ process.exit(0);
108
+ }
109
+ }
110
+
111
+ // 1. URL
112
+ let url;
113
+ while (true) {
114
+ const raw = await ask('App URL (e.g. https://myapp.example.com): ');
115
+ try { url = new URL(raw.trim()).href.replace(/\/$/, ''); break; }
116
+ catch { console.log(' Invalid URL. Please include the scheme (https://).'); }
117
+ }
118
+ const hostname = new URL(url).hostname;
119
+
120
+ // 2. Title
121
+ const titleRaw = await ask(`Title (default: ${hostname}): `);
122
+ const title = titleRaw.trim() || hostname;
123
+
124
+ // 3. Auth
125
+ const authRaw = await ask('Is the app public? (y/N): ');
126
+ const isPublic = authRaw.trim().toLowerCase() === 'y';
127
+
128
+ // 4. Start path
129
+ const startRaw = await ask('Start path (default: /): ');
130
+ const start = startRaw.trim() || '/';
131
+
132
+ // 5. Viewports
133
+ const vpRaw = await ask('Viewports — desktop, mobile, or both? (default: desktop): ');
134
+ const vpInput = vpRaw.trim().toLowerCase() || 'desktop';
135
+ let viewports;
136
+ if (vpInput === 'both') viewports = ['desktop', 'mobile'];
137
+ else if (vpInput === 'mobile') viewports = ['mobile'];
138
+ else viewports = ['desktop'];
139
+
140
+ // 6. Themes
141
+ const themeRaw = await ask('Themes — system, dark, light, or multiple? (default: system): ');
142
+ const themeInput = themeRaw.trim() || 'system';
143
+ let themes;
144
+ if (themeInput === 'multiple' || themeInput.includes(',')) {
145
+ themes = themeInput === 'multiple'
146
+ ? ['dark', 'light']
147
+ : themeInput.split(',').map(s => s.trim()).filter(Boolean);
148
+ } else {
149
+ themes = [themeInput];
150
+ }
151
+
152
+ // 7. Output dir
153
+ const outRaw = await ask('Output directory (default: ./darshana-output): ');
154
+ const outputDir = outRaw.trim() || './darshana-output';
155
+
156
+ const config = {
157
+ title,
158
+ url,
159
+ start,
160
+ public: isPublic,
161
+ capture: { viewports, themes },
162
+ outputDir,
163
+ };
164
+
165
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
166
+ console.log('✓ review.config.json created. Run: darshana --config review.config.json');
167
+ process.exit(0);
168
+ }
169
+
170
+ // Returns an `ask(question)` function.
171
+ // TTY mode: wraps readline.question for interactive line editing.
172
+ // Piped mode: buffers all stdin lines first, then serves them sequentially.
173
+ async function makeAsker() {
174
+ if (process.stdin.isTTY) {
175
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
176
+ return (q) => new Promise(resolve => rl.question(q, answer => { resolve(answer); }));
177
+ }
178
+ // Non-TTY: read all lines from stdin before asking anything
179
+ const lines = await new Promise(resolve => {
180
+ const buf = [];
181
+ const rl = readline.createInterface({ input: process.stdin });
182
+ rl.on('line', l => buf.push(l));
183
+ rl.on('close', () => resolve(buf));
184
+ });
185
+ let idx = 0;
186
+ return (q) => {
187
+ const answer = lines[idx++] ?? '';
188
+ console.log(`${q}${answer}`);
189
+ return Promise.resolve(answer);
190
+ };
191
+ }
192
+
39
193
  function parseArgs(argv) {
40
194
  const args = {
41
195
  config: null,
@@ -82,6 +236,7 @@ function parseArgs(argv) {
82
236
  if (a === '--route') { args.route = next(); continue; }
83
237
  if (a === '--auth-only') { args.authOnly = true; continue; }
84
238
  if (a === '--help' || a === '-h') { console.log(USAGE); process.exit(0); }
239
+ if (a === '--version' || a === '-v') { console.log(`darshana ${pkg.version}`); process.exit(0); }
85
240
  console.error(`Unknown argument: ${a}\n\n${USAGE}`);
86
241
  process.exit(1);
87
242
  }
@@ -136,95 +291,109 @@ function applyCliOverrides(config, args) {
136
291
  return config;
137
292
  }
138
293
 
139
- const args = parseArgs(process.argv.slice(2));
140
-
141
- if (!args.config && !args.url) {
142
- console.error('Error: --url or --config is required\n\n' + USAGE);
143
- process.exit(1);
294
+ // Dispatch init before parseArgs so we don't need --url
295
+ if (process.argv[2] === 'init') {
296
+ runInit();
297
+ } else {
298
+ runMain();
144
299
  }
145
300
 
146
- async function main() {
147
- let config;
148
- if (args.config) {
149
- config = loadConfig(args.config);
150
- config = applyCliOverrides(config, args);
151
- } else {
152
- config = configFromArgs(args);
301
+ function runMain() {
302
+ const args = parseArgs(process.argv.slice(2));
303
+
304
+ if (!args.config && !args.url) {
305
+ console.error('Error: --url or --config is required\n\n' + USAGE);
306
+ process.exit(1);
153
307
  }
154
308
 
155
- console.log(`[darshana] ${config.title} ${config.url}`);
309
+ async function main() {
310
+ let config;
311
+ if (args.config) {
312
+ config = loadConfig(args.config);
313
+ config = applyCliOverrides(config, args);
314
+ } else {
315
+ config = configFromArgs(args);
316
+ }
156
317
 
157
- const storageStatePath = await ensureAuth(config);
158
- config._storageStatePath = storageStatePath;
318
+ const storageStatePath = await ensureAuth(config);
319
+ config._storageStatePath = storageStatePath;
159
320
 
160
- if (args.authOnly) {
161
- console.log('[darshana] --auth-only done.');
162
- process.exit(0);
163
- }
321
+ printConfigSummary(config);
164
322
 
165
- let urls;
166
- if (args.route) {
167
- const fullUrl = args.route.startsWith('http') ? args.route : config.url + args.route;
168
- urls = [fullUrl];
169
- console.log(`[darshana] --route mode: ${fullUrl}`);
170
- } else {
171
- const crawlBrowser = await chromium.launch({ headless: true });
172
- const crawlContextOpts = storageStatePath ? { storageState: storageStatePath } : {};
173
- const crawlContext = await crawlBrowser.newContext(crawlContextOpts);
174
- try {
175
- console.log(`[darshana] Crawling from ${config.url}${config.start} ...`);
176
- urls = await crawl(crawlContext, config);
177
- } finally {
178
- await crawlContext.close();
179
- await crawlBrowser.close();
323
+ if (args.authOnly) {
324
+ console.log('[darshana] --auth-only done.');
325
+ process.exit(0);
180
326
  }
181
- console.log(`[darshana] Crawl complete: ${urls.length} URLs found.`);
182
- }
183
327
 
184
- if (args.dryRun) {
185
- console.log('\n[darshana] --dry-run: discovered URLs:');
186
- for (const u of urls) console.log(` ${u}`);
187
- process.exit(0);
188
- }
328
+ let urls;
329
+ if (args.route) {
330
+ const fullUrl = args.route.startsWith('http') ? args.route : config.url + args.route;
331
+ urls = [fullUrl];
332
+ console.log(`[darshana] --route mode: ${fullUrl}`);
333
+ } else {
334
+ const crawlBrowser = await chromium.launch({ headless: true });
335
+ const crawlContextOpts = storageStatePath ? { storageState: storageStatePath } : {};
336
+ const crawlContext = await crawlBrowser.newContext(crawlContextOpts);
337
+ try {
338
+ console.log(`[darshana] Crawling from ${config.url}${config.start} ...`);
339
+ urls = await crawl(crawlContext, config);
340
+ } finally {
341
+ await crawlContext.close();
342
+ await crawlBrowser.close();
343
+ }
344
+ console.log(`[darshana] Crawl complete: ${urls.length} URLs found.`);
345
+ }
189
346
 
190
- const browser = await chromium.launch({
191
- headless: true,
192
- args: ['--no-sandbox', '--disable-setuid-sandbox'],
193
- ...(config.capture.launchOptions ?? {}),
194
- });
347
+ if (args.dryRun) {
348
+ console.log('\n[darshana] --dry-run: discovered URLs:');
349
+ for (const u of urls) console.log(` ${u}`);
350
+ process.exit(0);
351
+ }
195
352
 
196
- let captures;
197
- try {
198
- captures = await captureAll(browser, config, urls);
199
- } finally {
200
- await browser.close();
201
- }
353
+ const browser = await chromium.launch({
354
+ headless: true,
355
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
356
+ ...(config.capture.launchOptions ?? {}),
357
+ });
202
358
 
203
- console.log(`\n[darshana] Captured ${captures.length} page(s).`);
359
+ let captures;
360
+ try {
361
+ captures = await captureAll(browser, config, urls);
362
+ } finally {
363
+ await browser.close();
364
+ }
204
365
 
205
- if (captures.length === 0) {
206
- console.error('[darshana] No pages captured. Exiting.');
207
- process.exit(1);
208
- }
366
+ console.log(`\n[darshana] Captured ${captures.length} page(s).`);
209
367
 
210
- const outputDir = config.outputDir;
211
- fs.mkdirSync(outputDir, { recursive: true });
368
+ if (captures.length === 0) {
369
+ console.error('[darshana] No pages captured. Exiting.');
370
+ process.exit(1);
371
+ }
212
372
 
213
- const outputs = config.outputs ?? ['pdf', 'html'];
373
+ const outputDir = config.outputDir;
374
+ fs.mkdirSync(outputDir, { recursive: true });
214
375
 
215
- if (outputs.includes('pdf')) {
216
- await assemblePdf(captures, config, outputDir);
217
- }
376
+ const outputs = config.outputs ?? ['pdf', 'html'];
218
377
 
219
- if (outputs.includes('html')) {
220
- await assembleHtml(captures, config, outputDir);
221
- }
378
+ if (outputs.includes('pdf')) {
379
+ await assemblePdf(captures, config, outputDir);
380
+ }
222
381
 
223
- if (outputs.includes('images')) {
224
- await writeImages(captures, outputDir);
382
+ if (outputs.includes('html')) {
383
+ await assembleHtml(captures, config, outputDir);
384
+ }
385
+
386
+ if (outputs.includes('images')) {
387
+ await writeImages(captures, outputDir);
388
+ }
389
+
390
+ console.log('\n[darshana] Done.');
225
391
  }
226
392
 
227
- console.log('\n[darshana] Done.');
393
+ main().catch(err => {
394
+ console.error('[darshana] Fatal:', err);
395
+ process.exit(1);
396
+ });
228
397
  }
229
398
 
230
399
  async function writeImages(captures, outputDir) {
@@ -257,8 +426,3 @@ function slugifyPathname(pathname) {
257
426
  .replace(/^-|-$/g, '')
258
427
  || 'root';
259
428
  }
260
-
261
- main().catch(err => {
262
- console.error('[darshana] Fatal:', err);
263
- process.exit(1);
264
- });