blob: bf4cca9ceb040b4ca39dbc886cec56ae800cdef1 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001// Package webui provides the web interface for the sketch loop.
2// It bundles typescript files into JavaScript using esbuild.
Earl Lee2e463fb2025-04-17 11:22:22 -07003package webui
4
5import (
David Crawshaw8bff16a2025-04-18 01:16:49 -07006 "archive/zip"
7 "bytes"
Philip Zeyliger176de792025-04-21 12:25:18 -07008 "compress/gzip"
Earl Lee2e463fb2025-04-17 11:22:22 -07009 "crypto/sha256"
10 "embed"
11 "encoding/hex"
12 "fmt"
13 "io"
14 "io/fs"
15 "os"
16 "os/exec"
17 "path/filepath"
18 "strings"
Philip Zeyliger09b86f42025-07-16 16:23:03 -070019 "time"
Earl Lee2e463fb2025-04-17 11:22:22 -070020
21 esbuildcli "github.com/evanw/esbuild/pkg/cli"
22)
23
Sean McCullough86b56862025-04-18 13:04:03 -070024//go:embed package.json package-lock.json src tsconfig.json
Earl Lee2e463fb2025-04-17 11:22:22 -070025var embedded embed.FS
26
Josh Bleecher Snydere634d262025-04-30 09:52:24 -070027//go:generate go run ../cmd/go2ts -o src/types.ts
28
Earl Lee2e463fb2025-04-17 11:22:22 -070029func embeddedHash() (string, error) {
30 h := sha256.New()
31 err := fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error {
32 if d.IsDir() {
33 return nil
34 }
35 f, err := embedded.Open(path)
36 if err != nil {
37 return err
38 }
39 defer f.Close()
40 if _, err := io.Copy(h, f); err != nil {
41 return fmt.Errorf("%s: %w", path, err)
42 }
43 return nil
44 })
45 if err != nil {
46 return "", fmt.Errorf("embedded hash: %w", err)
47 }
David Crawshaw8bff16a2025-04-18 01:16:49 -070048 return hex.EncodeToString(h.Sum(nil))[:32], nil
Earl Lee2e463fb2025-04-17 11:22:22 -070049}
50
Philip Zeyliger09b86f42025-07-16 16:23:03 -070051// ensureNodeModules runs npm ci only if package-lock.json has changed or node_modules doesn't exist.
52// This optimization saves ~2.4 seconds when only TypeScript or other source files change,
53// since npm ci is only run when dependencies actually change.
54func ensureNodeModules(buildDir string) error {
55 packageLockPath := filepath.Join(buildDir, "package-lock.json")
56 nodeModulesPath := filepath.Join(buildDir, "node_modules")
57 packageLockBackupPath := filepath.Join(buildDir, ".package-lock-installed")
58
59 // Check if node_modules exists
60 if _, err := os.Stat(nodeModulesPath); os.IsNotExist(err) {
Philip Zeyliger09b86f42025-07-16 16:23:03 -070061 return runNpmCI(buildDir, packageLockPath, packageLockBackupPath)
62 }
63
64 // Read current package-lock.json
65 packageLockData, err := os.ReadFile(packageLockPath)
66 if err != nil {
67 return fmt.Errorf("read package-lock.json: %w", err)
68 }
69
70 // Check if package-lock.json has changed by comparing with stored version
71 if storedPackageLockData, err := os.ReadFile(packageLockBackupPath); err == nil {
72 if bytes.Equal(packageLockData, storedPackageLockData) {
Philip Zeyliger09b86f42025-07-16 16:23:03 -070073 return nil
74 }
75 }
76
Philip Zeyliger09b86f42025-07-16 16:23:03 -070077 return runNpmCI(buildDir, packageLockPath, packageLockBackupPath)
78}
79
80// runNpmCI executes npm ci and stores the package-lock.json content
81func runNpmCI(buildDir, packageLockPath, packageLockBackupPath string) error {
82 start := time.Now()
83 cmd := exec.Command("npm", "ci", "--omit", "dev")
84 cmd.Dir = buildDir
85 if out, err := cmd.CombinedOutput(); err != nil {
86 return fmt.Errorf("npm ci: %s: %v", out, err)
87 }
88 fmt.Printf("[BUILD] npm ci completed in %v\n", time.Since(start))
89
90 // Store a copy of package-lock.json for future comparisons
91 packageLockData, err := os.ReadFile(packageLockPath)
92 if err != nil {
93 return fmt.Errorf("read package-lock.json after npm ci: %w", err)
94 }
95
96 if err := os.WriteFile(packageLockBackupPath, packageLockData, 0o666); err != nil {
97 return fmt.Errorf("write package-lock backup: %w", err)
98 }
99
100 return nil
101}
102
Earl Lee2e463fb2025-04-17 11:22:22 -0700103func cleanBuildDir(buildDir string) error {
104 err := fs.WalkDir(os.DirFS(buildDir), ".", func(path string, d fs.DirEntry, err error) error {
105 if d.Name() == "." {
106 return nil
107 }
108 if d.Name() == "node_modules" {
109 return fs.SkipDir
110 }
Philip Zeyliger09b86f42025-07-16 16:23:03 -0700111 if d.Name() == ".package-lock-installed" {
112 return nil // Skip file, but don't skip directory
113 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700114 osPath := filepath.Join(buildDir, path)
Earl Lee2e463fb2025-04-17 11:22:22 -0700115 os.RemoveAll(osPath)
116 if d.IsDir() {
117 return fs.SkipDir
118 }
119 return nil
120 })
121 if err != nil {
122 return fmt.Errorf("clean build dir: %w", err)
123 }
124 return nil
125}
126
127func unpackFS(out string, srcFS fs.FS) error {
128 err := fs.WalkDir(srcFS, ".", func(path string, d fs.DirEntry, err error) error {
129 if d.Name() == "." {
130 return nil
131 }
132 if d.IsDir() {
133 if err := os.Mkdir(filepath.Join(out, path), 0o777); err != nil {
134 return err
135 }
136 return nil
137 }
138 f, err := srcFS.Open(path)
139 if err != nil {
140 return err
141 }
142 defer f.Close()
143 dst, err := os.Create(filepath.Join(out, path))
144 if err != nil {
145 return err
146 }
147 defer dst.Close()
148 if _, err := io.Copy(dst, f); err != nil {
149 return err
150 }
151 if err := dst.Close(); err != nil {
152 return err
153 }
154 return nil
155 })
156 if err != nil {
157 return fmt.Errorf("unpack fs into out dir %s: %w", out, err)
158 }
159 return nil
160}
161
Philip Zeyliger983b58a2025-07-02 19:42:08 -0700162// TODO: This path being /root/.cache/sketch/webui/skui-....zip means that the Dockerfile
163// in createdockerfile.go needs to create the parent directory. Ideally we bundle the built webui
164// into the binary and avoid this altogether.
David Crawshaw8bff16a2025-04-18 01:16:49 -0700165func zipPath() (cacheDir, hashZip string, err error) {
166 homeDir, err := os.UserHomeDir()
167 if err != nil {
168 return "", "", err
169 }
170 hash, err := embeddedHash()
171 if err != nil {
172 return "", "", err
173 }
174 cacheDir = filepath.Join(homeDir, ".cache", "sketch", "webui")
175 return cacheDir, filepath.Join(cacheDir, "skui-"+hash+".zip"), nil
176}
177
Sean McCullough39995932025-06-25 19:32:08 +0000178// generateTailwindCSS generates tailwind.css from global.css and outputs it to the specified directory
179func generateTailwindCSS(buildDir, outDir string) error {
180 // Run tailwindcss CLI to generate the CSS
181 cmd := exec.Command("npx", "tailwindcss", "-i", "./src/global.css", "-o", filepath.Join(outDir, "tailwind.css"))
182 cmd.Dir = buildDir
183 if out, err := cmd.CombinedOutput(); err != nil {
184 return fmt.Errorf("tailwindcss generation failed: %s: %v", out, err)
185 }
186 return nil
187}
188
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700189// copyMonacoAssets copies Monaco editor assets to the output directory
190func copyMonacoAssets(buildDir, outDir string) error {
191 // Create Monaco directories
192 monacoEditorDir := filepath.Join(outDir, "monaco", "min", "vs", "editor")
193 codiconDir := filepath.Join(outDir, "monaco", "min", "vs", "base", "browser", "ui", "codicons", "codicon")
194
195 if err := os.MkdirAll(monacoEditorDir, 0o777); err != nil {
196 return fmt.Errorf("failed to create monaco editor directory: %w", err)
197 }
198
199 if err := os.MkdirAll(codiconDir, 0o777); err != nil {
200 return fmt.Errorf("failed to create codicon directory: %w", err)
201 }
202
203 // Copy Monaco editor CSS
204 editorCssPath := "node_modules/monaco-editor/min/vs/editor/editor.main.css"
205 editorCss, err := os.ReadFile(filepath.Join(buildDir, editorCssPath))
206 if err != nil {
207 return fmt.Errorf("failed to read monaco editor CSS: %w", err)
208 }
209
210 if err := os.WriteFile(filepath.Join(monacoEditorDir, "editor.main.css"), editorCss, 0o666); err != nil {
211 return fmt.Errorf("failed to write monaco editor CSS: %w", err)
212 }
213
214 // Copy Codicon font
215 codiconFontPath := "node_modules/monaco-editor/min/vs/base/browser/ui/codicons/codicon/codicon.ttf"
216 codiconFont, err := os.ReadFile(filepath.Join(buildDir, codiconFontPath))
217 if err != nil {
218 return fmt.Errorf("failed to read codicon font: %w", err)
219 }
220
221 if err := os.WriteFile(filepath.Join(codiconDir, "codicon.ttf"), codiconFont, 0o666); err != nil {
222 return fmt.Errorf("failed to write codicon font: %w", err)
223 }
224
225 return nil
226}
227
Earl Lee2e463fb2025-04-17 11:22:22 -0700228// Build unpacks and esbuild's all bundleTs typescript files
229func Build() (fs.FS, error) {
David Crawshaw8bff16a2025-04-18 01:16:49 -0700230 cacheDir, hashZip, err := zipPath()
Earl Lee2e463fb2025-04-17 11:22:22 -0700231 if err != nil {
232 return nil, err
233 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700234 buildDir := filepath.Join(cacheDir, "build")
235 if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
236 return nil, err
237 }
David Crawshaw8bff16a2025-04-18 01:16:49 -0700238 if b, err := os.ReadFile(hashZip); err == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700239 // Build already done, serve it out.
David Crawshaw8bff16a2025-04-18 01:16:49 -0700240 return zip.NewReader(bytes.NewReader(b), int64(len(b)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700241 }
242
David Crawshaw8bff16a2025-04-18 01:16:49 -0700243 // TODO: try downloading "https://sketch.dev/webui/"+filepath.Base(hashZip)
244
Earl Lee2e463fb2025-04-17 11:22:22 -0700245 // We need to do a build.
246
247 // Clear everything out of the build directory except node_modules.
248 if err := cleanBuildDir(buildDir); err != nil {
249 return nil, err
250 }
251 tmpHashDir := filepath.Join(buildDir, "out")
Philip Zeyliger09b86f42025-07-16 16:23:03 -0700252 if err := os.MkdirAll(tmpHashDir, 0o777); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700253 return nil, err
254 }
255
256 // Unpack everything from embedded into build dir.
257 if err := unpackFS(buildDir, embedded); err != nil {
258 return nil, err
259 }
260
Sean McCullough86b56862025-04-18 13:04:03 -0700261 // Do the build. Don't install dev dependencies, because they can be large
262 // and slow enough to install that the /init requests from the host process
263 // will run out of retries and the whole thing exits. We do need better health
264 // checking in general, but that's a separate issue. Don't do slow stuff here:
Philip Zeyliger09b86f42025-07-16 16:23:03 -0700265 if err := ensureNodeModules(buildDir); err != nil {
266 return nil, fmt.Errorf("ensure node modules: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700267 }
Sean McCullough39995932025-06-25 19:32:08 +0000268
269 // Generate Tailwind CSS
270 if err := generateTailwindCSS(buildDir, tmpHashDir); err != nil {
271 return nil, fmt.Errorf("generate tailwind css: %w", err)
272 }
philip.zeyligerc0a44592025-06-15 21:24:57 -0700273 // Create all bundles
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700274 bundleTs := []string{
275 "src/web-components/sketch-app-shell.ts",
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700276 "src/web-components/mobile-app-shell.ts",
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700277 "src/web-components/sketch-monaco-view.ts",
Sean McCullough021231a2025-06-12 09:35:24 -0700278 "src/messages-viewer.ts",
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700279 "node_modules/monaco-editor/esm/vs/editor/editor.worker.js",
280 "node_modules/monaco-editor/esm/vs/language/typescript/ts.worker.js",
281 "node_modules/monaco-editor/esm/vs/language/html/html.worker.js",
282 "node_modules/monaco-editor/esm/vs/language/css/css.worker.js",
283 "node_modules/monaco-editor/esm/vs/language/json/json.worker.js",
284 }
285
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000286 // Additionally create standalone bundles for caching
philip.zeyligerc0a44592025-06-15 21:24:57 -0700287 monacoHash, err := createStandaloneMonacoBundle(tmpHashDir, buildDir)
288 if err != nil {
289 return nil, fmt.Errorf("create monaco bundle: %w", err)
290 }
philip.zeyligerc0a44592025-06-15 21:24:57 -0700291
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000292 mermaidHash, err := createStandaloneMermaidBundle(tmpHashDir, buildDir)
293 if err != nil {
294 return nil, fmt.Errorf("create mermaid bundle: %w", err)
295 }
296
297 // Bundle all files with Monaco and Mermaid as external (since they may transitively import them)
Earl Lee2e463fb2025-04-17 11:22:22 -0700298 for _, tsName := range bundleTs {
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000299 // Use external Monaco and Mermaid for all TypeScript files to ensure consistency
philip.zeyligerc0a44592025-06-15 21:24:57 -0700300 if strings.HasSuffix(tsName, ".ts") {
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000301 if err := esbuildBundleWithExternals(tmpHashDir, filepath.Join(buildDir, tsName), monacoHash, mermaidHash); err != nil {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700302 return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
303 }
304 } else {
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000305 // Bundle worker files normally (they don't use Monaco or Mermaid)
philip.zeyligerc0a44592025-06-15 21:24:57 -0700306 if err := esbuildBundle(tmpHashDir, filepath.Join(buildDir, tsName), ""); err != nil {
307 return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
308 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700309 }
310 }
311
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700312 // Copy Monaco editor assets
313 if err := copyMonacoAssets(buildDir, tmpHashDir); err != nil {
314 return nil, fmt.Errorf("failed to copy Monaco assets: %w", err)
315 }
316
Earl Lee2e463fb2025-04-17 11:22:22 -0700317 // Copy src files used directly into the new hash output dir.
318 err = fs.WalkDir(embedded, "src", func(path string, d fs.DirEntry, err error) error {
319 if d.IsDir() {
Sean McCullough86b56862025-04-18 13:04:03 -0700320 if path == "src/web-components/demo" {
321 return fs.SkipDir
322 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700323 return nil
324 }
Pokey Rule8cac59a2025-04-24 12:21:19 +0100325 if strings.HasSuffix(path, "mockServiceWorker.js") {
326 return nil
327 }
Sean McCullough39995932025-06-25 19:32:08 +0000328 // Skip src/tailwind.css as it will be generated
329 if path == "src/tailwind.css" {
330 return nil
331 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700332 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".css") || strings.HasSuffix(path, ".js") {
333 b, err := embedded.ReadFile(path)
334 if err != nil {
335 return err
336 }
337 dstPath := filepath.Join(tmpHashDir, strings.TrimPrefix(path, "src/"))
338 if err := os.WriteFile(dstPath, b, 0o777); err != nil {
339 return err
340 }
341 return nil
342 }
343 return nil
344 })
345 if err != nil {
346 return nil, err
347 }
348
349 // Copy xterm.css from node_modules
350 const xtermCssPath = "node_modules/@xterm/xterm/css/xterm.css"
351 xtermCss, err := os.ReadFile(filepath.Join(buildDir, xtermCssPath))
352 if err != nil {
353 return nil, fmt.Errorf("failed to read xterm.css: %w", err)
354 }
355 if err := os.WriteFile(filepath.Join(tmpHashDir, "xterm.css"), xtermCss, 0o666); err != nil {
356 return nil, fmt.Errorf("failed to write xterm.css: %w", err)
357 }
358
Philip Zeyliger176de792025-04-21 12:25:18 -0700359 // Compress all .js, .js.map, and .css files with gzip, leaving the originals in place
360 err = filepath.Walk(tmpHashDir, func(path string, info os.FileInfo, err error) error {
361 if err != nil {
362 return err
363 }
364 if info.IsDir() {
365 return nil
366 }
367 // Check if file is a .js or .js.map file
368 if !strings.HasSuffix(path, ".js") && !strings.HasSuffix(path, ".js.map") && !strings.HasSuffix(path, ".css") {
369 return nil
370 }
371
372 // Read the original file
373 origData, err := os.ReadFile(path)
374 if err != nil {
375 return fmt.Errorf("failed to read file %s: %w", path, err)
376 }
377
378 // Create a gzipped file
379 gzipPath := path + ".gz"
380 gzipFile, err := os.Create(gzipPath)
381 if err != nil {
382 return fmt.Errorf("failed to create gzip file %s: %w", gzipPath, err)
383 }
384 defer gzipFile.Close()
385
386 // Create a gzip writer
387 gzWriter := gzip.NewWriter(gzipFile)
388 defer gzWriter.Close()
389
390 // Write the original file content to the gzip writer
391 _, err = gzWriter.Write(origData)
392 if err != nil {
393 return fmt.Errorf("failed to write to gzip file %s: %w", gzipPath, err)
394 }
395
396 // Ensure we flush and close properly
397 if err := gzWriter.Close(); err != nil {
398 return fmt.Errorf("failed to close gzip writer for %s: %w", gzipPath, err)
399 }
400 if err := gzipFile.Close(); err != nil {
401 return fmt.Errorf("failed to close gzip file %s: %w", gzipPath, err)
402 }
403
Josh Bleecher Snydera002a232025-07-09 19:38:03 +0000404 // The gzip handler will decompress on-the-fly for browsers that don't support gzip.
405 if err := os.Remove(path); err != nil {
406 return fmt.Errorf("failed to remove uncompressed file %s: %w", path, err)
407 }
408
Philip Zeyliger176de792025-04-21 12:25:18 -0700409 return nil
410 })
411 if err != nil {
412 return nil, fmt.Errorf("failed to compress .js/.js.map/.css files: %w", err)
413 }
414
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700415 return os.DirFS(tmpHashDir), nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700416}
417
Sean McCullough86b56862025-04-18 13:04:03 -0700418func esbuildBundle(outDir, src, metafilePath string) error {
419 args := []string{
Earl Lee2e463fb2025-04-17 11:22:22 -0700420 src,
421 "--bundle",
422 "--sourcemap",
423 "--log-level=error",
philip.zeyligerc0a44592025-06-15 21:24:57 -0700424 "--minify",
Earl Lee2e463fb2025-04-17 11:22:22 -0700425 "--outdir=" + outDir,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700426 "--loader:.ttf=file",
427 "--loader:.eot=file",
428 "--loader:.woff=file",
429 "--loader:.woff2=file",
430 // This changes where the sourcemap points to; we need relative dirs if we're proxied into a subdirectory.
431 "--public-path=.",
Sean McCullough86b56862025-04-18 13:04:03 -0700432 }
433
434 // Add metafile option if path is provided
435 if metafilePath != "" {
436 args = append(args, "--metafile="+metafilePath)
437 }
438
439 ret := esbuildcli.Run(args)
Earl Lee2e463fb2025-04-17 11:22:22 -0700440 if ret != 0 {
441 return fmt.Errorf("esbuild %s failed: %d", filepath.Base(src), ret)
442 }
443 return nil
444}
Sean McCullough86b56862025-04-18 13:04:03 -0700445
446// unpackTS unpacks all the typescript-relevant files from the embedded filesystem into tmpDir.
447func unpackTS(outDir string, embedded fs.FS) error {
448 return fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error {
449 if err != nil {
450 return err
451 }
452 tgt := filepath.Join(outDir, path)
453 if d.IsDir() {
454 if err := os.MkdirAll(tgt, 0o777); err != nil {
455 return err
456 }
457 return nil
458 }
459 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".md") || strings.HasSuffix(path, ".css") {
460 return nil
461 }
462 data, err := fs.ReadFile(embedded, path)
463 if err != nil {
464 return err
465 }
466 if err := os.WriteFile(tgt, data, 0o666); err != nil {
467 return err
468 }
469 return nil
470 })
471}
472
473// GenerateBundleMetafile creates metafiles for bundle analysis with esbuild.
474//
475// The metafiles contain information about bundle size and dependencies
476// that can be visualized at https://esbuild.github.io/analyze/
477//
478// It takes the output directory where the metafiles will be written.
479// Returns the file path of the generated metafiles.
480func GenerateBundleMetafile(outputDir string) (string, error) {
481 tmpDir, err := os.MkdirTemp("", "bundle-analysis-")
482 if err != nil {
483 return "", err
484 }
485 defer os.RemoveAll(tmpDir)
486
487 // Create output directory if it doesn't exist
Philip Zeyligerd1402952025-04-23 03:54:37 +0000488 if err := os.MkdirAll(outputDir, 0o755); err != nil {
Sean McCullough86b56862025-04-18 13:04:03 -0700489 return "", err
490 }
491
492 cacheDir, _, err := zipPath()
493 if err != nil {
494 return "", err
495 }
496 buildDir := filepath.Join(cacheDir, "build")
497 if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
498 return "", err
499 }
500
501 // Ensure we have a source to bundle
502 if err := unpackTS(tmpDir, embedded); err != nil {
503 return "", err
504 }
505
506 // All bundles to analyze
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700507 bundleTs := []string{
508 "src/web-components/sketch-app-shell.ts",
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700509 "src/web-components/mobile-app-shell.ts",
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700510 "src/web-components/sketch-monaco-view.ts",
Sean McCullough021231a2025-06-12 09:35:24 -0700511 "src/messages-viewer.ts",
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700512 }
Sean McCullough86b56862025-04-18 13:04:03 -0700513 metafiles := make([]string, len(bundleTs))
514
515 for i, tsName := range bundleTs {
516 // Create a metafile path for this bundle
517 baseFileName := filepath.Base(tsName)
518 metaFileName := strings.TrimSuffix(baseFileName, ".ts") + ".meta.json"
519 metafilePath := filepath.Join(outputDir, metaFileName)
520 metafiles[i] = metafilePath
521
522 // Bundle with metafile generation
523 outTmpDir, err := os.MkdirTemp("", "metafile-bundle-")
524 if err != nil {
525 return "", err
526 }
527 defer os.RemoveAll(outTmpDir)
528
529 if err := esbuildBundle(outTmpDir, filepath.Join(buildDir, tsName), metafilePath); err != nil {
530 return "", fmt.Errorf("failed to generate metafile for %s: %w", tsName, err)
531 }
532 }
533
534 return outputDir, nil
535}
philip.zeyligerc0a44592025-06-15 21:24:57 -0700536
537// createStandaloneMonacoBundle creates a separate Monaco editor bundle with content-based hash
538// This is useful for caching Monaco separately from the main application bundles
539func createStandaloneMonacoBundle(outDir, buildDir string) (string, error) {
540 // Create a temporary entry file that imports Monaco and exposes it globally
541 monacoEntryContent := `import * as monaco from 'monaco-editor';
542window.monaco = monaco;
543export default monaco;
544`
545 monacoEntryPath := filepath.Join(buildDir, "monaco-standalone-entry.js")
546 if err := os.WriteFile(monacoEntryPath, []byte(monacoEntryContent), 0o666); err != nil {
547 return "", fmt.Errorf("write monaco entry: %w", err)
548 }
549
550 // Calculate hash of monaco-editor package for content-based naming
551 monacoPackageJson := filepath.Join(buildDir, "node_modules", "monaco-editor", "package.json")
552 monacoContent, err := os.ReadFile(monacoPackageJson)
553 if err != nil {
554 return "", fmt.Errorf("read monaco package.json: %w", err)
555 }
556
557 h := sha256.New()
558 h.Write(monacoContent)
559 monacoHash := hex.EncodeToString(h.Sum(nil))[:16]
560
561 // Bundle Monaco with content-based filename
562 monacoOutputName := fmt.Sprintf("monaco-standalone-%s.js", monacoHash)
563 monacoOutputPath := filepath.Join(outDir, monacoOutputName)
564
565 args := []string{
566 monacoEntryPath,
567 "--bundle",
568 "--sourcemap",
569 "--minify",
570 "--log-level=error",
571 "--outfile=" + monacoOutputPath,
572 "--format=iife",
573 "--global-name=__MonacoLoader__",
574 "--loader:.ttf=file",
575 "--loader:.eot=file",
576 "--loader:.woff=file",
577 "--loader:.woff2=file",
578 "--public-path=.",
579 }
580
581 ret := esbuildcli.Run(args)
582 if ret != 0 {
583 return "", fmt.Errorf("esbuild monaco bundle failed: %d", ret)
584 }
585
586 return monacoHash, nil
587}
588
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000589// createStandaloneMermaidBundle creates a separate Mermaid bundle with content-based hash
590// This is useful for caching Mermaid separately from the main application bundles
591func createStandaloneMermaidBundle(outDir, buildDir string) (string, error) {
592 // Create a temporary entry file that imports Mermaid and exposes it globally
593 mermaidEntryContent := `import mermaid from 'mermaid';
594window.mermaid = mermaid;
595export default mermaid;
596`
597 mermaidEntryPath := filepath.Join(buildDir, "mermaid-standalone-entry.js")
598 if err := os.WriteFile(mermaidEntryPath, []byte(mermaidEntryContent), 0o666); err != nil {
599 return "", fmt.Errorf("write mermaid entry: %w", err)
600 }
601
602 // Calculate hash of mermaid package for content-based naming
603 mermaidPackageJson := filepath.Join(buildDir, "node_modules", "mermaid", "package.json")
604 mermaidContent, err := os.ReadFile(mermaidPackageJson)
605 if err != nil {
606 return "", fmt.Errorf("read mermaid package.json: %w", err)
607 }
608
609 h := sha256.New()
610 h.Write(mermaidContent)
611 mermaidHash := hex.EncodeToString(h.Sum(nil))[:16]
612
613 // Bundle Mermaid with content-based filename
614 mermaidOutputName := fmt.Sprintf("mermaid-standalone-%s.js", mermaidHash)
615 mermaidOutputPath := filepath.Join(outDir, mermaidOutputName)
616
617 args := []string{
618 mermaidEntryPath,
619 "--bundle",
620 "--sourcemap",
621 "--minify",
622 "--log-level=error",
623 "--outfile=" + mermaidOutputPath,
624 "--format=iife",
625 "--global-name=__MermaidLoader__",
626 "--loader:.ttf=file",
627 "--loader:.eot=file",
628 "--loader:.woff=file",
629 "--loader:.woff2=file",
630 "--public-path=.",
631 }
632
633 ret := esbuildcli.Run(args)
634 if ret != 0 {
635 return "", fmt.Errorf("esbuild mermaid bundle failed: %d", ret)
636 }
637
638 return mermaidHash, nil
639}
640
641// esbuildBundleWithExternals bundles a file with Monaco and Mermaid as external dependencies
642func esbuildBundleWithExternals(outDir, src, monacoHash, mermaidHash string) error {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700643 args := []string{
644 src,
645 "--bundle",
646 "--sourcemap",
647 "--minify",
648 "--log-level=error",
649 "--outdir=" + outDir,
650 "--external:monaco-editor",
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000651 "--external:mermaid",
philip.zeyligerc0a44592025-06-15 21:24:57 -0700652 "--loader:.ttf=file",
653 "--loader:.eot=file",
654 "--loader:.woff=file",
655 "--loader:.woff2=file",
656 "--public-path=.",
657 "--define:__MONACO_HASH__=\"" + monacoHash + "\"",
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000658 "--define:__MERMAID_HASH__=\"" + mermaidHash + "\"",
philip.zeyligerc0a44592025-06-15 21:24:57 -0700659 }
660
661 ret := esbuildcli.Run(args)
662 if ret != 0 {
663 return fmt.Errorf("esbuild %s failed: %d", filepath.Base(src), ret)
664 }
665 return nil
666}