blob: 1493fa712f6bf170327333fb78e7bf8d34a63209 [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) {
61 fmt.Printf("[BUILD] node_modules doesn't exist, running npm ci...\n")
62 return runNpmCI(buildDir, packageLockPath, packageLockBackupPath)
63 }
64
65 // Read current package-lock.json
66 packageLockData, err := os.ReadFile(packageLockPath)
67 if err != nil {
68 return fmt.Errorf("read package-lock.json: %w", err)
69 }
70
71 // Check if package-lock.json has changed by comparing with stored version
72 if storedPackageLockData, err := os.ReadFile(packageLockBackupPath); err == nil {
73 if bytes.Equal(packageLockData, storedPackageLockData) {
74 fmt.Printf("[BUILD] package-lock.json unchanged, skipping npm ci\n")
75 return nil
76 }
77 }
78
79 fmt.Printf("[BUILD] package-lock.json changed, running npm ci...\n")
80 return runNpmCI(buildDir, packageLockPath, packageLockBackupPath)
81}
82
83// runNpmCI executes npm ci and stores the package-lock.json content
84func runNpmCI(buildDir, packageLockPath, packageLockBackupPath string) error {
85 start := time.Now()
86 cmd := exec.Command("npm", "ci", "--omit", "dev")
87 cmd.Dir = buildDir
88 if out, err := cmd.CombinedOutput(); err != nil {
89 return fmt.Errorf("npm ci: %s: %v", out, err)
90 }
91 fmt.Printf("[BUILD] npm ci completed in %v\n", time.Since(start))
92
93 // Store a copy of package-lock.json for future comparisons
94 packageLockData, err := os.ReadFile(packageLockPath)
95 if err != nil {
96 return fmt.Errorf("read package-lock.json after npm ci: %w", err)
97 }
98
99 if err := os.WriteFile(packageLockBackupPath, packageLockData, 0o666); err != nil {
100 return fmt.Errorf("write package-lock backup: %w", err)
101 }
102
103 return nil
104}
105
Earl Lee2e463fb2025-04-17 11:22:22 -0700106func cleanBuildDir(buildDir string) error {
107 err := fs.WalkDir(os.DirFS(buildDir), ".", func(path string, d fs.DirEntry, err error) error {
108 if d.Name() == "." {
109 return nil
110 }
111 if d.Name() == "node_modules" {
112 return fs.SkipDir
113 }
Philip Zeyliger09b86f42025-07-16 16:23:03 -0700114 if d.Name() == ".package-lock-installed" {
115 return nil // Skip file, but don't skip directory
116 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700117 osPath := filepath.Join(buildDir, path)
Earl Lee2e463fb2025-04-17 11:22:22 -0700118 os.RemoveAll(osPath)
119 if d.IsDir() {
120 return fs.SkipDir
121 }
122 return nil
123 })
124 if err != nil {
125 return fmt.Errorf("clean build dir: %w", err)
126 }
127 return nil
128}
129
130func unpackFS(out string, srcFS fs.FS) error {
131 err := fs.WalkDir(srcFS, ".", func(path string, d fs.DirEntry, err error) error {
132 if d.Name() == "." {
133 return nil
134 }
135 if d.IsDir() {
136 if err := os.Mkdir(filepath.Join(out, path), 0o777); err != nil {
137 return err
138 }
139 return nil
140 }
141 f, err := srcFS.Open(path)
142 if err != nil {
143 return err
144 }
145 defer f.Close()
146 dst, err := os.Create(filepath.Join(out, path))
147 if err != nil {
148 return err
149 }
150 defer dst.Close()
151 if _, err := io.Copy(dst, f); err != nil {
152 return err
153 }
154 if err := dst.Close(); err != nil {
155 return err
156 }
157 return nil
158 })
159 if err != nil {
160 return fmt.Errorf("unpack fs into out dir %s: %w", out, err)
161 }
162 return nil
163}
164
Philip Zeyliger983b58a2025-07-02 19:42:08 -0700165// TODO: This path being /root/.cache/sketch/webui/skui-....zip means that the Dockerfile
166// in createdockerfile.go needs to create the parent directory. Ideally we bundle the built webui
167// into the binary and avoid this altogether.
David Crawshaw8bff16a2025-04-18 01:16:49 -0700168func zipPath() (cacheDir, hashZip string, err error) {
169 homeDir, err := os.UserHomeDir()
170 if err != nil {
171 return "", "", err
172 }
173 hash, err := embeddedHash()
174 if err != nil {
175 return "", "", err
176 }
177 cacheDir = filepath.Join(homeDir, ".cache", "sketch", "webui")
178 return cacheDir, filepath.Join(cacheDir, "skui-"+hash+".zip"), nil
179}
180
Sean McCullough39995932025-06-25 19:32:08 +0000181// generateTailwindCSS generates tailwind.css from global.css and outputs it to the specified directory
182func generateTailwindCSS(buildDir, outDir string) error {
183 // Run tailwindcss CLI to generate the CSS
184 cmd := exec.Command("npx", "tailwindcss", "-i", "./src/global.css", "-o", filepath.Join(outDir, "tailwind.css"))
185 cmd.Dir = buildDir
186 if out, err := cmd.CombinedOutput(); err != nil {
187 return fmt.Errorf("tailwindcss generation failed: %s: %v", out, err)
188 }
189 return nil
190}
191
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700192// copyMonacoAssets copies Monaco editor assets to the output directory
193func copyMonacoAssets(buildDir, outDir string) error {
194 // Create Monaco directories
195 monacoEditorDir := filepath.Join(outDir, "monaco", "min", "vs", "editor")
196 codiconDir := filepath.Join(outDir, "monaco", "min", "vs", "base", "browser", "ui", "codicons", "codicon")
197
198 if err := os.MkdirAll(monacoEditorDir, 0o777); err != nil {
199 return fmt.Errorf("failed to create monaco editor directory: %w", err)
200 }
201
202 if err := os.MkdirAll(codiconDir, 0o777); err != nil {
203 return fmt.Errorf("failed to create codicon directory: %w", err)
204 }
205
206 // Copy Monaco editor CSS
207 editorCssPath := "node_modules/monaco-editor/min/vs/editor/editor.main.css"
208 editorCss, err := os.ReadFile(filepath.Join(buildDir, editorCssPath))
209 if err != nil {
210 return fmt.Errorf("failed to read monaco editor CSS: %w", err)
211 }
212
213 if err := os.WriteFile(filepath.Join(monacoEditorDir, "editor.main.css"), editorCss, 0o666); err != nil {
214 return fmt.Errorf("failed to write monaco editor CSS: %w", err)
215 }
216
217 // Copy Codicon font
218 codiconFontPath := "node_modules/monaco-editor/min/vs/base/browser/ui/codicons/codicon/codicon.ttf"
219 codiconFont, err := os.ReadFile(filepath.Join(buildDir, codiconFontPath))
220 if err != nil {
221 return fmt.Errorf("failed to read codicon font: %w", err)
222 }
223
224 if err := os.WriteFile(filepath.Join(codiconDir, "codicon.ttf"), codiconFont, 0o666); err != nil {
225 return fmt.Errorf("failed to write codicon font: %w", err)
226 }
227
228 return nil
229}
230
Earl Lee2e463fb2025-04-17 11:22:22 -0700231// Build unpacks and esbuild's all bundleTs typescript files
232func Build() (fs.FS, error) {
David Crawshaw8bff16a2025-04-18 01:16:49 -0700233 cacheDir, hashZip, err := zipPath()
Earl Lee2e463fb2025-04-17 11:22:22 -0700234 if err != nil {
235 return nil, err
236 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700237 buildDir := filepath.Join(cacheDir, "build")
238 if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
239 return nil, err
240 }
David Crawshaw8bff16a2025-04-18 01:16:49 -0700241 if b, err := os.ReadFile(hashZip); err == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700242 // Build already done, serve it out.
David Crawshaw8bff16a2025-04-18 01:16:49 -0700243 return zip.NewReader(bytes.NewReader(b), int64(len(b)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700244 }
245
David Crawshaw8bff16a2025-04-18 01:16:49 -0700246 // TODO: try downloading "https://sketch.dev/webui/"+filepath.Base(hashZip)
247
Earl Lee2e463fb2025-04-17 11:22:22 -0700248 // We need to do a build.
Philip Zeyliger09b86f42025-07-16 16:23:03 -0700249 fmt.Printf("[BUILD] Starting webui build process...\n")
250 buildStart := time.Now()
Earl Lee2e463fb2025-04-17 11:22:22 -0700251
252 // Clear everything out of the build directory except node_modules.
Philip Zeyliger09b86f42025-07-16 16:23:03 -0700253 fmt.Printf("[BUILD] Cleaning build directory...\n")
Earl Lee2e463fb2025-04-17 11:22:22 -0700254 if err := cleanBuildDir(buildDir); err != nil {
255 return nil, err
256 }
257 tmpHashDir := filepath.Join(buildDir, "out")
Philip Zeyliger09b86f42025-07-16 16:23:03 -0700258 if err := os.MkdirAll(tmpHashDir, 0o777); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700259 return nil, err
260 }
261
262 // Unpack everything from embedded into build dir.
Philip Zeyliger09b86f42025-07-16 16:23:03 -0700263 fmt.Printf("[BUILD] Unpacking embedded files...\n")
Earl Lee2e463fb2025-04-17 11:22:22 -0700264 if err := unpackFS(buildDir, embedded); err != nil {
265 return nil, err
266 }
267
Sean McCullough86b56862025-04-18 13:04:03 -0700268 // Do the build. Don't install dev dependencies, because they can be large
269 // and slow enough to install that the /init requests from the host process
270 // will run out of retries and the whole thing exits. We do need better health
271 // checking in general, but that's a separate issue. Don't do slow stuff here:
Philip Zeyliger09b86f42025-07-16 16:23:03 -0700272 if err := ensureNodeModules(buildDir); err != nil {
273 return nil, fmt.Errorf("ensure node modules: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700274 }
Sean McCullough39995932025-06-25 19:32:08 +0000275
276 // Generate Tailwind CSS
277 if err := generateTailwindCSS(buildDir, tmpHashDir); err != nil {
278 return nil, fmt.Errorf("generate tailwind css: %w", err)
279 }
philip.zeyligerc0a44592025-06-15 21:24:57 -0700280 // Create all bundles
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700281 bundleTs := []string{
282 "src/web-components/sketch-app-shell.ts",
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700283 "src/web-components/mobile-app-shell.ts",
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700284 "src/web-components/sketch-monaco-view.ts",
Sean McCullough021231a2025-06-12 09:35:24 -0700285 "src/messages-viewer.ts",
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700286 "node_modules/monaco-editor/esm/vs/editor/editor.worker.js",
287 "node_modules/monaco-editor/esm/vs/language/typescript/ts.worker.js",
288 "node_modules/monaco-editor/esm/vs/language/html/html.worker.js",
289 "node_modules/monaco-editor/esm/vs/language/css/css.worker.js",
290 "node_modules/monaco-editor/esm/vs/language/json/json.worker.js",
291 }
292
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000293 // Additionally create standalone bundles for caching
philip.zeyligerc0a44592025-06-15 21:24:57 -0700294 monacoHash, err := createStandaloneMonacoBundle(tmpHashDir, buildDir)
295 if err != nil {
296 return nil, fmt.Errorf("create monaco bundle: %w", err)
297 }
philip.zeyligerc0a44592025-06-15 21:24:57 -0700298
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000299 mermaidHash, err := createStandaloneMermaidBundle(tmpHashDir, buildDir)
300 if err != nil {
301 return nil, fmt.Errorf("create mermaid bundle: %w", err)
302 }
303
304 // Bundle all files with Monaco and Mermaid as external (since they may transitively import them)
Earl Lee2e463fb2025-04-17 11:22:22 -0700305 for _, tsName := range bundleTs {
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000306 // Use external Monaco and Mermaid for all TypeScript files to ensure consistency
philip.zeyligerc0a44592025-06-15 21:24:57 -0700307 if strings.HasSuffix(tsName, ".ts") {
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000308 if err := esbuildBundleWithExternals(tmpHashDir, filepath.Join(buildDir, tsName), monacoHash, mermaidHash); err != nil {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700309 return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
310 }
311 } else {
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000312 // Bundle worker files normally (they don't use Monaco or Mermaid)
philip.zeyligerc0a44592025-06-15 21:24:57 -0700313 if err := esbuildBundle(tmpHashDir, filepath.Join(buildDir, tsName), ""); err != nil {
314 return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
315 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700316 }
317 }
318
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700319 // Copy Monaco editor assets
320 if err := copyMonacoAssets(buildDir, tmpHashDir); err != nil {
321 return nil, fmt.Errorf("failed to copy Monaco assets: %w", err)
322 }
323
Earl Lee2e463fb2025-04-17 11:22:22 -0700324 // Copy src files used directly into the new hash output dir.
325 err = fs.WalkDir(embedded, "src", func(path string, d fs.DirEntry, err error) error {
326 if d.IsDir() {
Sean McCullough86b56862025-04-18 13:04:03 -0700327 if path == "src/web-components/demo" {
328 return fs.SkipDir
329 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700330 return nil
331 }
Pokey Rule8cac59a2025-04-24 12:21:19 +0100332 if strings.HasSuffix(path, "mockServiceWorker.js") {
333 return nil
334 }
Sean McCullough39995932025-06-25 19:32:08 +0000335 // Skip src/tailwind.css as it will be generated
336 if path == "src/tailwind.css" {
337 return nil
338 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700339 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".css") || strings.HasSuffix(path, ".js") {
340 b, err := embedded.ReadFile(path)
341 if err != nil {
342 return err
343 }
344 dstPath := filepath.Join(tmpHashDir, strings.TrimPrefix(path, "src/"))
345 if err := os.WriteFile(dstPath, b, 0o777); err != nil {
346 return err
347 }
348 return nil
349 }
350 return nil
351 })
352 if err != nil {
353 return nil, err
354 }
355
356 // Copy xterm.css from node_modules
357 const xtermCssPath = "node_modules/@xterm/xterm/css/xterm.css"
358 xtermCss, err := os.ReadFile(filepath.Join(buildDir, xtermCssPath))
359 if err != nil {
360 return nil, fmt.Errorf("failed to read xterm.css: %w", err)
361 }
362 if err := os.WriteFile(filepath.Join(tmpHashDir, "xterm.css"), xtermCss, 0o666); err != nil {
363 return nil, fmt.Errorf("failed to write xterm.css: %w", err)
364 }
365
Philip Zeyliger176de792025-04-21 12:25:18 -0700366 // Compress all .js, .js.map, and .css files with gzip, leaving the originals in place
367 err = filepath.Walk(tmpHashDir, func(path string, info os.FileInfo, err error) error {
368 if err != nil {
369 return err
370 }
371 if info.IsDir() {
372 return nil
373 }
374 // Check if file is a .js or .js.map file
375 if !strings.HasSuffix(path, ".js") && !strings.HasSuffix(path, ".js.map") && !strings.HasSuffix(path, ".css") {
376 return nil
377 }
378
379 // Read the original file
380 origData, err := os.ReadFile(path)
381 if err != nil {
382 return fmt.Errorf("failed to read file %s: %w", path, err)
383 }
384
385 // Create a gzipped file
386 gzipPath := path + ".gz"
387 gzipFile, err := os.Create(gzipPath)
388 if err != nil {
389 return fmt.Errorf("failed to create gzip file %s: %w", gzipPath, err)
390 }
391 defer gzipFile.Close()
392
393 // Create a gzip writer
394 gzWriter := gzip.NewWriter(gzipFile)
395 defer gzWriter.Close()
396
397 // Write the original file content to the gzip writer
398 _, err = gzWriter.Write(origData)
399 if err != nil {
400 return fmt.Errorf("failed to write to gzip file %s: %w", gzipPath, err)
401 }
402
403 // Ensure we flush and close properly
404 if err := gzWriter.Close(); err != nil {
405 return fmt.Errorf("failed to close gzip writer for %s: %w", gzipPath, err)
406 }
407 if err := gzipFile.Close(); err != nil {
408 return fmt.Errorf("failed to close gzip file %s: %w", gzipPath, err)
409 }
410
Josh Bleecher Snydera002a232025-07-09 19:38:03 +0000411 // The gzip handler will decompress on-the-fly for browsers that don't support gzip.
412 if err := os.Remove(path); err != nil {
413 return fmt.Errorf("failed to remove uncompressed file %s: %w", path, err)
414 }
415
Philip Zeyliger176de792025-04-21 12:25:18 -0700416 return nil
417 })
418 if err != nil {
419 return nil, fmt.Errorf("failed to compress .js/.js.map/.css files: %w", err)
420 }
421
Philip Zeyliger09b86f42025-07-16 16:23:03 -0700422 fmt.Printf("[BUILD] Build completed in %v\n", time.Since(buildStart))
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700423 return os.DirFS(tmpHashDir), nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700424}
425
Sean McCullough86b56862025-04-18 13:04:03 -0700426func esbuildBundle(outDir, src, metafilePath string) error {
427 args := []string{
Earl Lee2e463fb2025-04-17 11:22:22 -0700428 src,
429 "--bundle",
430 "--sourcemap",
431 "--log-level=error",
philip.zeyligerc0a44592025-06-15 21:24:57 -0700432 "--minify",
Earl Lee2e463fb2025-04-17 11:22:22 -0700433 "--outdir=" + outDir,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700434 "--loader:.ttf=file",
435 "--loader:.eot=file",
436 "--loader:.woff=file",
437 "--loader:.woff2=file",
438 // This changes where the sourcemap points to; we need relative dirs if we're proxied into a subdirectory.
439 "--public-path=.",
Sean McCullough86b56862025-04-18 13:04:03 -0700440 }
441
442 // Add metafile option if path is provided
443 if metafilePath != "" {
444 args = append(args, "--metafile="+metafilePath)
445 }
446
447 ret := esbuildcli.Run(args)
Earl Lee2e463fb2025-04-17 11:22:22 -0700448 if ret != 0 {
449 return fmt.Errorf("esbuild %s failed: %d", filepath.Base(src), ret)
450 }
451 return nil
452}
Sean McCullough86b56862025-04-18 13:04:03 -0700453
454// unpackTS unpacks all the typescript-relevant files from the embedded filesystem into tmpDir.
455func unpackTS(outDir string, embedded fs.FS) error {
456 return fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error {
457 if err != nil {
458 return err
459 }
460 tgt := filepath.Join(outDir, path)
461 if d.IsDir() {
462 if err := os.MkdirAll(tgt, 0o777); err != nil {
463 return err
464 }
465 return nil
466 }
467 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".md") || strings.HasSuffix(path, ".css") {
468 return nil
469 }
470 data, err := fs.ReadFile(embedded, path)
471 if err != nil {
472 return err
473 }
474 if err := os.WriteFile(tgt, data, 0o666); err != nil {
475 return err
476 }
477 return nil
478 })
479}
480
481// GenerateBundleMetafile creates metafiles for bundle analysis with esbuild.
482//
483// The metafiles contain information about bundle size and dependencies
484// that can be visualized at https://esbuild.github.io/analyze/
485//
486// It takes the output directory where the metafiles will be written.
487// Returns the file path of the generated metafiles.
488func GenerateBundleMetafile(outputDir string) (string, error) {
489 tmpDir, err := os.MkdirTemp("", "bundle-analysis-")
490 if err != nil {
491 return "", err
492 }
493 defer os.RemoveAll(tmpDir)
494
495 // Create output directory if it doesn't exist
Philip Zeyligerd1402952025-04-23 03:54:37 +0000496 if err := os.MkdirAll(outputDir, 0o755); err != nil {
Sean McCullough86b56862025-04-18 13:04:03 -0700497 return "", err
498 }
499
500 cacheDir, _, err := zipPath()
501 if err != nil {
502 return "", err
503 }
504 buildDir := filepath.Join(cacheDir, "build")
505 if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
506 return "", err
507 }
508
509 // Ensure we have a source to bundle
510 if err := unpackTS(tmpDir, embedded); err != nil {
511 return "", err
512 }
513
514 // All bundles to analyze
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700515 bundleTs := []string{
516 "src/web-components/sketch-app-shell.ts",
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700517 "src/web-components/mobile-app-shell.ts",
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700518 "src/web-components/sketch-monaco-view.ts",
Sean McCullough021231a2025-06-12 09:35:24 -0700519 "src/messages-viewer.ts",
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700520 }
Sean McCullough86b56862025-04-18 13:04:03 -0700521 metafiles := make([]string, len(bundleTs))
522
523 for i, tsName := range bundleTs {
524 // Create a metafile path for this bundle
525 baseFileName := filepath.Base(tsName)
526 metaFileName := strings.TrimSuffix(baseFileName, ".ts") + ".meta.json"
527 metafilePath := filepath.Join(outputDir, metaFileName)
528 metafiles[i] = metafilePath
529
530 // Bundle with metafile generation
531 outTmpDir, err := os.MkdirTemp("", "metafile-bundle-")
532 if err != nil {
533 return "", err
534 }
535 defer os.RemoveAll(outTmpDir)
536
537 if err := esbuildBundle(outTmpDir, filepath.Join(buildDir, tsName), metafilePath); err != nil {
538 return "", fmt.Errorf("failed to generate metafile for %s: %w", tsName, err)
539 }
540 }
541
542 return outputDir, nil
543}
philip.zeyligerc0a44592025-06-15 21:24:57 -0700544
545// createStandaloneMonacoBundle creates a separate Monaco editor bundle with content-based hash
546// This is useful for caching Monaco separately from the main application bundles
547func createStandaloneMonacoBundle(outDir, buildDir string) (string, error) {
548 // Create a temporary entry file that imports Monaco and exposes it globally
549 monacoEntryContent := `import * as monaco from 'monaco-editor';
550window.monaco = monaco;
551export default monaco;
552`
553 monacoEntryPath := filepath.Join(buildDir, "monaco-standalone-entry.js")
554 if err := os.WriteFile(monacoEntryPath, []byte(monacoEntryContent), 0o666); err != nil {
555 return "", fmt.Errorf("write monaco entry: %w", err)
556 }
557
558 // Calculate hash of monaco-editor package for content-based naming
559 monacoPackageJson := filepath.Join(buildDir, "node_modules", "monaco-editor", "package.json")
560 monacoContent, err := os.ReadFile(monacoPackageJson)
561 if err != nil {
562 return "", fmt.Errorf("read monaco package.json: %w", err)
563 }
564
565 h := sha256.New()
566 h.Write(monacoContent)
567 monacoHash := hex.EncodeToString(h.Sum(nil))[:16]
568
569 // Bundle Monaco with content-based filename
570 monacoOutputName := fmt.Sprintf("monaco-standalone-%s.js", monacoHash)
571 monacoOutputPath := filepath.Join(outDir, monacoOutputName)
572
573 args := []string{
574 monacoEntryPath,
575 "--bundle",
576 "--sourcemap",
577 "--minify",
578 "--log-level=error",
579 "--outfile=" + monacoOutputPath,
580 "--format=iife",
581 "--global-name=__MonacoLoader__",
582 "--loader:.ttf=file",
583 "--loader:.eot=file",
584 "--loader:.woff=file",
585 "--loader:.woff2=file",
586 "--public-path=.",
587 }
588
589 ret := esbuildcli.Run(args)
590 if ret != 0 {
591 return "", fmt.Errorf("esbuild monaco bundle failed: %d", ret)
592 }
593
594 return monacoHash, nil
595}
596
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000597// createStandaloneMermaidBundle creates a separate Mermaid bundle with content-based hash
598// This is useful for caching Mermaid separately from the main application bundles
599func createStandaloneMermaidBundle(outDir, buildDir string) (string, error) {
600 // Create a temporary entry file that imports Mermaid and exposes it globally
601 mermaidEntryContent := `import mermaid from 'mermaid';
602window.mermaid = mermaid;
603export default mermaid;
604`
605 mermaidEntryPath := filepath.Join(buildDir, "mermaid-standalone-entry.js")
606 if err := os.WriteFile(mermaidEntryPath, []byte(mermaidEntryContent), 0o666); err != nil {
607 return "", fmt.Errorf("write mermaid entry: %w", err)
608 }
609
610 // Calculate hash of mermaid package for content-based naming
611 mermaidPackageJson := filepath.Join(buildDir, "node_modules", "mermaid", "package.json")
612 mermaidContent, err := os.ReadFile(mermaidPackageJson)
613 if err != nil {
614 return "", fmt.Errorf("read mermaid package.json: %w", err)
615 }
616
617 h := sha256.New()
618 h.Write(mermaidContent)
619 mermaidHash := hex.EncodeToString(h.Sum(nil))[:16]
620
621 // Bundle Mermaid with content-based filename
622 mermaidOutputName := fmt.Sprintf("mermaid-standalone-%s.js", mermaidHash)
623 mermaidOutputPath := filepath.Join(outDir, mermaidOutputName)
624
625 args := []string{
626 mermaidEntryPath,
627 "--bundle",
628 "--sourcemap",
629 "--minify",
630 "--log-level=error",
631 "--outfile=" + mermaidOutputPath,
632 "--format=iife",
633 "--global-name=__MermaidLoader__",
634 "--loader:.ttf=file",
635 "--loader:.eot=file",
636 "--loader:.woff=file",
637 "--loader:.woff2=file",
638 "--public-path=.",
639 }
640
641 ret := esbuildcli.Run(args)
642 if ret != 0 {
643 return "", fmt.Errorf("esbuild mermaid bundle failed: %d", ret)
644 }
645
646 return mermaidHash, nil
647}
648
649// esbuildBundleWithExternals bundles a file with Monaco and Mermaid as external dependencies
650func esbuildBundleWithExternals(outDir, src, monacoHash, mermaidHash string) error {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700651 args := []string{
652 src,
653 "--bundle",
654 "--sourcemap",
655 "--minify",
656 "--log-level=error",
657 "--outdir=" + outDir,
658 "--external:monaco-editor",
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000659 "--external:mermaid",
philip.zeyligerc0a44592025-06-15 21:24:57 -0700660 "--loader:.ttf=file",
661 "--loader:.eot=file",
662 "--loader:.woff=file",
663 "--loader:.woff2=file",
664 "--public-path=.",
665 "--define:__MONACO_HASH__=\"" + monacoHash + "\"",
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000666 "--define:__MERMAID_HASH__=\"" + mermaidHash + "\"",
philip.zeyligerc0a44592025-06-15 21:24:57 -0700667 }
668
669 ret := esbuildcli.Run(args)
670 if ret != 0 {
671 return fmt.Errorf("esbuild %s failed: %d", filepath.Base(src), ret)
672 }
673 return nil
674}