@opendatalabs/darshana 1.3.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendatalabs/darshana",
3
- "version": "1.3.0",
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/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
@@ -315,7 +315,7 @@ function runMain() {
315
315
  config = configFromArgs(args);
316
316
  }
317
317
 
318
- const storageStatePath = await ensureAuth(config);
318
+ const { storageStatePath, authCaptures } = await ensureAuth(config);
319
319
  config._storageStatePath = storageStatePath;
320
320
 
321
321
  printConfigSummary(config);
@@ -365,7 +365,9 @@ function runMain() {
365
365
 
366
366
  console.log(`\n[darshana] Captured ${captures.length} page(s).`);
367
367
 
368
- if (captures.length === 0) {
368
+ const allCaptures = [...authCaptures, ...captures];
369
+
370
+ if (allCaptures.length === 0) {
369
371
  console.error('[darshana] No pages captured. Exiting.');
370
372
  process.exit(1);
371
373
  }
@@ -376,15 +378,15 @@ function runMain() {
376
378
  const outputs = config.outputs ?? ['pdf', 'html'];
377
379
 
378
380
  if (outputs.includes('pdf')) {
379
- await assemblePdf(captures, config, outputDir);
381
+ await assemblePdf(allCaptures, config, outputDir);
380
382
  }
381
383
 
382
384
  if (outputs.includes('html')) {
383
- await assembleHtml(captures, config, outputDir);
385
+ await assembleHtml(allCaptures, config, outputDir);
384
386
  }
385
387
 
386
388
  if (outputs.includes('images')) {
387
- await writeImages(captures, outputDir);
389
+ await writeImages(allCaptures, outputDir);
388
390
  }
389
391
 
390
392
  console.log('\n[darshana] Done.');
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,