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();