@opendatalabs/darshana 1.2.1 → 1.4.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
@@ -7,7 +7,7 @@ Crawl any web app and generate a labeled PDF, HTML viewer, or image set for desi
7
7
  ## Try it now
8
8
 
9
9
  ```bash
10
- npx @opendatalabs/darshana --url https://vana.org --public
10
+ npx @opendatalabs/darshana --url https://vana.org --public --max-pages 10
11
11
  ```
12
12
 
13
13
  Output lands in `./darshana-output/` — a PDF and a self-contained HTML viewer with sidebar nav, filters, and keyboard navigation.
@@ -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.4.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/auth.mjs CHANGED
@@ -7,7 +7,7 @@ const AUTH_MAX_AGE_MS = 12 * 60 * 60 * 1000;
7
7
  export async function ensureAuth(config) {
8
8
  if (config.public === true) {
9
9
  console.log('[auth] Public app — skipping auth.');
10
- return null;
10
+ return { storageStatePath: null, authCaptures: [] };
11
11
  }
12
12
 
13
13
  const storagePath = config.authStorage;
@@ -18,7 +18,7 @@ export async function ensureAuth(config) {
18
18
  if (ageMs < AUTH_MAX_AGE_MS) {
19
19
  const ageMin = Math.round(ageMs / 60000);
20
20
  console.log(`[auth] Using cached auth (${ageMin}m old): ${storagePath}`);
21
- return storagePath;
21
+ return { storageStatePath: storagePath, authCaptures: [] };
22
22
  }
23
23
  console.log('[auth] Cached auth is stale (>12h) — re-authenticating.');
24
24
  }
@@ -31,25 +31,77 @@ export async function ensureAuth(config) {
31
31
  throw new Error(`authScript must export a default function, got: ${typeof fn}`);
32
32
  }
33
33
  const browser = await chromium.launch({ headless: true });
34
+ let storagePath;
34
35
  try {
35
36
  const result = await fn(browser);
36
37
  if (typeof result !== 'string') {
37
38
  throw new Error('authScript must return a storageState file path string');
38
39
  }
39
- return result;
40
+ storagePath = result;
40
41
  } finally {
41
42
  await browser.close();
42
43
  }
44
+
45
+ // Post-auth verification screenshot using the saved storageState
46
+ const authCaptures = [];
47
+ try {
48
+ console.log('[auth] Capturing post-auth landing page...');
49
+ const verifyBrowser = await chromium.launch({ headless: true });
50
+ try {
51
+ const verifyContext = await verifyBrowser.newContext({ storageState: storagePath });
52
+ const verifyPage = await verifyContext.newPage();
53
+ const landingUrl = config.url + (config.start ?? '/');
54
+ await verifyPage.goto(landingUrl, { waitUntil: 'networkidle', timeout: 30000 });
55
+ const imageBuffer = await verifyPage.screenshot({ fullPage: true, type: 'png' });
56
+ authCaptures.push({
57
+ url: verifyPage.url(),
58
+ pathname: '/_auth/landing',
59
+ theme: 'system',
60
+ viewport: 'desktop',
61
+ section: 'auth',
62
+ label: 'Auth · post-login landing',
63
+ imageBuffer,
64
+ });
65
+ await verifyPage.close();
66
+ await verifyContext.close();
67
+ } finally {
68
+ await verifyBrowser.close();
69
+ }
70
+ } catch (err) {
71
+ console.warn(`[auth] WARNING: post-auth screenshot failed: ${err.message}`);
72
+ }
73
+
74
+ return { storageStatePath: storagePath, authCaptures };
43
75
  }
44
76
 
45
77
  console.log('\n[auth] Launching headed browser for manual login...');
46
78
  console.log(`[auth] Navigate to: ${config.url}`);
47
79
  console.log('[auth] Log in, then press ENTER here to capture session.\n');
48
80
 
81
+ const startPath = config.start ?? '/';
82
+ const startUrl = config.url + startPath;
83
+
49
84
  const browser = await chromium.launch({ headless: false });
