@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 +6 -1
- package/package.json +1 -1
- package/src/auth.mjs +91 -5
- package/src/capture.mjs +11 -6
- package/src/config.mjs +31 -0
- package/src/html.mjs +96 -17
- package/src/index.mjs +239 -73
- package/src/pdf.mjs +3 -1
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
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
|
-
|
|
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(
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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">▼</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">▼</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
|
-
|
|
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(
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
config._storageStatePath = storageStatePath;
|
|
321
|
+
printConfigSummary(config);
|
|
159
322
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
323
|
+
if (args.authOnly) {
|
|
324
|
+
console.log('[darshana] --auth-only done.');
|
|
325
|
+
process.exit(0);
|
|
326
|
+
}
|
|
164
327
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
176
|
-
urls = await crawl(crawlContext, config);
|
|
361
|
+
captures = await captureAll(browser, config, urls);
|
|
177
362
|
} finally {
|
|
178
|
-
await
|
|
179
|
-
await crawlBrowser.close();
|
|
363
|
+
await browser.close();
|
|
180
364
|
}
|
|
181
|
-
console.log(`[darshana] Crawl complete: ${urls.length} URLs found.`);
|
|
182
|
-
}
|
|
183
365
|
|
|
184
|
-
|
|
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
|
-
|
|
191
|
-
headless: true,
|
|
192
|
-
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
193
|
-
...(config.capture.launchOptions ?? {}),
|
|
194
|
-
});
|
|
368
|
+
const allCaptures = [...authCaptures, ...captures];
|
|
195
369
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
process.exit(1);
|
|
208
|
-
}
|
|
375
|
+
const outputDir = config.outputDir;
|
|
376
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
209
377
|
|
|
210
|
-
|
|
211
|
-
fs.mkdirSync(outputDir, { recursive: true });
|
|
378
|
+
const outputs = config.outputs ?? ['pdf', 'html'];
|
|
212
379
|
|
|
213
|
-
|
|
380
|
+
if (outputs.includes('pdf')) {
|
|
381
|
+
await assemblePdf(allCaptures, config, outputDir);
|
|
382
|
+
}
|
|
214
383
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
384
|
+
if (outputs.includes('html')) {
|
|
385
|
+
await assembleHtml(allCaptures, config, outputDir);
|
|
386
|
+
}
|
|
218
387
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
388
|
+
if (outputs.includes('images')) {
|
|
389
|
+
await writeImages(allCaptures, outputDir);
|
|
390
|
+
}
|
|
222
391
|
|
|
223
|
-
|
|
224
|
-
await writeImages(captures, outputDir);
|
|
392
|
+
console.log('\n[darshana] Done.');
|
|
225
393
|
}
|
|
226
394
|
|
|
227
|
-
|
|
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:
|
|
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,
|