| | #!/usr/bin/env node |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | import { spawn } from 'node:child_process'; |
| | import { setTimeout as delay } from 'node:timers/promises'; |
| | import { chromium } from 'playwright'; |
| | import { resolve, dirname } from 'node:path'; |
| | import { promises as fs } from 'node:fs'; |
| | import { fileURLToPath } from 'node:url'; |
| | import process from 'node:process'; |
| |
|
| | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| |
|
| | |
| | |
| | |
| |
|
| | async function run(command, args = [], options = {}) { |
| | return new Promise((resolvePromise, reject) => { |
| | const child = spawn(command, args, { stdio: 'inherit', shell: false, ...options }); |
| | child.on('error', reject); |
| | child.on('exit', (code) => { |
| | if (code === 0) resolvePromise(undefined); |
| | else reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`)); |
| | }); |
| | }); |
| | } |
| |
|
| | async function waitForServer(url, timeoutMs = 60000) { |
| | const start = Date.now(); |
| | while (Date.now() - start < timeoutMs) { |
| | try { |
| | const res = await fetch(url); |
| | if (res.ok) return; |
| | } catch { } |
| | await delay(500); |
| | } |
| | throw new Error(`Server did not start in time: ${url}`); |
| | } |
| |
|
| | function parseArgs(argv) { |
| | const out = {}; |
| | for (const arg of argv.slice(2)) { |
| | if (!arg.startsWith('--')) continue; |
| | const [k, v] = arg.replace(/^--/, '').split('='); |
| | out[k] = v === undefined ? true : v; |
| | } |
| | return out; |
| | } |
| |
|
| | function slugify(text) { |
| | return String(text || '') |
| | .normalize('NFKD') |
| | .replace(/\p{Diacritic}+/gu, '') |
| | .toLowerCase() |
| | .replace(/[^a-z0-9]+/g, '-') |
| | .replace(/^-+|-+$/g, '') |
| | .slice(0, 120) || 'article'; |
| | } |
| |
|
| | async function waitForImages(page, timeoutMs = 15000) { |
| | await page.evaluate(async (timeout) => { |
| | const deadline = Date.now() + timeout; |
| | const imgs = Array.from(document.images || []); |
| | const unloaded = imgs.filter(img => !img.complete || (img.naturalWidth === 0)); |
| | await Promise.race([ |
| | Promise.all(unloaded.map(img => new Promise(res => { |
| | if (img.complete && img.naturalWidth !== 0) return res(undefined); |
| | img.addEventListener('load', () => res(undefined), { once: true }); |
| | img.addEventListener('error', () => res(undefined), { once: true }); |
| | }))), |
| | new Promise(res => setTimeout(res, Math.max(0, deadline - Date.now()))) |
| | ]); |
| | }, timeoutMs); |
| | } |
| |
|
| | async function waitForPlotly(page, timeoutMs = 20000) { |
| | await page.evaluate(async (timeout) => { |
| | const start = Date.now(); |
| | const hasPlots = () => Array.from(document.querySelectorAll('.js-plotly-plot')).length > 0; |
| | while (!hasPlots() && (Date.now() - start) < timeout) { |
| | await new Promise(r => setTimeout(r, 200)); |
| | } |
| | const deadline = start + timeout; |
| | const allReady = () => Array.from(document.querySelectorAll('.js-plotly-plot')).every(el => el.querySelector('svg.main-svg')); |
| | while (!allReady() && Date.now() < deadline) { |
| | await new Promise(r => setTimeout(r, 200)); |
| | } |
| | }, timeoutMs); |
| | } |
| |
|
| | async function waitForD3(page, timeoutMs = 20000) { |
| | await page.evaluate(async (timeout) => { |
| | const start = Date.now(); |
| | const isReady = () => { |
| | const hero = document.querySelector('.hero-banner'); |
| | if (hero) { |
| | return !!hero.querySelector('svg circle, svg path, svg rect, svg g'); |
| | } |
| | const containers = [ |
| | ...Array.from(document.querySelectorAll('.d3-line')), |
| | ...Array.from(document.querySelectorAll('.d3-bar')) |
| | ]; |
| | if (!containers.length) return true; |
| | return containers.every(c => c.querySelector('svg circle, svg path, svg rect, svg g')); |
| | }; |
| | while (!isReady() && (Date.now() - start) < timeout) { |
| | await new Promise(r => setTimeout(r, 200)); |
| | } |
| | }, timeoutMs); |
| | } |
| |
|
| | async function waitForStableLayout(page, timeoutMs = 5000) { |
| | const start = Date.now(); |
| | let last = await page.evaluate(() => document.scrollingElement ? document.scrollingElement.scrollHeight : document.body.scrollHeight); |
| | let stableCount = 0; |
| | while ((Date.now() - start) < timeoutMs && stableCount < 3) { |
| | await page.waitForTimeout(250); |
| | const now = await page.evaluate(() => document.scrollingElement ? document.scrollingElement.scrollHeight : document.body.scrollHeight); |
| | if (now === last) stableCount += 1; else { stableCount = 0; last = now; } |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| |
|
| | async function main() { |
| | const cwd = process.cwd(); |
| | const port = Number(process.env.PREVIEW_PORT || 8080); |
| | const baseUrl = `http://127.0.0.1:${port}/`; |
| | const args = parseArgs(process.argv); |
| | |
| | const theme = (args.theme === 'dark' || args.theme === 'light') ? args.theme : 'light'; |
| | const format = args.format || 'A4'; |
| | const wait = args.wait || 'full'; |
| | |
| | let outFileBase = (args.filename && String(args.filename).replace(/\.pdf$/i, '')) || 'article-book'; |
| | |
| | |
| | const distDir = resolve(cwd, 'dist'); |
| | let hasDist = false; |
| | try { |
| | const st = await fs.stat(distDir); |
| | hasDist = st && st.isDirectory(); |
| | } catch { } |
| | |
| | if (!hasDist) { |
| | console.log('📦 Building Astro site…'); |
| | await run('npm', ['run', 'build']); |
| | } else { |
| | console.log('✓ Using existing dist/ build'); |
| | } |
| | |
| | console.log('🚀 Starting Astro preview server…'); |
| | const preview = spawn('npm', ['run', 'preview'], { cwd, stdio: 'inherit', detached: true }); |
| | const previewExit = new Promise((resolvePreview) => { |
| | preview.on('close', (code, signal) => resolvePreview({ code, signal })); |
| | }); |
| | |
| | try { |
| | await waitForServer(baseUrl, 60000); |
| | console.log('✓ Server ready'); |
| | |
| | console.log('📖 Launching browser with Paged.js…'); |
| | const browser = await chromium.launch({ headless: true }); |
| | |
| | try { |
| | const context = await browser.newContext(); |
| | |
| | |
| | await context.addInitScript((desired) => { |
| | try { |
| | localStorage.setItem('theme', desired); |
| | if (document && document.documentElement) { |
| | document.documentElement.dataset.theme = desired; |
| | } |
| | } catch { } |
| | }, theme); |
| | |
| | const page = await context.newPage(); |
| | |
| | |
| | await page.setViewportSize({ width: 1200, height: 1600 }); |
| | |
| | console.log('📄 Loading page…'); |
| | await page.goto(baseUrl, { waitUntil: 'load', timeout: 60000 }); |
| | |
| | |
| | try { await page.waitForFunction(() => !!window.Plotly, { timeout: 8000 }); } catch { } |
| | try { await page.waitForFunction(() => !!window.d3, { timeout: 8000 }); } catch { } |
| | |
| | |
| | if (!args.filename) { |
| | const fromBtn = await page.evaluate(() => { |
| | const btn = document.getElementById('download-pdf-btn'); |
| | const f = btn ? btn.getAttribute('data-pdf-filename') : null; |
| | return f || ''; |
| | }); |
| | if (fromBtn) { |
| | outFileBase = String(fromBtn).replace(/\.pdf$/i, '') + '-book'; |
| | } else { |
| | const title = await page.evaluate(() => { |
| | const h1 = document.querySelector('h1.hero-title'); |
| | const t = h1 ? h1.textContent : document.title; |
| | return (t || '').replace(/\s+/g, ' ').trim(); |
| | }); |
| | outFileBase = slugify(title) + '-book'; |
| | } |
| | } |
| | |
| | |
| | if (wait === 'images' || wait === 'full') { |
| | console.log('⏳ Waiting for images…'); |
| | await waitForImages(page); |
| | } |
| | if (wait === 'd3' || wait === 'full') { |
| | console.log('⏳ Waiting for D3…'); |
| | await waitForD3(page); |
| | } |
| | if (wait === 'plotly' || wait === 'full') { |
| | console.log('⏳ Waiting for Plotly…'); |
| | await waitForPlotly(page); |
| | } |
| | if (wait === 'full') { |
| | await waitForStableLayout(page); |
| | } |
| | |
| | |
| | await page.emulateMedia({ media: 'print' }); |
| | |
| | console.log('📚 Injecting Paged.js…'); |
| | |
| | |
| | const bookCssPath = resolve(__dirname, '..', 'src', 'styles', '_print-book.css'); |
| | const bookCss = await fs.readFile(bookCssPath, 'utf-8'); |
| | await page.addStyleTag({ content: bookCss }); |
| | |
| | |
| | const pagedJsPath = resolve(cwd, 'node_modules', 'pagedjs', 'dist', 'paged.polyfill.js'); |
| | await page.addScriptTag({ path: pagedJsPath }); |
| | |
| | console.log('⏳ Running Paged.js pagination…'); |
| | |
| | |
| | await page.evaluate(async () => { |
| | if (window.Paged && window.Paged.Previewer) { |
| | const previewer = new window.Paged.Previewer(); |
| | await previewer.preview(); |
| | } |
| | }); |
| | |
| | |
| | await page.waitForFunction(() => { |
| | const pages = document.querySelectorAll('.pagedjs_page'); |
| | return pages && pages.length > 0; |
| | }, { timeout: 60000 }); |
| | |
| | |
| | await page.waitForTimeout(2000); |
| | |
| | console.log('✓ Pagination complete'); |
| | |
| | |
| | const pageInfo = await page.evaluate(() => { |
| | const pages = document.querySelectorAll('.pagedjs_page'); |
| | return { |
| | totalPages: pages.length, |
| | hasContent: pages.length > 0 |
| | }; |
| | }); |
| | |
| | console.log(`📄 Generated ${pageInfo.totalPages} pages`); |
| | |
| | |
| | const outPath = resolve(cwd, 'dist', `${outFileBase}.pdf`); |
| | |
| | console.log('🖨️ Generating PDF…'); |
| | |
| | await page.pdf({ |
| | path: outPath, |
| | format, |
| | printBackground: true, |
| | preferCSSPageSize: true, |
| | margin: { top: 0, right: 0, bottom: 0, left: 0 } |
| | }); |
| | |
| | console.log(`✅ PDF generated: ${outPath}`); |
| | |
| | |
| | const publicPath = resolve(cwd, 'public', `${outFileBase}.pdf`); |
| | try { |
| | await fs.mkdir(resolve(cwd, 'public'), { recursive: true }); |
| | await fs.copyFile(outPath, publicPath); |
| | console.log(`✅ PDF copied to: ${publicPath}`); |
| | } catch (e) { |
| | console.warn('⚠️ Unable to copy PDF to public/:', e?.message || e); |
| | } |
| | |
| | } finally { |
| | await browser.close(); |
| | } |
| | |
| | } finally { |
| | |
| | console.log('🛑 Stopping preview server…'); |
| | try { |
| | if (process.platform !== 'win32') { |
| | try { process.kill(-preview.pid, 'SIGINT'); } catch { } |
| | } |
| | try { preview.kill('SIGINT'); } catch { } |
| | await Promise.race([previewExit, delay(3000)]); |
| | |
| | if (!preview.killed) { |
| | try { |
| | if (process.platform !== 'win32') { |
| | try { process.kill(-preview.pid, 'SIGKILL'); } catch { } |
| | } |
| | try { preview.kill('SIGKILL'); } catch { } |
| | } catch { } |
| | await Promise.race([previewExit, delay(1000)]); |
| | } |
| | } catch { } |
| | } |
| | |
| | console.log(''); |
| | console.log('╔═══════════════════════════════════════════════════════════════╗'); |
| | console.log('║ 📚 PDF BOOK GENERATED! 📚 ║'); |
| | console.log('╚═══════════════════════════════════════════════════════════════╝'); |
| | console.log(''); |
| | } |
| |
|
| | main().catch((err) => { |
| | console.error('❌ Error:', err); |
| | process.exit(1); |
| | }); |
| |
|
| |
|