#!/usr/bin/env ts-node /** * PDF Datasheet Generator (React-PDF) * * Renders PDFs via `@react-pdf/renderer`. * * Source of truth: * - All technical data + cross-section tables: Excel files in `data/excel/` * - Product description text: Fetched dynamically from Payload CMS */ // pg-pool in Node v24 leaves dangling promises when connection fails. // Suppress the crash so the script can fall back to Excel-only mode. process.on('unhandledRejection', () => {}); import * as fs from 'fs'; import * as path from 'path'; import * as XLSX from 'xlsx'; import { getPayload } from 'payload'; import configPromise from '@payload-config'; import { renderToBuffer } from '@react-pdf/renderer'; import * as React from 'react'; import type { ProductData as BaseProductData } from './pdf/model/types'; interface ProductData extends BaseProductData { voltageType: string; } import { buildDatasheetModel } from './pdf/model/build-datasheet-model'; import { loadImageAsPngDataUrl, loadQrAsPngDataUrl } from './pdf/react-pdf/assets'; import { generateFileName, normalizeValue, stripHtml } from './pdf/model/utils'; import { PDFDatasheet } from '../lib/pdf-datasheet'; const CONFIG = { outputDir: path.join(process.cwd(), 'public/datasheets'), chunkSize: 10, } as const; const EXCEL_FILES = [ { path: path.join(process.cwd(), 'data/excel/high-voltage.xlsx'), voltageType: 'high-voltage' }, { path: path.join(process.cwd(), 'data/excel/medium-voltage-KM.xlsx'), voltageType: 'medium-voltage', }, { path: path.join(process.cwd(), 'data/excel/low-voltage-KM.xlsx'), voltageType: 'low-voltage' }, { path: path.join(process.cwd(), 'data/excel/solar-cables.xlsx'), voltageType: 'solar' }, ] as const; type CmsProduct = { slug: string; title: string; sku: string; categories: string[]; images: string[]; descriptionHtml: string; applicationHtml: string; }; type CmsIndex = Map; // key: normalized designation/title function ensureOutputDir(): void { if (!fs.existsSync(CONFIG.outputDir)) { fs.mkdirSync(CONFIG.outputDir, { recursive: true }); } } function normalizeExcelKey(value: string): string { return String(value || '') .toUpperCase() .replace(/-\d+$/g, '') .replace(/[^A-Z0-9]+/g, ''); } async function buildCmsIndex(locale: 'en' | 'de'): Promise { const idx: CmsIndex = new Map(); try { // Attempt to connect to DB, but don't fail the whole script if it fails const payload = await getPayload({ config: configPromise }); const isDev = process.env.NODE_ENV === 'development'; const result = await payload.find({ collection: 'products', where: { ...(!isDev ? { _status: { equals: 'published' } } : {}), }, locale: locale as any, pagination: false, }); for (const doc of result.docs) { if (!doc.title) continue; const title = normalizeValue(String(doc.title)); const sku = normalizeValue(String(doc.sku || '')); const categories = Array.isArray(doc.categories) ? doc.categories.map((c: any) => normalizeValue(String(c.category || c))).filter(Boolean) : []; const images = Array.isArray(doc.images) ? doc.images .map((i: any) => normalizeValue(String(typeof i === 'string' ? i : i.url))) .filter(Boolean) : []; const descriptionHtml = normalizeValue(String(doc.description || '')); const applicationHtml = ''; const slug = doc.slug || ''; idx.set(normalizeExcelKey(title), { slug, title, sku, categories, images, descriptionHtml, applicationHtml, }); } } catch (error) { console.warn( `[Payload] Warning: Could not fetch CMS index (${locale}). Using Excel data only.`, error instanceof Error ? error.message : error, ); } return idx; } function findKeyByHeaderValue(headerRow: Record, pattern: RegExp): string | null { for (const [k, v] of Object.entries(headerRow || {})) { const text = normalizeValue(String(v ?? '')); if (!text) continue; if (pattern.test(text)) return k; } return null; } function readExcelRows(filePath: string): Array> { if (!fs.existsSync(filePath)) { console.warn(`[Excel] Warning: File not found: ${filePath}`); return []; } try { const data = fs.readFileSync(filePath); const workbook = XLSX.read(data, { type: 'buffer', cellDates: false, cellNF: false, cellText: false, }); const sheetName = workbook.SheetNames[0]; if (!sheetName) return []; const sheet = workbook.Sheets[sheetName]; if (!sheet) return []; return XLSX.utils.sheet_to_json(sheet, { defval: '', raw: false, blankrows: false, }) as Array>; } catch (err) { console.error(`[Excel] Failed to read ${filePath}:`, err); return []; } } function readDesignationsFromExcelFile(filePath: string): Map { const rows = readExcelRows(filePath); if (!rows.length) return new Map(); const headerRow = rows[0] || {}; const partNumberKey = (Object.prototype.hasOwnProperty.call(headerRow, 'Part Number') ? 'Part Number' : null) || findKeyByHeaderValue(headerRow, /^part\s*number$/i) || '__EMPTY'; const out = new Map(); for (const r of rows) { const pn = normalizeValue(String(r?.[partNumberKey] ?? '')); if (!pn || pn === 'Units' || pn === 'Part Number') continue; const key = normalizeExcelKey(pn); if (!key) continue; if (!out.has(key)) out.set(key, pn); } return out; } function loadAllExcelDesignations(): Map { const out = new Map(); for (const file of EXCEL_FILES) { const m = readDesignationsFromExcelFile(file.path); Array.from(m.entries()).forEach(([k, v]) => { if (!out.has(k)) out.set(k, { designation: v, voltageType: file.voltageType }); }); } return out; } async function loadProductsFromExcelAndCms(locale: 'en' | 'de'): Promise { const cmsIndex = await buildCmsIndex(locale); const excelDesignations = loadAllExcelDesignations(); const products: ProductData[] = []; let id = 1; Array.from(excelDesignations.entries()).forEach(([key, data]) => { const cmsItem = cmsIndex.get(key) || null; const title = cmsItem?.title || data.designation; const slug = cmsItem?.slug || title .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); const descriptionHtml = cmsItem?.descriptionHtml || ''; products.push({ id: id++, name: title, shortDescriptionHtml: '', descriptionHtml, images: cmsItem?.images || [], featuredImage: (cmsItem?.images && cmsItem.images[0]) || null, sku: cmsItem?.sku || title, slug, translationKey: slug, locale, categories: (cmsItem?.categories || []).map((name) => ({ name })), attributes: [], voltageType: (() => { const cats = (cmsItem?.categories || []).map((c) => String(c)); const isMV = cats.some((c) => /medium[-\s]?voltage|mittelspannung/i.test(c)); if (isMV && data.voltageType === 'high-voltage') return 'medium-voltage'; return data.voltageType; })(), }); }); products.sort( (a, b) => (a.slug || '').localeCompare(b.slug || '') || a.name.localeCompare(b.name), ); return products.filter((p) => stripHtml(p.name)); } async function processChunk( products: ProductData[], chunkIndex: number, totalChunks: number, ): Promise { console.log( `\nProcessing chunk ${chunkIndex + 1}/${totalChunks} (${products.length} products)...`, ); for (const product of products) { try { const locale = (product.locale || 'en') as 'en' | 'de'; console.log(`[${product.id}] Starting: ${product.name} (${locale})`); const model = buildDatasheetModel({ product, locale }); if (!model) { console.warn(`[${product.id}] Warning: buildDatasheetModel returned nothing`); continue; } // Load assets as Data URLs for React-PDF const [heroDataUrl, logoDataUrl] = await Promise.all([ loadImageAsPngDataUrl(model.product.heroSrc), loadImageAsPngDataUrl('/logo-black.svg'), ]); const fileName = generateFileName(product, locale); const voltageType = (product as any).voltageType || 'other'; const subfolder = path.join(CONFIG.outputDir, voltageType); if (!fs.existsSync(subfolder)) { fs.mkdirSync(subfolder, { recursive: true }); } // Render using the unified component const element = ( ); const buffer = await renderToBuffer(element); fs.writeFileSync(path.join(subfolder, fileName), buffer); console.log(`āœ“ ${locale.toUpperCase()}: ${voltageType}/${fileName}`); await new Promise((resolve) => setTimeout(resolve, 25)); } catch (error) { console.error(`āœ— Failed to process product ${product.id} (${product.name}):`, error); } } } async function processProductsInChunks(): Promise { console.log('Starting PDF generation (React-PDF - Unified Component)'); ensureOutputDir(); const onlyLocale = normalizeValue(String(process.env.PDF_LOCALE || '')).toLowerCase(); const locales: Array<'en' | 'de'> = onlyLocale === 'de' || onlyLocale === 'en' ? [onlyLocale] : ['en', 'de']; const allProducts: ProductData[] = []; for (const locale of locales) { const products = await loadProductsFromExcelAndCms(locale); allProducts.push(...products); } if (allProducts.length === 0) { console.log('No products found'); return; } let products = allProducts; const match = normalizeValue(String(process.env.PDF_MATCH || '')).toLowerCase(); if (match) { products = products.filter((p) => { const hay = [p.slug, p.translationKey, p.sku, p.name].filter(Boolean).join(' ').toLowerCase(); return hay.includes(match); }); } const limit = Number(process.env.PDF_LIMIT || '0'); products = Number.isFinite(limit) && limit > 0 ? products.slice(0, limit) : products; const enProducts = products.filter((p) => (p.locale || 'en') === 'en'); const deProducts = products.filter((p) => (p.locale || 'en') === 'de'); console.log(`Found ${enProducts.length} EN + ${deProducts.length} DE products`); const totalChunks = Math.ceil(products.length / CONFIG.chunkSize); for (let i = 0; i < totalChunks; i++) { const chunk = products.slice(i * CONFIG.chunkSize, (i + 1) * CONFIG.chunkSize); await processChunk(chunk, i, totalChunks); } console.log('\nāœ… PDF generation completed!'); console.log(`Generated ${enProducts.length} EN + ${deProducts.length} DE PDFs`); console.log(`Output: ${CONFIG.outputDir}`); } async function main(): Promise { const start = Date.now(); try { await processProductsInChunks(); console.log(`\nTime: ${((Date.now() - start) / 1000).toFixed(2)}s`); } catch (error) { console.error('Fatal error:', error); process.exit(1); } } main().catch(console.error); export { main as generatePDFDatasheets };