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