50
85
  const context = await browser.newContext();
51
86
  const page = await context.newPage();
52
- await page.goto(config.url);
87
+ await page.goto(startUrl, { waitUntil: 'load' });
88
+
89
+ // Pre-auth screenshot (captures the login/redirect page)
90
+ let preAuthCapture = null;
91
+ try {
92
+ const preAuthImageBuffer = await page.screenshot({ fullPage: true, type: 'png' });
93
+ preAuthCapture = {
94
+ url: page.url(),
95
+ pathname: '/_auth/login',
96
+ theme: 'system',
97
+ viewport: 'desktop',
98
+ section: 'auth',
99
+ label: 'Auth · login page',
100
+ imageBuffer: preAuthImageBuffer,
101
+ };
102
+ } catch (err) {
103
+ console.warn(`[auth] WARNING: pre-auth screenshot failed: ${err.message}`);
104
+ }
53
105
 
54
106
  await waitForEnter();
55
107
 
@@ -58,7 +110,41 @@ export async function ensureAuth(config) {
58
110
  await browser.close();
59
111
 
60
112
  console.log('[auth] Session saved.\n');
61
- return storagePath;
113
+
114
+ // Post-auth verification screenshot using a new page with saved storageState
115
+ let postAuthCapture = null;
116
+ try {
117
+ console.log('[auth] Capturing post-auth landing page...');
118
+ const verifyBrowser = await chromium.launch({ headless: true });
119
+ try {
120
+ const verifyContext = await verifyBrowser.newContext({ storageState: storagePath });
121
+ const verifyPage = await verifyContext.newPage();
122
+ await verifyPage.goto(startUrl, { waitUntil: 'networkidle', timeout: 30000 });
123
+ const postAuthImageBuffer = await verifyPage.screenshot({ fullPage: true, type: 'png' });
124
+ postAuthCapture = {
125
+ url: verifyPage.url(),
126
+ pathname: '/_auth/landing',
127
+ theme: 'system',
128
+ viewport: 'desktop',
129
+ section: 'auth',
130
+ label: 'Auth · post-login landing',
131
+ imageBuffer: postAuthImageBuffer,
132
+ };
133
+ await verifyPage.close();
134
+ await verifyContext.close();
135
+ } finally {
136
+ await verifyBrowser.close();
137
+ }
138
+ } catch (err) {
139
+ console.warn(`[auth] WARNING: post-auth screenshot failed: ${err.message}`);
140
+ }
141
+
142
+ const authCaptures = [
143
+ ...(preAuthCapture ? [preAuthCapture] : []),
144
+ ...(postAuthCapture ? [postAuthCapture] : []),
145
+ ];
146
+
147
+ return { storageStatePath: storagePath, authCaptures };
62
148
  }
63
149
 
64
150
  function waitForEnter() {
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/html.mjs CHANGED
@@ -13,26 +13,78 @@ export async function assembleHtml(pages, config, outputDir) {
13
13
  return `img[data-viewport="${vp}"] { width: ${cssWidth}; max-width: ${preset.width}px; }`;
14
14
  }).join('\n ');
15
15
 
16
- const navItems = [];
16
+ const authPages = pages.filter(p => p.section === 'auth');
17
+ const appPages = pages.filter(p => p.section !== 'auth');
18
+ const hasAuthCaptures = authPages.length > 0;
19
+
17
20
  const pageSections = [];
18
21
 
