blob: 246f9cbda0d03faaece708458dfa09979a2e6cea [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",
278 "node_modules/monaco-editor/esm/vs/editor/editor.worker.js",
279 "node_modules/monaco-editor/esm/vs/language/typescript/ts.worker.js",
280 "node_modules/monaco-editor/esm/vs/language/html/html.worker.js",
281 "node_modules/monaco-editor/esm/vs/language/css/css.worker.js",
282 "node_modules/monaco-editor/esm/vs/language/json/json.worker.js",
283 }
284
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000285 // Additionally create standalone bundles for caching
philip.zeyligerc0a44592025-06-15 21:24:57 -0700286 monacoHash, err := createStandaloneMonacoBundle(tmpHashDir, buildDir)
287 if err != nil {
288 return nil, fmt.Errorf("create monaco bundle: %w", err)
289 }
philip.zeyligerc0a44592025-06-15 21:24:57 -0700290
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000291 mermaidHash, err := createStandaloneMermaidBundle(tmpHashDir, buildDir)
292 if err != nil {
293 return nil, fmt.Errorf("create mermaid bundle: %w", err)
294 }
295
296 // Bundle all files with Monaco and Mermaid as external (since they may transitively import them)
Earl Lee2e463fb2025-04-17 11:22:22 -0700297 for _, tsName := range bundleTs {
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000298 // Use external Monaco and Mermaid for all TypeScript files to ensure consistency
philip.zeyligerc0a44592025-06-15 21:24:57 -0700299 if strings.HasSuffix(tsName, ".ts") {
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000300 if err := esbuildBundleWithExternals(tmpHashDir, filepath.Join(buildDir, tsName), monacoHash, mermaidHash); err != nil {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700301 return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
302 }
303 } else {
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000304 // Bundle worker files normally (they don't use Monaco or Mermaid)
philip.zeyligerc0a44592025-06-15 21:24:57 -0700305 if err := esbuildBundle(tmpHashDir, filepath.Join(buildDir, tsName), ""); err != nil {
306 return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
307 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700308 }
309 }
310
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700311 // Copy Monaco editor assets
312 if err := copyMonacoAssets(buildDir, tmpHashDir); err != nil {
313 return nil, fmt.Errorf("failed to copy Monaco assets: %w", err)
314 }
315
Earl Lee2e463fb2025-04-17 11:22:22 -0700316 // Copy src files used directly into the new hash output dir.
317 err = fs.WalkDir(embedded, "src", func(path string, d fs.DirEntry, err error) error {
318 if d.IsDir() {
Sean McCullough86b56862025-04-18 13:04:03 -0700319 if path == "src/web-components/demo" {
320 return fs.SkipDir
321 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700322 return nil
323 }
Pokey Rule8cac59a2025-04-24 12:21:19 +0100324 if strings.HasSuffix(path, "mockServiceWorker.js") {
325 return nil
326 }
Sean McCullough39995932025-06-25 19:32:08 +0000327 // Skip src/tailwind.css as it will be generated
328 if path == "src/tailwind.css" {
329 return nil
330 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700331 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".css") || strings.HasSuffix(path, ".js") {
332 b, err := embedded.ReadFile(path)
333 if err != nil {
334 return err
335 }
336 dstPath := filepath.Join(tmpHashDir, strings.TrimPrefix(path, "src/"))
337 if err := os.WriteFile(dstPath, b, 0o777); err != nil {
338 return err
339 }
340 return nil
341 }
342 return nil
343 })
344 if err != nil {
345 return nil, err
346 }
347
348 // Copy xterm.css from node_modules
349 const xtermCssPath = "node_modules/@xterm/xterm/css/xterm.css"
350 xtermCss, err := os.ReadFile(filepath.Join(buildDir, xtermCssPath))
351 if err != nil {
352 return nil, fmt.Errorf("failed to read xterm.css: %w", err)
353 }
354 if err := os.WriteFile(filepath.Join(tmpHashDir, "xterm.css"), xtermCss, 0o666); err != nil {
355 return nil, fmt.Errorf("failed to write xterm.css: %w", err)
356 }
357
Philip Zeyliger176de792025-04-21 12:25:18 -0700358 // Compress all .js, .js.map, and .css files with gzip, leaving the originals in place
359 err = filepath.Walk(tmpHashDir, func(path string, info os.FileInfo, err error) error {
360 if err != nil {
361 return err
362 }
363 if info.IsDir() {
364 return nil
365 }
366 // Check if file is a .js or .js.map file
367 if !strings.HasSuffix(path, ".js") && !strings.HasSuffix(path, ".js.map") && !strings.HasSuffix(path, ".css") {
368 return nil
369 }
370
371 // Read the original file
372 origData, err := os.ReadFile(path)
373 if err != nil {
374 return fmt.Errorf("failed to read file %s: %w", path, err)
375 }
376
377 // Create a gzipped file
378 gzipPath := path + ".gz"
379 gzipFile, err := os.Create(gzipPath)
380 if err != nil {
381 return fmt.Errorf("failed to create gzip file %s: %w", gzipPath, err)
382 }
383 defer gzipFile.Close()
384
385 // Create a gzip writer
386 gzWriter := gzip.NewWriter(gzipFile)
387 defer gzWriter.Close()
388
389 // Write the original file content to the gzip writer
390 _, err = gzWriter.Write(origData)
391 if err != nil {
392 return fmt.Errorf("failed to write to gzip file %s: %w", gzipPath, err)
393 }
394
395 // Ensure we flush and close properly
396 if err := gzWriter.Close(); err != nil {
397 return fmt.Errorf("failed to close gzip writer for %s: %w", gzipPath, err)
398 }
399 if err := gzipFile.Close(); err != nil {
400 return fmt.Errorf("failed to close gzip file %s: %w", gzipPath, err)
401 }
402
Josh Bleecher Snydera002a232025-07-09 19:38:03 +0000403 // The gzip handler will decompress on-the-fly for browsers that don't support gzip.
404 if err := os.Remove(path); err != nil {
405 return fmt.Errorf("failed to remove uncompressed file %s: %w", path, err)
406 }
407
Philip Zeyliger176de792025-04-21 12:25:18 -0700408 return nil
409 })
410 if err != nil {
411 return nil, fmt.Errorf("failed to compress .js/.js.map/.css files: %w", err)
412 }
413
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700414 return os.DirFS(tmpHashDir), nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700415}
416
Sean McCullough86b56862025-04-18 13:04:03 -0700417func esbuildBundle(outDir, src, metafilePath string) error {
418 args := []string{
Earl Lee2e463fb2025-04-17 11:22:22 -0700419 src,
420 "--bundle",
421 "--sourcemap",
422 "--log-level=error",
philip.zeyligerc0a44592025-06-15 21:24:57 -0700423 "--minify",
Earl Lee2e463fb2025-04-17 11:22:22 -0700424 "--outdir=" + outDir,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700425 "--loader:.ttf=file",
426 "--loader:.eot=file",
427 "--loader:.woff=file",
428 "--loader:.woff2=file",
429 // This changes where the sourcemap points to; we need relative dirs if we're proxied into a subdirectory.
430 "--public-path=.",
Sean McCullough86b56862025-04-18 13:04:03 -0700431 }
432
433 // Add metafile option if path is provided
434 if metafilePath != "" {
435 args = append(args, "--metafile="+metafilePath)
436 }
437
438 ret := esbuildcli.Run(args)
Earl Lee2e463fb2025-04-17 11:22:22 -0700439 if ret != 0 {
440 return fmt.Errorf("esbuild %s failed: %d", filepath.Base(src), ret)
441 }
442 return nil
443}
Sean McCullough86b56862025-04-18 13:04:03 -0700444
445// unpackTS unpacks all the typescript-relevant files from the embedded filesystem into tmpDir.
446func unpackTS(outDir string, embedded fs.FS) error {
447 return fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error {
448 if err != nil {
449 return err
450 }
451 tgt := filepath.Join(outDir, path)
452 if d.IsDir() {
453 if err := os.MkdirAll(tgt, 0o777); err != nil {
454 return err
455 }
456 return nil
457 }
458 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".md") || strings.HasSuffix(path, ".css") {
459 return nil
460 }
461 data, err := fs.ReadFile(embedded, path)
462 if err != nil {
463 return err
464 }
465 if err := os.WriteFile(tgt, data, 0o666); err != nil {
466 return err
467 }
468 return nil
469 })
470}
471
472// GenerateBundleMetafile creates metafiles for bundle analysis with esbuild.
473//
474// The metafiles contain information about bundle size and dependencies
475// that can be visualized at https://esbuild.github.io/analyze/
476//
477// It takes the output directory where the metafiles will be written.
478// Returns the file path of the generated metafiles.
479func GenerateBundleMetafile(outputDir string) (string, error) {
480 tmpDir, err := os.MkdirTemp("", "bundle-analysis-")
481 if err != nil {
482 return "", err
483 }
484 defer os.RemoveAll(tmpDir)
485
486 // Create output directory if it doesn't exist
Philip Zeyligerd1402952025-04-23 03:54:37 +0000487 if err := os.MkdirAll(outputDir, 0o755); err != nil {
Sean McCullough86b56862025-04-18 13:04:03 -0700488 return "", err
489 }
490
491 cacheDir, _, err := zipPath()
492 if err != nil {
493 return "", err
494 }
495 buildDir := filepath.Join(cacheDir, "build")
496 if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
497 return "", err
498 }
499
500 // Ensure we have a source to bundle
501 if err := unpackTS(tmpDir, embedded); err != nil {
502 return "", err
503 }
504
505 // All bundles to analyze
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700506 bundleTs := []string{
507 "src/web-components/sketch-app-shell.ts",
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700508 "src/web-components/mobile-app-shell.ts",
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700509 "src/web-components/sketch-monaco-view.ts",
510 }
Sean McCullough86b56862025-04-18 13:04:03 -0700511 metafiles := make([]string, len(bundleTs))
512
513 for i, tsName := range bundleTs {
514 // Create a metafile path for this bundle
515 baseFileName := filepath.Base(tsName)
516 metaFileName := strings.TrimSuffix(baseFileName, ".ts") + ".meta.json"
517 metafilePath := filepath.Join(outputDir, metaFileName)
518 metafiles[i] = metafilePath
519
520 // Bundle with metafile generation
521 outTmpDir, err := os.MkdirTemp("", "metafile-bundle-")
522 if err != nil {
523 return "", err
524 }
525 defer os.RemoveAll(outTmpDir)
526
527 if err := esbuildBundle(outTmpDir, filepath.Join(buildDir, tsName), metafilePath); err != nil {
528 return "", fmt.Errorf("failed to generate metafile for %s: %w", tsName, err)
529 }
530 }
531
532 return outputDir, nil
533}
philip.zeyligerc0a44592025-06-15 21:24:57 -0700534
535// createStandaloneMonacoBundle creates a separate Monaco editor bundle with content-based hash
536// This is useful for caching Monaco separately from the main application bundles
537func createStandaloneMonacoBundle(outDir, buildDir string) (string, error) {
538 // Create a temporary entry file that imports Monaco and exposes it globally
539 monacoEntryContent := `import * as monaco from 'monaco-editor';
540window.monaco = monaco;
541export default monaco;
542`
543 monacoEntryPath := filepath.Join(buildDir, "monaco-standalone-entry.js")
544 if err := os.WriteFile(monacoEntryPath, []byte(monacoEntryContent), 0o666); err != nil {
545 return "", fmt.Errorf("write monaco entry: %w", err)
546 }
547
548 // Calculate hash of monaco-editor package for content-based naming
549 monacoPackageJson := filepath.Join(buildDir, "node_modules", "monaco-editor", "package.json")
550 monacoContent, err := os.ReadFile(monacoPackageJson)
551 if err != nil {
552 return "", fmt.Errorf("read monaco package.json: %w", err)
553 }
554
555 h := sha256.New()
556 h.Write(monacoContent)
557 monacoHash := hex.EncodeToString(h.Sum(nil))[:16]
558
559 // Bundle Monaco with content-based filename
560 monacoOutputName := fmt.Sprintf("monaco-standalone-%s.js", monacoHash)
561 monacoOutputPath := filepath.Join(outDir, monacoOutputName)
562
563 args := []string{
564 monacoEntryPath,
565 "--bundle",
566 "--sourcemap",
567 "--minify",
568 "--log-level=error",
569 "--outfile=" + monacoOutputPath,
570 "--format=iife",
571 "--global-name=__MonacoLoader__",
572 "--loader:.ttf=file",
573 "--loader:.eot=file",
574 "--loader:.woff=file",
575 "--loader:.woff2=file",
576 "--public-path=.",
577 }
578
579 ret := esbuildcli.Run(args)
580 if ret != 0 {
581 return "", fmt.Errorf("esbuild monaco bundle failed: %d", ret)
582 }
583
584 return monacoHash, nil
585}
586
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000587// createStandaloneMermaidBundle creates a separate Mermaid bundle with content-based hash
588// This is useful for caching Mermaid separately from the main application bundles
589func createStandaloneMermaidBundle(outDir, buildDir string) (string, error) {
590 // Create a temporary entry file that imports Mermaid and exposes it globally
591 mermaidEntryContent := `import mermaid from 'mermaid';
592window.mermaid = mermaid;
593export default mermaid;
594`
595 mermaidEntryPath := filepath.Join(buildDir, "mermaid-standalone-entry.js")
596 if err := os.WriteFile(mermaidEntryPath, []byte(mermaidEntryContent), 0o666); err != nil {
597 return "", fmt.Errorf("write mermaid entry: %w", err)
598 }
599
600 // Calculate hash of mermaid package for content-based naming
601 mermaidPackageJson := filepath.Join(buildDir, "node_modules", "mermaid", "package.json")
602 mermaidContent, err := os.ReadFile(mermaidPackageJson)
603 if err != nil {
604 return "", fmt.Errorf("read mermaid package.json: %w", err)
605 }
606
607 h := sha256.New()
608 h.Write(mermaidContent)
609 mermaidHash := hex.EncodeToString(h.Sum(nil))[:16]
610
611 // Bundle Mermaid with content-based filename
612 mermaidOutputName := fmt.Sprintf("mermaid-standalone-%s.js", mermaidHash)
613 mermaidOutputPath := filepath.Join(outDir, mermaidOutputName)
614
615 args := []string{
616 mermaidEntryPath,
617 "--bundle",
618 "--sourcemap",
619 "--minify",
620 "--log-level=error",
621 "--outfile=" + mermaidOutputPath,
622 "--format=iife",
623 "--global-name=__MermaidLoader__",
624 "--loader:.ttf=file",
625 "--loader:.eot=file",
626 "--loader:.woff=file",
627 "--loader:.woff2=file",
628 "--public-path=.",
629 }
630
631 ret := esbuildcli.Run(args)
632 if ret != 0 {
633 return "", fmt.Errorf("esbuild mermaid bundle failed: %d", ret)
634 }
635
636 return mermaidHash, nil
637}
638
639// esbuildBundleWithExternals bundles a file with Monaco and Mermaid as external dependencies
640func esbuildBundleWithExternals(outDir, src, monacoHash, mermaidHash string) error {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700641 args := []string{
642 src,
643 "--bundle",
644 "--sourcemap",
645 "--minify",
646 "--log-level=error",
647 "--outdir=" + outDir,
648 "--external:monaco-editor",
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000649 "--external:mermaid",
philip.zeyligerc0a44592025-06-15 21:24:57 -0700650 "--loader:.ttf=file",
651 "--loader:.eot=file",
652 "--loader:.woff=file",
653 "--loader:.woff2=file",
654 "--public-path=.",
655 "--define:__MONACO_HASH__=\"" + monacoHash + "\"",
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000656 "--define:__MERMAID_HASH__=\"" + mermaidHash + "\"",
philip.zeyligerc0a44592025-06-15 21:24:57 -0700657 }
658
659 ret := esbuildcli.Run(args)
660 if ret != 0 {
661 return fmt.Errorf("esbuild %s failed: %d", filepath.Base(src), ret)
662 }
663 return nil
664}