cv-generator.js
1 #!/usr/bin/env node 2 3 /** 4 * CV Website Generator 5 * 6 * Generates the complete CV website by combining base CV data, GitHub activity 7 * metrics, and AI enhancements into a production-ready static site. 8 * 9 * Features: 10 * - Dynamic content compilation from multiple data sources 11 * - Responsive HTML generation with optimized assets 12 * - GitHub Pages deployment preparation 13 * - SEO optimization and meta tag generation 14 * - Performance optimization and asset bundling 15 * 16 * Usage: node cv-generator.js 17 * Output: ./dist/ directory with complete website 18 */ 19 20 const fs = require('fs').promises; 21 const puppeteer = require('puppeteer'); 22 const path = require('path'); 23 24 // Determine root directory by checking for project-specific files 25 // We look for index.html as the definitive indicator of project root 26 let rootPrefix = '.'; 27 28 // Check if index.html exists in current directory (we're in project root) 29 if (require('fs').existsSync(path.join(process.cwd(), 'index.html'))) { 30 rootPrefix = '.'; 31 } else if (require('fs').existsSync(path.join(process.cwd(), '../../index.html'))) { 32 // We're likely in .github/scripts 33 rootPrefix = '../..'; 34 } else { 35 // Try to find index.html by walking up the directory tree 36 let currentDir = process.cwd(); 37 let levelsUp = 0; 38 while (levelsUp < 5) { 39 if (require('fs').existsSync(path.join(currentDir, 'index.html'))) { 40 rootPrefix = '../'.repeat(levelsUp) || '.'; 41 break; 42 } 43 currentDir = path.dirname(currentDir); 44 levelsUp++; 45 } 46 } 47 48 // Configuration 49 const CONFIG = { 50 INPUT_DIR: rootPrefix, 51 OUTPUT_DIR: path.join(rootPrefix, 'dist'), 52 DATA_DIR: path.join(rootPrefix, 'data'), 53 ASSETS_DIR: path.join(rootPrefix, 'assets'), 54 TEMPLATE_FILE: 'index.html', 55 SITE_URL: 'https://cv.adrianwedd.com', 56 GITHUB_USERNAME: 'adrianwedd' 57 }; 58 59 /** 60 * CV Website Generator 61 * 62 * Compiles all CV data sources into a production-ready website 63 */ 64 class CVGenerator { 65 constructor() { 66 this.generationStartTime = Date.now(); 67 this.cvData = {}; 68 this.activityData = {}; 69 this.aiEnhancements = {}; 70 } 71 72 /** 73 * Generate complete CV website 74 */ 75 async generate() { 76 console.log('π¨ **CV WEBSITE GENERATOR INITIATED**'); 77 console.log(`π Output directory: ${CONFIG.OUTPUT_DIR}`); 78 console.log(''); 79 80 try { 81 // Prepare output directory 82 await this.prepareOutputDirectory(); 83 84 // Load all data sources 85 await this.loadDataSources(); 86 87 // Generate website components 88 await this.generateHTML(); 89 await this.copyAssets(); 90 await this.generatePDF(); // New call 91 await this.generateATSPDF(); 92 await this.generateSitemap(); 93 await this.generateRobotsTxt(); 94 95 // Generate additional files for GitHub Pages 96 await this.generateGitHubPagesFiles(); 97 98 const generationTime = ((Date.now() - this.generationStartTime) / 1000).toFixed(2); 99 console.log(`β CV website generated in ${generationTime}s`); 100 console.log(`π Website ready at: ${CONFIG.OUTPUT_DIR}/`); 101 console.log(`π Deploy to: ${CONFIG.SITE_URL}`); 102 103 } catch (genError) { 104 console.error('β Website generation failed:', genError.message); 105 throw genError; 106 } 107 } 108 109 /** 110 * Prepare output directory 111 */ 112 async prepareOutputDirectory() { 113 console.log('π Preparing output directory...'); 114 115 try { 116 // Remove existing directory 117 await fs.rm(CONFIG.OUTPUT_DIR, { recursive: true, force: true }); 118 119 // Create fresh directory 120 await fs.mkdir(CONFIG.OUTPUT_DIR, { recursive: true }); 121 122 console.log(`β Output directory prepared: ${CONFIG.OUTPUT_DIR}`); 123 } catch (error) { 124 console.error('β Failed to prepare output directory:', error.message); 125 throw error; 126 } 127 } 128 129 /** 130 * Load all data sources with validation and fallback mechanisms 131 */ 132 async loadDataSources() { 133 console.log('π Loading data sources...'); 134 135 try { 136 // Load base CV data 137 try { 138 const cvDataPath = path.join(CONFIG.DATA_DIR, 'base-cv.json'); 139 const cvDataContent = await fs.readFile(cvDataPath, 'utf8'); 140 this.cvData = JSON.parse(cvDataContent); 141 this.validateCVData(); 142 console.log('β Base CV data loaded and validated'); 143 } catch { 144 console.warn('β οΈ Base CV data not found, using defaults'); 145 this.cvData = this.getDefaultCVData(); 146 } 147 148 // Load activity data with validation 149 try { 150 const activityPath = path.join(CONFIG.DATA_DIR, 'activity-summary.json'); 151 const activityContent = await fs.readFile(activityPath, 'utf8'); 152 this.activityData = JSON.parse(activityContent); 153 this.validateActivityData(); 154 console.log('β Activity data loaded and validated'); 155 } catch (error) { 156 console.warn('β οΈ Activity data not found, using fallback'); 157 this.activityData = this.getDefaultActivityData(); 158 } 159 160 // Load AI enhancements 161 try { 162 const aiPath = path.join(CONFIG.DATA_DIR, 'ai-enhancements.json'); 163 const aiContent = await fs.readFile(aiPath, 'utf8'); 164 this.aiEnhancements = JSON.parse(aiContent); 165 console.log('β AI enhancements loaded'); 166 } catch (error) { 167 console.warn('β οΈ AI enhancements not found'); 168 this.aiEnhancements = {}; 169 } 170 171 // Log data integrity status 172 this.logDataIntegrityStatus(); 173 174 } catch (error) { 175 console.error('β Failed to load data sources:', error.message); 176 throw error; 177 } 178 } 179 180 /** 181 * Validate CV data structure and content 182 */ 183 validateCVData() { 184 if (!this.cvData.personal_info) { 185 console.warn('β οΈ Missing personal_info in CV data'); 186 this.cvData.personal_info = {}; 187 } 188 189 if (!this.cvData.skills || !Array.isArray(this.cvData.skills)) { 190 console.warn('β οΈ Missing or invalid skills array in CV data'); 191 this.cvData.skills = []; 192 } 193 194 if (!this.cvData.projects || !Array.isArray(this.cvData.projects)) { 195 console.warn('β οΈ Missing or invalid projects array in CV data'); 196 this.cvData.projects = []; 197 } 198 } 199 200 /** 201 * Validate activity data and sanitize metrics 202 */ 203 validateActivityData() { 204 // Ensure summary object exists 205 if (!this.activityData.summary) { 206 console.warn('β οΈ Missing summary in activity data'); 207 this.activityData.summary = {}; 208 } 209 210 const summary = this.activityData.summary; 211 212 // Validate and sanitize commit count 213 if (typeof summary.total_commits !== 'number' || summary.total_commits < 0) { 214 console.warn(`β οΈ Invalid commit count: ${summary.total_commits}, setting to 0`); 215 summary.total_commits = 0; 216 } 217 218 // Validate and sanitize lines contributed 219 if (typeof summary.net_lines_contributed !== 'number' || summary.net_lines_contributed < 0) { 220 console.warn(`β οΈ Invalid lines contributed: ${summary.net_lines_contributed}, setting to 0`); 221 summary.net_lines_contributed = 0; 222 } 223 224 // Validate active days 225 if (typeof summary.active_days !== 'number' || summary.active_days < 0) { 226 console.warn(`β οΈ Invalid active days: ${summary.active_days}, setting to 0`); 227 summary.active_days = 0; 228 } 229 230 // Ensure reasonable limits (data integrity check) 231 const maxReasonableCommits = 1000; // 1000 commits in 30 days is extremely high but possible 232 const maxReasonableLines = 1000000; // 1M lines in 30 days is unrealistic 233 234 if (summary.total_commits > maxReasonableCommits) { 235 console.warn(`β οΈ Unrealistic commit count: ${summary.total_commits}, capping at ${maxReasonableCommits}`); 236 summary.total_commits = maxReasonableCommits; 237 } 238 239 if (summary.net_lines_contributed > maxReasonableLines) { 240 console.warn(`β οΈ Unrealistic lines contributed: ${summary.net_lines_contributed}, capping at ${maxReasonableLines}`); 241 summary.net_lines_contributed = maxReasonableLines; 242 } 243 } 244 245 /** 246 * Get default activity data when real data is unavailable 247 */ 248 getDefaultActivityData() { 249 return { 250 last_updated: new Date().toISOString(), 251 tracker_version: "fallback", 252 analysis_depth: "basic", 253 lookback_period_days: 30, 254 summary: { 255 total_commits: 0, 256 active_days: 0, 257 net_lines_contributed: 0, 258 tracking_status: "fallback" 259 }, 260 cv_integration: { 261 ready_for_enhancement: false, 262 data_freshness: new Date().toISOString(), 263 next_cv_update: "Requires GitHub data collection" 264 } 265 }; 266 } 267 268 /** 269 * Log data integrity status for monitoring 270 */ 271 logDataIntegrityStatus() { 272 const hasRealGitHubData = this.activityData?.summary?.total_commits > 0; 273 const hasValidMetrics = this.activityData?.summary?.net_lines_contributed > 0; 274 const dataFreshness = this.activityData?.cv_integration?.data_freshness; 275 276 if (hasRealGitHubData && hasValidMetrics) { 277 console.log(`β Data integrity: Excellent - Using verified GitHub data (${this.activityData.summary.total_commits} commits, ${this.activityData.summary.net_lines_contributed} lines)`); 278 } else if (hasRealGitHubData) { 279 console.log(`β οΈ Data integrity: Good - Using GitHub data with limited metrics`); 280 } else { 281 console.log(`β Data integrity: Fallback - Using placeholder data (GitHub data unavailable)`); 282 } 283 284 if (dataFreshness) { 285 const freshness = Math.round((Date.now() - new Date(dataFreshness).getTime()) / (1000 * 60)); 286 console.log(`π Data freshness: ${freshness} minutes old`); 287 } 288 } 289 290 /** 291 * Generate HTML file with dynamic content 292 */ 293 async generateHTML() { 294 console.log('π¨ Generating HTML...'); 295 296 try { 297 // Read template HTML 298 const templatePath = path.join(CONFIG.INPUT_DIR, CONFIG.TEMPLATE_FILE); 299 let htmlContent = await fs.readFile(templatePath, 'utf8'); 300 301 // Process template with data 302 htmlContent = await this.processHTMLTemplate(htmlContent); 303 304 // Write processed HTML 305 const outputPath = path.join(CONFIG.OUTPUT_DIR, 'index.html'); 306 await fs.writeFile(outputPath, htmlContent, 'utf8'); 307 308 console.log('β HTML generated successfully'); 309 310 } catch (error) { 311 console.error('β HTML generation failed:', error.message); 312 throw error; 313 } 314 } 315 316 /** 317 * Process HTML template with dynamic data 318 */ 319 async processHTMLTemplate(htmlContent) { 320 // Update meta tags 321 htmlContent = this.updateMetaTags(htmlContent); 322 323 // Update structured data with GitHub-enhanced skills 324 htmlContent = this.updateStructuredDataWithGitHubSkills(htmlContent); 325 326 // Update dynamic content placeholders 327 htmlContent = this.updateDynamicContent(htmlContent); 328 329 // Inject full CV data as inline script so Puppeteer's file:// PDF render 330 // gets real data instead of the hardcoded fallback (fetch fails on file://) 331 htmlContent = this.injectInlineData(htmlContent); 332 333 return htmlContent; 334 } 335 336 /** 337 * Inject CV data as window.__CV_DATA__ so script.js can use it when fetch fails 338 * (e.g. Puppeteer file:// PDF generation) 339 */ 340 injectInlineData(htmlContent) { 341 // Escape < to prevent XSS via inline JSON injection (covers </script> and variants) 342 const safe = (obj) => JSON.stringify(obj).replace(/</g, '\\u003c'); 343 const inlineScript = `<script id="cv-inline-data"> 344 window.__CV_DATA__ = ${safe(this.cvData)}; 345 window.__ACTIVITY_DATA__ = ${safe(this.activityData || {})}; 346 window.__AI_ENHANCEMENTS__ = ${safe(this.aiEnhancements || {})}; 347 </script>`; 348 // Remove any previously injected inline data blocks to prevent duplication 349 htmlContent = htmlContent.replace(/<script id="cv-inline-data">[\s\S]*?<\/script>\n?/g, ''); 350 return htmlContent.replace('</head>', `${inlineScript}\n</head>`); 351 } 352 353 /** 354 * Escape a string for safe insertion into HTML attributes 355 */ 356 escapeAttr(str) { 357 return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>'); 358 } 359 360 /** 361 * Update meta tags with dynamic content 362 */ 363 updateMetaTags(htmlContent) { 364 const personalInfo = this.cvData.personal_info || {}; 365 const name = this.escapeAttr(personalInfo.name || 'Adrian Wedd'); 366 const title = this.escapeAttr(personalInfo.title || 'AI Engineer & Software Architect'); 367 const rawDesc = this.aiEnhancements?.professional_summary?.enhanced || 368 this.cvData.professional_summary || 369 'AI Engineer & Software Architect specializing in autonomous systems, machine learning, and innovative technology solutions'; 370 const description = this.escapeAttr(rawDesc); 371 372 // Update title 373 htmlContent = htmlContent.replace( 374 /<title>.*?<\/title>/, 375 `<title>${name} - ${title}</title>` 376 ); 377 378 // Update meta description 379 htmlContent = htmlContent.replace( 380 /<meta name="description" content=".*?">/, 381 `<meta name="description" content="${description.substring(0, 160)}...">` 382 ); 383 384 // Update Open Graph tags 385 htmlContent = htmlContent.replace( 386 /<meta property="og:title" content=".*?">/, 387 `<meta property="og:title" content="${name} - ${title}">` 388 ); 389 390 htmlContent = htmlContent.replace( 391 /<meta property="og:description" content=".*?">/, 392 `<meta property="og:description" content="${description.substring(0, 160)}...">` 393 ); 394 395 return htmlContent; 396 } 397 398 399 /** 400 * Update dynamic content placeholders with verified GitHub data 401 */ 402 updateDynamicContent(htmlContent) { 403 // Update professional summary if enhanced version available 404 if (this.aiEnhancements?.professional_summary?.enhanced) { 405 const enhancedSummary = this.escapeAttr(this.aiEnhancements.professional_summary.enhanced); 406 htmlContent = htmlContent.replace( 407 /(<p class="summary-text" id="professional-summary">)[\s\S]*?(<\/p>)/, 408 `$1${enhancedSummary}$2` 409 ); 410 } 411 412 // Update GitHub activity metrics with verified data 413 htmlContent = this.updateGitHubMetrics(htmlContent); 414 415 // Add generation timestamp 416 const now = new Date(); 417 htmlContent = htmlContent.replace( 418 /(<span id="footer-last-updated">).*?(<\/span>)/, 419 `$1${now.toLocaleDateString('en-US', { 420 year: 'numeric', 421 month: 'short', 422 day: 'numeric', 423 hour: '2-digit', 424 minute: '2-digit' 425 })}$2` 426 ); 427 428 return htmlContent; 429 } 430 431 /** 432 * Update GitHub metrics with verified activity data 433 */ 434 updateGitHubMetrics(htmlContent) { 435 const summary = this.activityData?.summary || {}; 436 const cvIntegration = this.activityData?.cv_integration || {}; 437 438 // Load latest professional development metrics if available 439 let professionalMetrics = {}; 440 try { 441 const metricsFile = this.activityData?.data_files?.latest_metrics; 442 if (metricsFile) { 443 const metricsPath = path.join(CONFIG.DATA_DIR, 'metrics', metricsFile); 444 const fs = require('fs'); 445 if (fs.existsSync(metricsPath)) { 446 professionalMetrics = JSON.parse(fs.readFileSync(metricsPath, 'utf8')); 447 } 448 } 449 } catch (error) { 450 console.warn('β οΈ Could not load professional metrics:', error.message); 451 } 452 453 // Update commits count (30 days) 454 const commitsCount = summary.total_commits || 0; 455 htmlContent = htmlContent.replace( 456 /(<div class="stat-value" id="commits-count">)[^<]*(<\/div>)/, 457 `$1${commitsCount}$2` 458 ); 459 460 // Update activity score 461 const activityScore = professionalMetrics?.scores?.activity_score || 462 Math.round((summary.active_days || 0) * 10); 463 htmlContent = htmlContent.replace( 464 /(<div class="stat-value" id="activity-score">)[^<]*(<\/div>)/, 465 `$1${activityScore}$2` 466 ); 467 468 // Update languages count (estimated from activity data) 469 const languagesCount = this.estimateLanguageCount(); 470 htmlContent = htmlContent.replace( 471 /(<div class="stat-value" id="languages-count">)[^<]*(<\/div>)/, 472 `$1${languagesCount}$2` 473 ); 474 475 // Update last updated with actual GitHub activity timestamp 476 if (cvIntegration.data_freshness) { 477 const lastUpdated = new Date(cvIntegration.data_freshness).toLocaleDateString('en-US', { 478 month: 'short', 479 day: 'numeric', 480 hour: '2-digit', 481 minute: '2-digit' 482 }); 483 htmlContent = htmlContent.replace( 484 /(<div class="stat-value" id="last-updated">)[^<]*(<\/div>)/, 485 `$1${lastUpdated}$2` 486 ); 487 } 488 489 // Update AI credibility score (based on data verification) 490 const credibilityScore = this.calculateCredibilityScore(summary, professionalMetrics); 491 htmlContent = htmlContent.replace( 492 /(<div class="stat-value" id="credibility-score">)[^<]*(<\/div>)/, 493 `$1${credibilityScore}%$2` 494 ); 495 496 console.log(`β GitHub metrics updated: ${commitsCount} commits, ${activityScore} activity score, ${languagesCount} languages`); 497 498 return htmlContent; 499 } 500 501 /** 502 * Estimate language count from available data 503 */ 504 estimateLanguageCount() { 505 // Try to get from base CV skills 506 const programmingSkills = (this.cvData.skills || []) 507 .filter(skill => skill.category === 'Programming Languages') 508 .length; 509 510 // Use reasonable default if no data available 511 return programmingSkills > 0 ? programmingSkills : 8; 512 } 513 514 /** 515 * Calculate credibility score based on data verification 516 */ 517 calculateCredibilityScore(summary, professionalMetrics) { 518 let credibilityScore = 100; 519 520 // Deduct points for missing or suspicious data 521 if (!summary.total_commits || summary.total_commits === 0) { 522 credibilityScore -= 20; 523 } 524 525 if (!summary.net_lines_contributed || summary.net_lines_contributed === 0) { 526 credibilityScore -= 15; 527 } 528 529 if (!professionalMetrics?.scores?.overall_professional_score) { 530 credibilityScore -= 10; 531 } 532 533 // Add points for comprehensive data 534 if (summary.total_commits > 50) { 535 credibilityScore += 5; 536 } 537 538 if (summary.net_lines_contributed > 10000) { 539 credibilityScore += 5; 540 } 541 542 return Math.min(100, Math.max(60, credibilityScore)); 543 } 544 545 /** 546 * Update structured data with GitHub-sourced skills 547 */ 548 updateStructuredDataWithGitHubSkills(htmlContent) { 549 const personalInfo = this.cvData.personal_info || {}; 550 let skills = (this.cvData.skills || []).slice(0, 10).map(skill => skill.name); 551 552 // Try to enhance with GitHub language data if available 553 try { 554 const skillAnalysisFile = this.activityData?.data_files?.latest_activity; 555 if (skillAnalysisFile) { 556 const activityPath = path.join(CONFIG.DATA_DIR, 'activity', skillAnalysisFile); 557 const fs = require('fs'); 558 if (fs.existsSync(activityPath)) { 559 const activityData = JSON.parse(fs.readFileSync(activityPath, 'utf8')); 560 const skillAnalysis = activityData.skill_analysis; 561 562 if (skillAnalysis && skillAnalysis.skill_proficiency) { 563 // Get top GitHub-verified skills 564 const githubSkills = Object.keys(skillAnalysis.skill_proficiency) 565 .filter(skill => skillAnalysis.skill_proficiency[skill].proficiency_level !== 'Beginner') 566 .slice(0, 10); 567 568 // Merge with existing skills, prioritizing GitHub-verified ones 569 if (githubSkills.length > 0) { 570 skills = [...new Set([...githubSkills, ...skills])].slice(0, 10); 571 console.log(`β Enhanced skills with GitHub data: ${githubSkills.length} verified skills`); 572 } 573 } 574 } 575 } 576 } catch (error) { 577 console.warn('β οΈ Could not enhance skills with GitHub data:', error.message); 578 } 579 580 const structuredData = { 581 "@context": "https://schema.org", 582 "@type": "Person", 583 "name": personalInfo.name || "Adrian Wedd", 584 "jobTitle": personalInfo.title || "AI Engineer & Software Architect", 585 "description": this.aiEnhancements?.professional_summary?.enhanced || this.cvData.professional_summary, 586 "url": CONFIG.SITE_URL, 587 "email": personalInfo.email || "adrian@adrianwedd.com", 588 "telephone": personalInfo.phone || "+61407081084", 589 "sameAs": [ 590 personalInfo.github || "https://github.com/adrianwedd", 591 personalInfo.linkedin || "https://linkedin.com/in/adrianwedd" 592 ], 593 "knowsAbout": skills, 594 "address": { 595 "@type": "PostalAddress", 596 "postOfficeBoxNumber": "331", 597 "addressLocality": "Cygnet", 598 "addressRegion": "Tasmania", 599 "addressCountry": "Australia" 600 } 601 }; 602 603 const structuredDataJson = JSON.stringify(structuredData, null, 2) 604 .replace(/<\//g, '<\\/'); 605 606 return htmlContent.replace( 607 /<script type="application\/ld\+json">[\s\S]*?<\/script>/, 608 `<script type="application/ld+json">\n${structuredDataJson}\n</script>` 609 ); 610 } 611 612 /** 613 * Copy assets to output directory 614 */ 615 async copyAssets() { 616 console.log('π¦ Copying assets...'); 617 618 try { 619 const assetsOutputDir = path.join(CONFIG.OUTPUT_DIR, 'assets'); 620 await fs.mkdir(assetsOutputDir, { recursive: true }); 621 622 // Copy CSS 623 const cssSource = path.join(CONFIG.ASSETS_DIR, 'styles.css'); 624 const cssTarget = path.join(assetsOutputDir, 'styles.css'); 625 await fs.copyFile(cssSource, cssTarget); 626 627 // Copy JavaScript 628 const jsSource = path.join(CONFIG.ASSETS_DIR, 'script.js'); 629 const jsTarget = path.join(assetsOutputDir, 'script.js'); 630 await fs.copyFile(jsSource, jsTarget); 631 632 // Copy JS modules 633 const modulesSourceDir = path.join(CONFIG.ASSETS_DIR, 'modules'); 634 const modulesOutputDir = path.join(assetsOutputDir, 'modules'); 635 const modulesExist = await fs.access(modulesSourceDir).then(() => true).catch(() => false); 636 if (modulesExist) { 637 await fs.mkdir(path.join(modulesOutputDir, 'sections'), { recursive: true }); 638 // Copy top-level module files 639 const moduleFiles = await fs.readdir(modulesSourceDir); 640 for (const file of moduleFiles) { 641 const srcPath = path.join(modulesSourceDir, file); 642 const stat = await fs.stat(srcPath); 643 if (stat.isFile() && file.endsWith('.js')) { 644 await fs.copyFile(srcPath, path.join(modulesOutputDir, file)); 645 } 646 } 647 // Copy sections subdirectory 648 const sectionsDir = path.join(modulesSourceDir, 'sections'); 649 const sectionsExist = await fs.access(sectionsDir).then(() => true).catch(() => false); 650 if (sectionsExist) { 651 const sectionFiles = await fs.readdir(sectionsDir); 652 for (const file of sectionFiles) { 653 if (file.endsWith('.js')) { 654 await fs.copyFile( 655 path.join(sectionsDir, file), 656 path.join(modulesOutputDir, 'sections', file) 657 ); 658 } 659 } 660 } 661 console.log('β JS modules copied'); 662 } 663 664 // Copy additional JS files 665 for (const jsFile of ['curvature-field.js', 'curvature-init.js', 'activity-viz.js', 'linkedin-insight.js', 'analytics-config.js', 'analytics-events.js']) { 666 const src = path.join(CONFIG.ASSETS_DIR, jsFile); 667 const dst = path.join(assetsOutputDir, jsFile); 668 await fs.copyFile(src, dst).catch(e => console.warn(`β οΈ ${jsFile} not found:`, e.message)); 669 } 670 671 // Copy image assets 672 for (const imgFile of ['og-image.png', 'og-image.svg']) { 673 const src = path.join(CONFIG.ASSETS_DIR, imgFile); 674 const dst = path.join(assetsOutputDir, imgFile); 675 await fs.copyFile(src, dst).catch(e => console.warn(`β οΈ ${imgFile} not found:`, e.message)); 676 } 677 678 // Copy watch-me-work.html to dist root 679 const watchHtmlSource = path.join(CONFIG.INPUT_DIR, 'watch-me-work.html'); 680 const watchHtmlTarget = path.join(CONFIG.OUTPUT_DIR, 'watch-me-work.html'); 681 await fs.copyFile(watchHtmlSource, watchHtmlTarget).catch(e => console.warn('β οΈ watch-me-work.html not found:', e.message)); 682 683 // Copy root web files 684 for (const file of ['favicon.svg', 'favicon.ico', 'apple-touch-icon.png', 'robots.txt', 'sitemap.xml', 'manifest.json', 'CNAME']) { 685 try { 686 const src = path.join(CONFIG.INPUT_DIR, file); 687 const dst = path.join(CONFIG.OUTPUT_DIR, file); 688 await fs.copyFile(src, dst); 689 } catch (e) { 690 console.warn(`β οΈ ${file} not found:`, e.message); 691 } 692 } 693 console.log('β Root web files copied'); 694 695 // Copy data directory 696 const dataOutputDir = path.join(CONFIG.OUTPUT_DIR, 'data'); 697 await fs.mkdir(dataOutputDir, { recursive: true }); 698 699 // Copy JSON data files 700 const dataFiles = ['base-cv.json', 'activity-summary.json', 'ai-enhancements.json', 'github-activity.json']; 701 702 for (const file of dataFiles) { 703 try { 704 const sourcePath = path.join(CONFIG.DATA_DIR, file); 705 const targetPath = path.join(dataOutputDir, file); 706 await fs.copyFile(sourcePath, targetPath); 707 } catch (error) { 708 console.warn(`β οΈ Could not copy ${file}:`, error.message); 709 } 710 } 711 712 // Copy data subdirectories (activity, metrics, trends) 713 for (const subdir of ['activity', 'metrics', 'trends']) { 714 try { 715 const sourceDir = path.join(CONFIG.DATA_DIR, subdir); 716 const outputDir = path.join(dataOutputDir, subdir); 717 718 const exists = await fs.access(sourceDir).then(() => true).catch(() => false); 719 if (exists) { 720 await fs.mkdir(outputDir, { recursive: true }); 721 722 const files = await fs.readdir(sourceDir); 723 for (const file of files) { 724 if (file.endsWith('.json')) { 725 await fs.copyFile(path.join(sourceDir, file), path.join(outputDir, file)); 726 } 727 } 728 console.log(`β ${subdir} data directory copied (${files.length} files)`); 729 } 730 } catch (error) { 731 console.warn(`β οΈ Could not copy ${subdir} directory:`, error.message); 732 } 733 } 734 735 console.log('β Assets copied successfully'); 736 737 } catch (error) { 738 console.error('β Asset copying failed:', error.message); 739 throw error; 740 } 741 } 742 743 /** 744 * Generate sitemap.xml 745 */ 746 async generateSitemap() { 747 console.log('πΊοΈ Generating sitemap...'); 748 749 const lastmod = new Date().toISOString(); 750 const sitemap = `<?xml version="1.0" encoding="UTF-8"?> 751 <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> 752 <url> 753 <loc>${CONFIG.SITE_URL}</loc> 754 <lastmod>${lastmod}</lastmod> 755 <changefreq>weekly</changefreq> 756 <priority>1.0</priority> 757 </url> 758 </urlset>`; 759 760 const sitemapPath = path.join(CONFIG.OUTPUT_DIR, 'sitemap.xml'); 761 await fs.writeFile(sitemapPath, sitemap, 'utf8'); 762 763 console.log('β Sitemap generated'); 764 } 765 766 /** 767 * Generate robots.txt 768 */ 769 async generateRobotsTxt() { 770 console.log('π€ Generating robots.txt...'); 771 772 const robots = `User-agent: * 773 Allow: / 774 775 Sitemap: ${CONFIG.SITE_URL}/sitemap.xml 776 777 # Disallow crawler access to data files for privacy 778 Disallow: /data/ 779 `; 780 781 const robotsPath = path.join(CONFIG.OUTPUT_DIR, 'robots.txt'); 782 await fs.writeFile(robotsPath, robots, 'utf8'); 783 784 console.log('β Robots.txt generated'); 785 } 786 787 /** 788 * Generate web manifest 789 */ 790 async generateManifest() { 791 console.log('π± Generating web manifest...'); 792 793 const personalInfo = this.cvData.personal_info || {}; 794 795 const manifest = { 796 name: `${personalInfo.name || 'Adrian Wedd'} - Professional CV`, 797 short_name: personalInfo.name || 'Adrian Wedd', 798 description: this.cvData.professional_summary || 'AI Engineer & Software Architect', 799 start_url: '/', 800 display: 'standalone', 801 background_color: '#070a0f', 802 theme_color: '#8ac7d9', 803 orientation: 'portrait-primary', 804 icons: [ 805 { 806 src: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">π€</text></svg>', 807 sizes: '192x192', 808 type: 'image/svg+xml' 809 } 810 ] 811 }; 812 813 const manifestPath = path.join(CONFIG.OUTPUT_DIR, 'manifest.json'); 814 await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf8'); 815 816 console.log('β Web manifest generated'); 817 } 818 819 /** 820 * Generate GitHub Pages specific files 821 */ 822 async generateGitHubPagesFiles() { 823 console.log('π Generating GitHub Pages files...'); 824 825 // Generate CNAME file if custom domain is specified 826 if (process.env.CUSTOM_DOMAIN) { 827 const cnamePath = path.join(CONFIG.OUTPUT_DIR, 'CNAME'); 828 await fs.writeFile(cnamePath, process.env.CUSTOM_DOMAIN, 'utf8'); 829 console.log(`β CNAME file generated for ${process.env.CUSTOM_DOMAIN}`); 830 } 831 832 // Generate .nojekyll to bypass Jekyll processing 833 const nojekyllPath = path.join(CONFIG.OUTPUT_DIR, '.nojekyll'); 834 await fs.writeFile(nojekyllPath, '', 'utf8'); 835 console.log('β .nojekyll file generated'); 836 837 // Generate 404.html 838 const notFoundHtml = `<!DOCTYPE html> 839 <html lang="en"> 840 <head> 841 <meta charset="UTF-8"> 842 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 843 <title>Page Not Found - ${this.cvData.personal_info?.name || 'Adrian Wedd'}</title> 844 <style> 845 body { 846 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 847 display: flex; 848 align-items: center; 849 justify-content: center; 850 min-height: 100vh; 851 margin: 0; 852 background: linear-gradient(135deg, #2563eb 0%, #10b981 100%); 853 color: white; 854 text-align: center; 855 } 856 .container { 857 max-width: 600px; 858 padding: 2rem; 859 } 860 h1 { 861 font-size: 3rem; 862 margin-bottom: 1rem; 863 } 864 p { 865 font-size: 1.2rem; 866 margin-bottom: 2rem; 867 opacity: 0.9; 868 } 869 .btn { 870 display: inline-block; 871 padding: 1rem 2rem; 872 background: rgba(255, 255, 255, 0.2); 873 color: white; 874 text-decoration: none; 875 border-radius: 0.5rem; 876 font-weight: 500; 877 transition: background 0.3s ease; 878 } 879 .btn:hover { 880 background: rgba(255, 255, 255, 0.3); 881 } 882 </style> 883 </head> 884 <body> 885 <div class="container"> 886 <h1>π€ 404</h1> 887 <p>The page you're looking for doesn't exist, but my CV does!</p> 888 <a href="/" class="btn">β Back to CV</a> 889 </div> 890 </body> 891 </html>`; 892 893 const notFoundPath = path.join(CONFIG.OUTPUT_DIR, '404.html'); 894 await fs.writeFile(notFoundPath, notFoundHtml, 'utf8'); 895 console.log('β 404.html generated'); 896 } 897 898 /** 899 * Generates a high-quality PDF from the generated HTML. 900 */ 901 async generatePDF() { 902 console.log('π Generating PDF version of the CV...'); 903 const browser = await puppeteer.launch({ args: ['--no-sandbox', '--allow-file-access-from-files'] }); 904 try { 905 const page = await browser.newPage(); 906 907 const htmlPath = path.resolve(path.join(CONFIG.OUTPUT_DIR, 'index.html')); 908 await page.goto(`file://${htmlPath}`, { waitUntil: 'networkidle0' }); 909 910 // Ensure dark/light theme is set for consistency (e.g., light theme for print) 911 await page.evaluate(() => { 912 document.documentElement.setAttribute('data-theme', 'light'); // eslint-disable-line no-undef 913 }); 914 915 const pdfPath = path.join(CONFIG.OUTPUT_DIR, 'assets', 'adrian-wedd-cv.pdf'); 916 await page.pdf({ 917 path: pdfPath, 918 format: 'A4', 919 printBackground: true, 920 margin: { 921 top: '20mm', 922 right: '20mm', 923 bottom: '20mm', 924 left: '20mm' 925 } 926 }); 927 928 console.log(`β PDF generated successfully at: ${pdfPath}`); 929 } finally { 930 await browser.close(); 931 } 932 } 933 934 /** 935 * Build ATS-optimised HTML from template and CV data 936 */ 937 async buildATSHTML() { 938 const templatePath = path.join( 939 rootPrefix, '.github', 'scripts', 'ats-template.html' 940 ); 941 let html = await fs.readFile(templatePath, 'utf8'); 942 943 const info = this.cvData.personal_info || {}; 944 945 // Simple replacements β escape text fields for safe HTML insertion 946 html = html.replace(/\{\{NAME\}\}/g, this.escapeHtml(info.name || 'Adrian Wedd')); 947 html = html.replace(/\{\{TITLE\}\}/g, this.escapeHtml(info.title || 'AI Safety Researcher & Developer')); 948 html = html.replace(/\{\{LOCATION\}\}/g, this.escapeHtml(info.location || '')); 949 html = html.replace(/\{\{PHONE\}\}/g, this.escapeHtml(info.phone || '')); 950 html = html.replace(/\{\{EMAIL\}\}/g, this.escapeHtml(info.email || '')); 951 952 // URL fields β validate protocol instead of escaping (they go into href attributes) 953 const linkedinUrl = (info.linkedin || '').startsWith('https://') ? info.linkedin : 'https://linkedin.com/in/adrianwedd'; 954 const githubUrl = (info.github || '').startsWith('https://') ? info.github : 'https://github.com/adrianwedd'; 955 html = html.replace(/\{\{LINKEDIN_URL\}\}/g, this.escapeHtml(linkedinUrl)); 956 html = html.replace(/\{\{GITHUB_URL\}\}/g, this.escapeHtml(githubUrl)); 957 958 // Summary β truncate to ~400 chars on sentence boundary 959 const fullSummary = this.cvData.professional_summary || ''; 960 let summary = fullSummary; 961 if (summary.length > 400) { 962 const truncated = summary.substring(0, 400); 963 const lastDot = truncated.lastIndexOf('.'); 964 summary = lastDot > 200 ? truncated.substring(0, lastDot + 1) : truncated; 965 } 966 html = html.replace('{{SUMMARY}}', this.escapeHtml(summary)); 967 968 // Competencies 969 html = html.replace( 970 '{{COMPETENCIES}}', 971 'AI Safety & Evaluation | Frontier AI Models | Risk Assessment | Policy Translation' 972 ); 973 974 // Experience β first 3 entries 975 const experience = (this.cvData.experience || []).slice(0, 3); 976 let expHtml = ''; 977 for (const job of experience) { 978 const desc = this.truncateToSentence(job.description || '', 1); 979 const achievements = (job.achievements || []).slice(0, 3); 980 let achHtml = ''; 981 if (achievements.length > 0) { 982 achHtml = '<ul>' + 983 achievements.map(a => `<li>${this.escapeHtml(a)}</li>`).join('') + 984 '</ul>'; 985 } 986 expHtml += `<div class="job-header"><h3>${this.escapeHtml(job.position || '')} β ${this.escapeHtml(job.company || '')}</h3>` + 987 `<span class="job-period">${this.escapeHtml(job.period || '')}</span></div>` + 988 `<p>${this.escapeHtml(desc)}</p>` + 989 achHtml; 990 } 991 html = html.replace('{{EXPERIENCE}}', expHtml); 992 993 // Projects β first 3 994 const projects = (this.cvData.projects || []).slice(0, 3); 995 let projHtml = ''; 996 for (const proj of projects) { 997 const desc = this.truncateToSentence(proj.description || '', 1); 998 projHtml += `<li><strong>${this.escapeHtml(proj.name || '')}</strong> β ${this.escapeHtml(desc)}</li>\n`; 999 } 1000 html = html.replace('{{PROJECTS}}', projHtml); 1001 1002 // Skills β grouped by category, no tiers 1003 const skills = this.cvData.skills || []; 1004 const grouped = {}; 1005 for (const skill of skills) { 1006 const cat = skill.category || 'Other'; 1007 if (!grouped[cat]) grouped[cat] = []; 1008 grouped[cat].push(skill.name); 1009 } 1010 let skillsHtml = ''; 1011 for (const [cat, names] of Object.entries(grouped)) { 1012 skillsHtml += `<strong>${this.escapeHtml(cat)}:</strong> ${this.escapeHtml(names.join(', '))}<br>`; 1013 } 1014 html = html.replace('{{SKILLS}}', skillsHtml); 1015 1016 // Education β first 2, skip "First Code at Age 6" 1017 const education = (this.cvData.education || []) 1018 .filter(e => !(e.degree || '').includes('First Code')) 1019 .slice(0, 2); 1020 let eduHtml = ''; 1021 for (const edu of education) { 1022 eduHtml += `<h3>${this.escapeHtml(edu.degree || '')}</h3>` + 1023 `<p>${this.escapeHtml(edu.institution || '')} β ${this.escapeHtml(edu.period || '')}</p>`; 1024 } 1025 html = html.replace('{{EDUCATION}}', eduHtml); 1026 1027 return html; 1028 } 1029 1030 /** 1031 * Escape text for safe HTML insertion 1032 */ 1033 escapeHtml(str) { 1034 return String(str) 1035 .replace(/&/g, '&') 1036 .replace(/</g, '<') 1037 .replace(/>/g, '>') 1038 .replace(/"/g, '"') 1039 .replace(/'/g, '''); 1040 } 1041 1042 /** 1043 * Truncate text to a given number of sentences 1044 */ 1045 truncateToSentence(text, count) { 1046 const sentences = text.match(/[^.!?]+[.!?]+/g) || [text]; 1047 return sentences.slice(0, count).join(' ').trim(); 1048 } 1049 1050 /** 1051 * Generate ATS-optimised short PDF (2-3 pages) 1052 */ 1053 async generateATSPDF() { 1054 console.log('π Generating ATS-optimised short PDF...'); 1055 const htmlContent = await this.buildATSHTML(); 1056 1057 const browser = await puppeteer.launch({ args: ['--no-sandbox', '--allow-file-access-from-files'] }); 1058 try { 1059 const page = await browser.newPage(); 1060 1061 await page.setContent(htmlContent, { waitUntil: 'networkidle0' }); 1062 1063 const pdfPath = path.join(CONFIG.OUTPUT_DIR, 'assets', 'adrian-wedd-cv-short.pdf'); 1064 await page.pdf({ 1065 path: pdfPath, 1066 format: 'A4', 1067 printBackground: true, 1068 margin: { 1069 top: '15mm', 1070 right: '15mm', 1071 bottom: '15mm', 1072 left: '15mm' 1073 } 1074 }); 1075 1076 console.log(`β ATS short PDF generated at: ${pdfPath}`); 1077 } finally { 1078 await browser.close(); 1079 } 1080 } 1081 1082 /** 1083 * Get default CV data if no data files are available 1084 */ 1085 getDefaultCVData() { 1086 return { 1087 personal_info: { 1088 name: "Adrian Wedd", 1089 title: "AI Engineer & Software Architect", 1090 location: "Tasmania, Australia", 1091 github: "https://github.com/adrianwedd", 1092 linkedin: "https://linkedin.com/in/adrianwedd" 1093 }, 1094 professional_summary: "AI Engineer and Software Architect specializing in autonomous systems, machine learning, and innovative technology solutions.", 1095 experience: [], 1096 projects: [], 1097 skills: [], 1098 achievements: [] 1099 }; 1100 } 1101 } 1102 1103 /** 1104 * Main execution function 1105 */ 1106 async function main() { 1107 try { 1108 const generator = new CVGenerator(); 1109 await generator.generate(); 1110 1111 console.log('\nπ **CV WEBSITE GENERATION COMPLETE**'); 1112 console.log(`π Website ready for deployment at: ${CONFIG.OUTPUT_DIR}/`); 1113 console.log(`π Target URL: ${CONFIG.SITE_URL}`); 1114 1115 } catch (error) { 1116 console.error('β Generation failed:', error.message); 1117 process.exit(1); 1118 } 1119 } 1120 1121 // Execute if called directly 1122 if (require.main === module) { 1123 main().catch(console.error); 1124 } 1125 1126 module.exports = { CVGenerator, CONFIG };