/ .github / scripts / cv-generator.js
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, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
 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 &amp; 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, '&amp;')
1036              .replace(/</g, '&lt;')
1037              .replace(/>/g, '&gt;')
1038              .replace(/"/g, '&quot;')
1039              .replace(/'/g, '&#39;');
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 };