19
22
  pages.forEach((capture, i) => {
20
23
  const idx = i + 1;
21
24
  const pageId = `page-${idx}`;
22
25
  const base64 = capture.imageBuffer.toString('base64');
26
+ const sectionAttr = capture.section === 'auth' ? ' data-section="auth"' : '';
23
27
 
24
- navItems.push(
25
- `<li data-theme="${escHtml(capture.theme)}" data-viewport="${escHtml(capture.viewport)}"><a href="#${pageId}" data-theme="${escHtml(capture.theme)}" data-viewport="${escHtml(capture.viewport)}">${escHtml(capture.label)}</a></li>`
26
- );
27
-
28
- pageSections.push(`<div class="page" id="${pageId}" data-theme="${escHtml(capture.theme)}" data-viewport="${escHtml(capture.viewport)}">
28
+ pageSections.push(`<div class="page" id="${pageId}" data-theme="${escHtml(capture.theme)}" data-viewport="${escHtml(capture.viewport)}"${sectionAttr}>
29
29
  <div class="label"><span class="idx">${idx}</span>${escHtml(capture.label)}</div>
30
- <img src="data:image/png;base64,${base64}" alt="${escHtml(capture.label)}" data-viewport="${escHtml(capture.viewport)}" loading="lazy">
30
+ <img src="data:image/png;base64,${base64}" alt="${escHtml(capture.label)}" data-viewport="${escHtml(capture.viewport)}"${sectionAttr} loading="lazy">
31
31
  </div>`);
32
32
  });
33
33
 
34
- const themes = [...new Set(pages.map(p => p.theme))];
35
- const viewports = [...new Set(pages.map(p => p.viewport))];
34
+ // Build sidebar nav items
35
+ let navHtml;
36
+ if (hasAuthCaptures) {
37
+ // Auth group
38
+ const authItems = [];
39
+ pages.forEach((capture, i) => {
40
+ if (capture.section !== 'auth') return;
41
+ const idx = i + 1;
42
+ const pageId = `page-${idx}`;
43
+ authItems.push(
44
+ `<li data-section="auth"><a href="#${pageId}">${escHtml(capture.label)}</a></li>`
45
+ );
46
+ });
47
+
48
+ // Pages group
49
+ const pageItems = [];
50
+ pages.forEach((capture, i) => {
51
+ if (capture.section === 'auth') return;
52
+ const idx = i + 1;
53
+ const pageId = `page-${idx}`;
54
+ pageItems.push(
55
+ `<li data-theme="${escHtml(capture.theme)}" data-viewport="${escHtml(capture.viewport)}"><a href="#${pageId}" data-theme="${escHtml(capture.theme)}" data-viewport="${escHtml(capture.viewport)}">${escHtml(capture.label)}</a></li>`
56
+ );
57
+ });
58
+
59
+ navHtml = `
60
+ <div class="section-group">
61
+ <div class="section-header" onclick="toggleSection(this)">
62
+ <span class="chevron">&#9660;</span> Auth
63
+ </div>
64
+ <ul class="section-list">
65
+ ${authItems.join('\n ')}
66
+ </ul>
67
+ </div>
68
+ <div class="section-group">
69
+ <div class="section-header" onclick="toggleSection(this)">
70
+ <span class="chevron">&#9660;</span> Pages
71
+ </div>
72
+ <ul class="section-list" id="pages-section-list">
73
+ ${pageItems.join('\n ')}
74
+ </ul>
75
+ </div>`;
76
+ } else {
77
+ // Flat nav — same as original behavior
78
+ const flatItems = pages.map((capture, i) => {
79
+ const idx = i + 1;
80
+ const pageId = `page-${idx}`;
81
+ return `<li data-theme="${escHtml(capture.theme)}" data-viewport="${escHtml(capture.viewport)}"><a href="#${pageId}" data-theme="${escHtml(capture.theme)}" data-viewport="${escHtml(capture.viewport)}">${escHtml(capture.label)}</a></li>`;
82
+ });
83
+ navHtml = `<ul id="nav-list">\n ${flatItems.join('\n ')}\n </ul>`;
84
+ }
85
+
86
+ const themes = [...new Set(appPages.map(p => p.theme))];
87
+ const viewports = [...new Set(appPages.map(p => p.viewport))];
36
88
 
37
89
  const themeCheckboxes = themes.map(t =>
38
90
  `<label><input type="checkbox" data-filter="theme" value="${escHtml(t)}" checked> ${escHtml(t)}</label>`
@@ -64,6 +116,19 @@ export async function assembleHtml(pages, config, outputDir) {
64
116
  #nav-list li a:hover, #nav-list li a.active { background: #1e1e1e; color: #fff; }
65
117
  #nav-list li[data-hidden] { display: none; }
66
118
 
119
+ .section-group { flex-shrink: 0; }
120
+ .section-header { position: sticky; top: 0; background: #111; z-index: 1; padding: 8px 16px; font-size: 10px; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 1px solid #2a2a2a; cursor: pointer; user-select: none; display: flex; align-items: center; gap: 6px; }
121
+ .section-header:hover { color: #bbb; }
122
+ .section-header .chevron { font-size: 8px; transition: transform 0.15s; display: inline-block; }
123
+ .section-header.collapsed .chevron { transform: rotate(-90deg); }
124
+ .section-list { list-style: none; margin: 0; padding: 4px 0; }
125
+ .section-list.collapsed { display: none; }
126
+ .section-list li a { display: block; padding: 5px 16px; font-size: 11px; color: #666; text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
127
+ .section-list li a:hover, .section-list li a.active { background: #1e1e1e; color: #fff; }
128
+ .section-list li[data-hidden] { display: none; }
129
+ .section-list li[data-section="auth"] a { color: #7a8aaa; }
130
+ .section-list li[data-section="auth"] a:hover { color: #aab8d4; background: #1e1e1e; }
131
+
67
132
  #content { margin-left: 240px; flex: 1; padding: 32px; min-width: 0; }
68
133
  .cover { padding: 48px 0 40px; border-bottom: 1px solid #2a2a2a; margin-bottom: 48px; }
69
134
  .cover h1 { font-size: 1.75rem; margin: 0 0 8px; font-weight: 600; }
@@ -73,6 +138,7 @@ export async function assembleHtml(pages, config, outputDir) {
73
138
  .page[data-hidden] { display: none; }
74
139
  .label { background: #0d0d0d; border: 1px solid #2a2a2a; border-bottom: none; padding: 8px 14px; font-size: 12px; color: #ccc; font-family: 'SF Mono', 'Fira Code', monospace; display: flex; align-items: center; gap: 10px; border-radius: 6px 6px 0 0; }
75
140
  .label .idx { background: #2a2a2a; color: #888; padding: 1px 6px; border-radius: 3px; font-size: 10px; min-width: 24px; text-align: center; }
141
+ .page[data-section="auth"] .label { background: #0d0f1a; border-color: #2a2e42; color: #9aadcc; }
76
142
  .page img { display: block; border: 1px solid #2a2a2a; border-radius: 0 0 6px 6px; box-shadow: 0 4px 24px rgba(0,0,0,0.4); }
77
143
  img[data-viewport="mobile"] { border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); }
78
144
 
@@ -94,9 +160,7 @@ export async function assembleHtml(pages, config, outputDir) {
94
160
  <div class="filter-group-label" style="margin-top:10px">Viewport</div>
95
161
  ${viewportCheckboxes}
96
162
  </div>
97
- <ul id="nav-list">
98
- ${navItems.join('\n ')}
99
- </ul>
163
+ ${navHtml}
100
164
  </nav>
101
165
  <main id="content">
102
166
  <div class="cover">
@@ -107,15 +171,30 @@ export async function assembleHtml(pages, config, outputDir) {
107
171
  ${pageSections.join('\n ')}
108
172
  </main>
109
173
  <script>
174
+ function toggleSection(header) {
175
+ header.classList.toggle('collapsed');
176
+ const list = header.nextElementSibling;
177
+ if (list && list.classList.contains('section-list')) {
178
+ list.classList.toggle('collapsed');
179
+ }
180
+ }
181
+
110
182
  function applyFilters() {
111
183
  const checked = { theme: new Set(), viewport: new Set() };
112
184
  document.querySelectorAll('input[data-filter]').forEach(cb => {
113
185
  if (cb.checked) checked[cb.dataset.filter].add(cb.value);
114
186
  });
115
- document.querySelectorAll('.page').forEach((page, i) => {
187
+ document.querySelectorAll('.page').forEach(page => {
188
+ // Auth captures are always visible — not part of the theme/viewport matrix
189
+ if (page.dataset.section === 'auth') return;
116
190
  const visible = checked.theme.has(page.dataset.theme) && checked.viewport.has(page.dataset.viewport);
117
191
  page.toggleAttribute('data-hidden', !visible);
118
- document.querySelectorAll('#nav-list li')[i]?.toggleAttribute('data-hidden', !visible);
192
+ // Also hide/show the corresponding nav item in the Pages section list
193
+ const link = document.querySelector('.section-list a[href="#' + page.id + '"]');
194
+ if (link) link.closest('li')?.toggleAttribute('data-hidden', !visible);
195
+ // Flat nav fallback
196
+ const flatLink = document.querySelector('#nav-list a[href="#' + page.id + '"]');
197
+ if (flatLink) flatLink.closest('li')?.toggleAttribute('data-hidden', !visible);
119
198
  });
120
199
  }
121
200
  document.querySelectorAll('input[data-filter]').forEach(cb => cb.addEventListener('change', applyFilters));
@@ -129,8 +208,8 @@ export async function assembleHtml(pages, config, outputDir) {
129
208
  else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') currentIdx = Math.max(currentIdx - 1, 0);
130
209
  else return;
131
210
  ps[currentIdx].scrollIntoView({ behavior: 'smooth', block: 'start' });
132
- document.querySelectorAll('#nav-list li a').forEach(a => a.classList.remove('active'));
133
- const link = document.querySelector('#nav-list li a[href="#' + ps[currentIdx].id + '"]');
211
+ document.querySelectorAll('.section-list li a, #nav-list li a').forEach(a => a.classList.remove('active'));
212
+ const link = document.querySelector('a[href="#' + ps[currentIdx].id + '"]');
134
213
  if (link) { link.classList.add('active'); link.scrollIntoView({ block: 'nearest' }); }
135
214
  });
136
215
 
@@ -138,7 +217,7 @@ export async function assembleHtml(pages, config, outputDir) {
138
217
  entries.forEach(entry => {
139
218
  if (entry.isIntersecting && !entry.target.hasAttribute('data-hidden')) {
140
219
  const id = entry.target.id;
141
- document.querySelectorAll('#nav-list li a').forEach(a => {
220
+ document.querySelectorAll('.section-list li a, #nav-list li a').forEach(a => {
142
221
  a.classList.toggle('active', a.getAttribute('href') === '#' + id);
143
222
  });
144
223
  }
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,111 @@ 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
+ }
317
+
318
+ const { storageStatePath, authCaptures } = await ensureAuth(config);
319
+ config._storageStatePath = storageStatePath;
156
320
 
157
- const storageStatePath = await ensureAuth(config);
158
- config._storageStatePath = storageStatePath;
321
+ printConfigSummary(config);
159
322
 
160
- if (args.authOnly) {
161
- console.log('[darshana] --auth-only done.');
162
- process.exit(0);
163
- }
323
+ if (args.authOnly) {
324
+ console.log('[darshana] --auth-only done.');
325
+ process.exit(0);
326
+ }
164
327
 
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);
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
+ }
346
+
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
+ }
352
+
353
+ const browser = await chromium.launch({
354
+ headless: true,
355
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
356
+ ...(config.capture.launchOptions ?? {}),
357
+ });
358
+
359
+ let captures;
174
360
  try {
175
- console.log(`[darshana] Crawling from ${config.url}${config.start} ...`);
176
- urls = await crawl(crawlContext, config);
361
+ captures = await captureAll(browser, config, urls);
177
362
  } finally {
178
- await crawlContext.close();
179
- await crawlBrowser.close();
363
+ await browser.close();
180
364
  }
181
- console.log(`[darshana] Crawl complete: ${urls.length} URLs found.`);
182
- }
183
365
 
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
- }
366
+ console.log(`\n[darshana] Captured ${captures.length} page(s).`);
189
367
 
190
- const browser = await chromium.launch({
191
- headless: true,
192
- args: ['--no-sandbox', '--disable-setuid-sandbox'],
193
- ...(config.capture.launchOptions ?? {}),
194
- });
368
+ const allCaptures = [...authCaptures, ...captures];
195
369
 
196
- let captures;
197
- try {
198
- captures = await captureAll(browser, config, urls);
199
- } finally {
200
- await browser.close();
201
- }
202
-
203
- console.log(`\n[darshana] Captured ${captures.length} page(s).`);
370
+ if (allCaptures.length === 0) {
371
+ console.error('[darshana] No pages captured. Exiting.');
372
+ process.exit(1);
373
+ }
204
374
 
205
- if (captures.length === 0) {
206
- console.error('[darshana] No pages captured. Exiting.');
207
- process.exit(1);
208
- }
375
+ const outputDir = config.outputDir;
376
+ fs.mkdirSync(outputDir, { recursive: true });
209
377
 
210
- const outputDir = config.outputDir;
211
- fs.mkdirSync(outputDir, { recursive: true });
378
+ const outputs = config.outputs ?? ['pdf', 'html'];
212
379
 
213
- const outputs = config.outputs ?? ['pdf', 'html'];
380
+ if (outputs.includes('pdf')) {
381
+ await assemblePdf(allCaptures, config, outputDir);
382
+ }
214
383
 
215
- if (outputs.includes('pdf')) {
216
- await assemblePdf(captures, config, outputDir);
217
- }
384
+ if (outputs.includes('html')) {
385
+ await assembleHtml(allCaptures, config, outputDir);
386
+ }
218
387
 
219
- if (outputs.includes('html')) {
220
- await assembleHtml(captures, config, outputDir);
221
- }
388
+ if (outputs.includes('images')) {
389
+ await writeImages(allCaptures, outputDir);
390
+ }
222
391
 
223
- if (outputs.includes('images')) {
224
- await writeImages(captures, outputDir);
392
+ console.log('\n[darshana] Done.');
225
393
  }
226
394
 
227
- console.log('\n[darshana] Done.');
395
+ main().catch(err => {
396
+ console.error('[darshana] Fatal:', err);
397
+ process.exit(1);
398
+ });
228
399
  }
229
400
 
230
401
  async function writeImages(captures, outputDir) {
@@ -257,8 +428,3 @@ function slugifyPathname(pathname) {
257
428
  .replace(/^-|-$/g, '')
258
429
  || 'root';
259
430
  }
260
-
261
- main().catch(err => {
262
- console.error('[darshana] Fatal:', err);
263
- process.exit(1);
264
- });
package/src/pdf.mjs CHANGED
@@ -4,6 +4,7 @@ import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
4
4
 
5
5
  const HEADER_HEIGHT = 28;
6
6
  const HEADER_BG = rgb(0, 0, 0);
7
+ const HEADER_BG_AUTH = rgb(0.25, 0.25, 0.35);
7
8
  const HEADER_FG = rgb(1, 1, 1);
8
9
  const FONT_SIZE = 11;
9
10
 
@@ -49,8 +50,9 @@ export async function assemblePdf(pages, config, outputDir) {
49
50
  page.drawImage(img, { x: 0, y: 0, width: pgWidth, height: imgHeight });
50
51
 
51
52
  // Header bar at top (y=0 is bottom in pdf-lib, so header sits at y=imgHeight)
53
+ const headerColor = capture.section === 'auth' ? HEADER_BG_AUTH : HEADER_BG;
52
54
  page.drawRectangle({
53
- x: 0, y: imgHeight, width: pgWidth, height: HEADER_HEIGHT, color: HEADER_BG,
55
+ x: 0, y: imgHeight, width: pgWidth, height: HEADER_HEIGHT, color: headerColor,
54
56
  });
55
57
  page.drawText(capture.label, {
56
58
  x: 8, y: imgHeight + 8, font, size: FONT_SIZE, color: HEADER_FG, maxWidth: pgWidth - 16,