import puppeteer from 'puppeteer'; import { resolve, basename, dirname } from 'path'; import { existsSync, mkdirSync } from 'fs'; import { fileURLToPath } from 'url'; import { createServer } from 'http'; import { readFile } from 'fs/promises'; import { extname } from 'path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = resolve(__dirname, '../..'); const OUTPUT_DIR = resolve(PROJECT_ROOT, 'output/pdf'); const MIME_TYPES = { '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', '.mjs': 'application/javascript', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.json': 'application/json', }; async function generatePdf(htmlPath) { const absolutePath = resolve(htmlPath); if (!existsSync(absolutePath)) { console.error(`File not found: ${absolutePath}`); process.exit(1); } mkdirSync(OUTPUT_DIR, { recursive: true }); // Strip .output and .template from PDF filename let pdfName = basename(absolutePath, '.html'); pdfName = pdfName.replace(/\.(output|template)$/, '') + '.pdf'; const pdfPath = resolve(OUTPUT_DIR, pdfName); // Start temp HTTP server to resolve /assets/ paths correctly const server = createServer(async (req, res) => { const filePath = resolve(PROJECT_ROOT, decodeURIComponent(req.url).replace(/^\//, '')); try { const data = await readFile(filePath); const ext = extname(filePath); res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' }); res.end(data); } catch { res.writeHead(404); res.end('Not found'); } }); await new Promise(r => server.listen(0, '127.0.0.1', r)); const port = server.address().port; const relPath = absolutePath.replace(PROJECT_ROOT + '/', ''); const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] }); const page = await browser.newPage(); await page.goto(`http://127.0.0.1:${port}/${relPath}`, { waitUntil: 'networkidle0', timeout: 30000 }); // Theme custom elements load via dynamic import inside the per-theme loader. // networkidle0 doesn't wait for the post-import upgrade + shadow-DOM image fetch, // so we explicitly wait for any used custom elements to be defined and their // shadow-DOM images to finish loading. const hasCustomTags = await page.evaluate(() => [...document.querySelectorAll('*')].some(el => el.tagName.includes('-')) ); if (hasCustomTags) { await page.waitForFunction(() => { const customs = [...document.querySelectorAll('*')].filter(el => el.tagName.includes('-')); return customs.every(el => { const root = el.shadowRoot; if (!root) return false; const imgs = root.querySelectorAll('img'); if (imgs.length === 0) return false; return [...imgs].every(img => img.complete && img.naturalWidth > 0); }); }, { timeout: 15000, polling: 100 }); } await page.pdf({ path: pdfPath, format: 'A4', printBackground: true, margin: { top: 0, right: 0, bottom: 0, left: 0 }, preferCSSPageSize: true, }); await browser.close(); server.close(); console.log(`PDF generated: ${pdfPath}`); return pdfPath; } const htmlFile = process.argv[2]; if (!htmlFile) { console.error('Usage: node generate-pdf.mjs '); process.exit(1); } generatePdf(htmlFile);