build.js

  1import * as esbuild from 'esbuild';
  2import * as fs from 'fs';
  3import * as zlib from 'zlib';
  4import * as crypto from 'crypto';
  5import { execSync } from 'child_process';
  6
  7const isWatch = process.argv.includes('--watch');
  8const isProd = !isWatch;
  9const verbose = process.env.VERBOSE === '1' || process.env.VERBOSE === 'true';
 10
 11function log(...args) {
 12  if (verbose) console.log(...args);
 13}
 14
 15async function build() {
 16  const startTime = Date.now();
 17  try {
 18    // Ensure dist directory exists
 19    if (!fs.existsSync('dist')) {
 20      fs.mkdirSync('dist');
 21    }
 22
 23    // Build Monaco editor worker separately (IIFE format for web worker)
 24    log('Building Monaco editor worker...');
 25    await esbuild.build({
 26      entryPoints: ['node_modules/monaco-editor/esm/vs/editor/editor.worker.js'],
 27      bundle: true,
 28      outfile: 'dist/editor.worker.js',
 29      format: 'iife',
 30      minify: isProd,
 31      sourcemap: true,
 32    });
 33
 34    // Build @pierre/diffs worker for syntax highlighting (IIFE format for web worker)
 35    log('Building diffs worker...');
 36    await esbuild.build({
 37      entryPoints: ['src/diffs-worker.ts'],
 38      bundle: true,
 39      outfile: 'dist/diffs-worker.js',
 40      format: 'iife',
 41      minify: isProd,
 42      sourcemap: true,
 43    });
 44
 45    // Build Monaco editor as a separate chunk (JS + CSS)
 46    log('Building Monaco editor bundle...');
 47    await esbuild.build({
 48      entryPoints: ['node_modules/monaco-editor/esm/vs/editor/editor.main.js'],
 49      bundle: true,
 50      outfile: 'dist/monaco-editor.js',
 51      format: 'esm',
 52      minify: isProd,
 53      sourcemap: true,
 54      loader: {
 55        '.ttf': 'file',
 56      },
 57    });
 58
 59    // Build main app - exclude monaco-editor, we'll load it dynamically
 60    log('Building main application...');
 61    const result = await esbuild.build({
 62      entryPoints: ['src/main.tsx'],
 63      bundle: true,
 64      outfile: 'dist/main.js',
 65      format: 'esm',
 66      minify: isProd,
 67      sourcemap: true,
 68      metafile: true,
 69      external: ['monaco-editor', '/monaco-editor.js'],
 70    });
 71
 72    // Copy static files
 73    fs.copyFileSync('src/index.html', 'dist/index.html');
 74    fs.copyFileSync('src/styles.css', 'dist/styles.css');
 75
 76    // Copy assets (icons, manifest, etc.)
 77    const assetsDir = 'src/assets';
 78    if (fs.existsSync(assetsDir)) {
 79      for (const file of fs.readdirSync(assetsDir)) {
 80        fs.copyFileSync(`${assetsDir}/${file}`, `dist/${file}`);
 81      }
 82    }
 83
 84    // Write build info
 85    // Get the absolute path to the src directory for staleness checking
 86    const srcDir = new URL('../src', import.meta.url).pathname;
 87
 88    // Get git commit info
 89    let commit = '';
 90    let commitTime = '';
 91    let modified = false;
 92    try {
 93      commit = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim();
 94      commitTime = execSync('git log -1 --format=%cI', { encoding: 'utf8' }).trim();
 95      // Check for modifications, excluding the dist/ directory (which we're currently building)
 96      const status = execSync('git status --porcelain --ignore-submodules', { encoding: 'utf8' });
 97      // Filter out dist/ changes since those are expected during build
 98      const significantChanges = status.split('\n').filter(line =>
 99        line.trim() && !line.includes('dist/')
100      );
101      modified = significantChanges.length > 0;
102    } catch (e) {
103      // Git not available or not a git repo
104    }
105
106    const buildInfo = {
107      timestamp: Date.now(),
108      date: new Date().toISOString(),
109      srcDir: srcDir,
110      commit: commit,
111      commitTime: commitTime,
112      modified: modified,
113    };
114    fs.writeFileSync('dist/build-info.json', JSON.stringify(buildInfo, null, 2));
115
116    // Generate gzip versions of large files and remove originals to reduce binary size
117    // The server will decompress on-the-fly for the rare clients that don't support gzip
118    log('\nGenerating gzip compressed files...');
119    const filesToCompress = ['monaco-editor.js', 'editor.worker.js', 'diffs-worker.js', 'main.js', 'monaco-editor.css', 'styles.css', 'main.css'];
120    const checksums = {};
121    let totalOrigSize = 0;
122    let totalGzSize = 0;
123
124    for (const file of filesToCompress) {
125      const inputPath = `dist/${file}`;
126      const outputPath = `dist/${file}.gz`;
127      if (fs.existsSync(inputPath)) {
128        const input = fs.readFileSync(inputPath);
129        const compressed = zlib.gzipSync(input, { level: 9 });
130        fs.writeFileSync(outputPath, compressed);
131
132        // Compute SHA256 of the compressed content for ETag
133        const hash = crypto.createHash('sha256').update(compressed).digest('hex').slice(0, 16);
134        checksums[file] = hash;
135
136        totalOrigSize += input.length;
137        totalGzSize += compressed.length;
138
139        if (verbose) {
140          const origKb = (input.length / 1024).toFixed(1);
141          const gzKb = (compressed.length / 1024).toFixed(1);
142          const ratio = ((compressed.length / input.length) * 100).toFixed(0);
143          console.log(`  ${file}: ${origKb} KB -> ${gzKb} KB gzip (${ratio}%) [${hash}]`);
144        }
145
146        // Remove original to save space in embedded binary
147        fs.unlinkSync(inputPath);
148      }
149    }
150
151    // Write checksums for ETag support
152    fs.writeFileSync('dist/checksums.json', JSON.stringify(checksums, null, 2));
153    log('\nChecksums written to dist/checksums.json');
154
155    if (verbose) {
156      console.log('\nOther files:');
157      const otherFiles = fs.readdirSync('dist').filter(f =>
158        (f.endsWith('.ttf') || f.endsWith('.map')) && !f.endsWith('.gz')
159      );
160      for (const file of otherFiles.sort()) {
161        const stats = fs.statSync(`dist/${file}`);
162        const sizeKb = (stats.size / 1024).toFixed(1);
163        console.log(`  ${file}: ${sizeKb} KB`);
164      }
165    }
166
167    const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
168    const totalGzKb = (totalGzSize / 1024).toFixed(0);
169    console.log(`UI built in ${elapsed}s (${totalGzKb} KB gzipped)`);
170  } catch (error) {
171    console.error('Build failed:', error);
172    process.exit(1);
173  }
174}
175
176build();