blob: b4d76f1e6c3f4cde0d5d66ab0618b56f7d8c29c8 [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"
19
20 esbuildcli "github.com/evanw/esbuild/pkg/cli"
21)
22
Sean McCullough86b56862025-04-18 13:04:03 -070023//go:embed package.json package-lock.json src tsconfig.json
Earl Lee2e463fb2025-04-17 11:22:22 -070024var embedded embed.FS
25
Josh Bleecher Snydere634d262025-04-30 09:52:24 -070026//go:generate go run ../cmd/go2ts -o src/types.ts
27
Earl Lee2e463fb2025-04-17 11:22:22 -070028func embeddedHash() (string, error) {
29 h := sha256.New()
30 err := fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error {
31 if d.IsDir() {
32 return nil
33 }
34 f, err := embedded.Open(path)
35 if err != nil {
36 return err
37 }
38 defer f.Close()
39 if _, err := io.Copy(h, f); err != nil {
40 return fmt.Errorf("%s: %w", path, err)
41 }
42 return nil
43 })
44 if err != nil {
45 return "", fmt.Errorf("embedded hash: %w", err)
46 }
David Crawshaw8bff16a2025-04-18 01:16:49 -070047 return hex.EncodeToString(h.Sum(nil))[:32], nil
Earl Lee2e463fb2025-04-17 11:22:22 -070048}
49
Philip Zeyliger09b86f42025-07-16 16:23:03 -070050// ensureNodeModules runs npm ci only if package-lock.json has changed or node_modules doesn't exist.
51// This optimization saves ~2.4 seconds when only TypeScript or other source files change,
52// since npm ci is only run when dependencies actually change.
53func ensureNodeModules(buildDir string) error {
54 packageLockPath := filepath.Join(buildDir, "package-lock.json")
55 nodeModulesPath := filepath.Join(buildDir, "node_modules")
56 packageLockBackupPath := filepath.Join(buildDir, ".package-lock-installed")
57
58 // Check if node_modules exists
59 if _, err := os.Stat(nodeModulesPath); os.IsNotExist(err) {
Philip Zeyliger09b86f42025-07-16 16:23:03 -070060 return runNpmCI(buildDir, packageLockPath, packageLockBackupPath)
61 }
62
63 // Read current package-lock.json
64 packageLockData, err := os.ReadFile(packageLockPath)
65 if err != nil {
66 return fmt.Errorf("read package-lock.json: %w", err)
67 }
68
69 // Check if package-lock.json has changed by comparing with stored version
70 if storedPackageLockData, err := os.ReadFile(packageLockBackupPath); err == nil {
71 if bytes.Equal(packageLockData, storedPackageLockData) {
Philip Zeyliger09b86f42025-07-16 16:23:03 -070072 return nil
73 }
74 }
75
Philip Zeyliger09b86f42025-07-16 16:23:03 -070076 return runNpmCI(buildDir, packageLockPath, packageLockBackupPath)
77}
78
79// runNpmCI executes npm ci and stores the package-lock.json content
80func runNpmCI(buildDir, packageLockPath, packageLockBackupPath string) error {
Philip Zeyliger09b86f42025-07-16 16:23:03 -070081 cmd := exec.Command("npm", "ci", "--omit", "dev")
82 cmd.Dir = buildDir
83 if out, err := cmd.CombinedOutput(); err != nil {
84 return fmt.Errorf("npm ci: %s: %v", out, err)
85 }
Philip Zeyliger09b86f42025-07-16 16:23:03 -070086
87 // Store a copy of package-lock.json for future comparisons
88 packageLockData, err := os.ReadFile(packageLockPath)
89 if err != nil {
90 return fmt.Errorf("read package-lock.json after npm ci: %w", err)
91 }
92
93 if err := os.WriteFile(packageLockBackupPath, packageLockData, 0o666); err != nil {
94 return fmt.Errorf("write package-lock backup: %w", err)
95 }
96
97 return nil
98}
99
Earl Lee2e463fb2025-04-17 11:22:22 -0700100func cleanBuildDir(buildDir string) error {
101 err := fs.WalkDir(os.DirFS(buildDir), ".", func(path string, d fs.DirEntry, err error) error {
102 if d.Name() == "." {
103 return nil
104 }
105 if d.Name() == "node_modules" {
106 return fs.SkipDir
107 }
Philip Zeyliger09b86f42025-07-16 16:23:03 -0700108 if d.Name() == ".package-lock-installed" {
109 return nil // Skip file, but don't skip directory
110 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700111 osPath := filepath.Join(buildDir, path)
Earl Lee2e463fb2025-04-17 11:22:22 -0700112 os.RemoveAll(osPath)
113 if d.IsDir() {
114 return fs.SkipDir
115 }
116 return nil
117 })
118 if err != nil {
119 return fmt.Errorf("clean build dir: %w", err)
120 }
121 return nil
122}
123
124func unpackFS(out string, srcFS fs.FS) error {
125 err := fs.WalkDir(srcFS, ".", func(path string, d fs.DirEntry, err error) error {
126 if d.Name() == "." {
127 return nil
128 }
129 if d.IsDir() {
130 if err := os.Mkdir(filepath.Join(out, path), 0o777); err != nil {
131 return err
132 }
133 return nil
134 }
135 f, err := srcFS.Open(path)
136 if err != nil {
137 return err
138 }
139 defer f.Close()
140 dst, err := os.Create(filepath.Join(out, path))
141 if err != nil {
142 return err
143 }
144 defer dst.Close()
145 if _, err := io.Copy(dst, f); err != nil {
146 return err
147 }
148 if err := dst.Close(); err != nil {
149 return err
150 }
151 return nil
152 })
153 if err != nil {
154 return fmt.Errorf("unpack fs into out dir %s: %w", out, err)
155 }
156 return nil
157}
158
Philip Zeyliger983b58a2025-07-02 19:42:08 -0700159// TODO: This path being /root/.cache/sketch/webui/skui-....zip means that the Dockerfile
160// in createdockerfile.go needs to create the parent directory. Ideally we bundle the built webui
161// into the binary and avoid this altogether.
David Crawshaw8bff16a2025-04-18 01:16:49 -0700162func zipPath() (cacheDir, hashZip string, err error) {
163 homeDir, err := os.UserHomeDir()
164 if err != nil {
165 return "", "", err
166 }
167 hash, err := embeddedHash()
168 if err != nil {
169 return "", "", err
170 }
171 cacheDir = filepath.Join(homeDir, ".cache", "sketch", "webui")
172 return cacheDir, filepath.Join(cacheDir, "skui-"+hash+".zip"), nil
173}
174
Sean McCullough39995932025-06-25 19:32:08 +0000175// generateTailwindCSS generates tailwind.css from global.css and outputs it to the specified directory
176func generateTailwindCSS(buildDir, outDir string) error {
177 // Run tailwindcss CLI to generate the CSS
178 cmd := exec.Command("npx", "tailwindcss", "-i", "./src/global.css", "-o", filepath.Join(outDir, "tailwind.css"))
179 cmd.Dir = buildDir
180 if out, err := cmd.CombinedOutput(); err != nil {
181 return fmt.Errorf("tailwindcss generation failed: %s: %v", out, err)
182 }
183 return nil
184}
185
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700186// copyMonacoAssets copies Monaco editor assets to the output directory
187func copyMonacoAssets(buildDir, outDir string) error {
188 // Create Monaco directories
189 monacoEditorDir := filepath.Join(outDir, "monaco", "min", "vs", "editor")
190 codiconDir := filepath.Join(outDir, "monaco", "min", "vs", "base", "browser", "ui", "codicons", "codicon")
191
192 if err := os.MkdirAll(monacoEditorDir, 0o777); err != nil {
193 return fmt.Errorf("failed to create monaco editor directory: %w", err)
194 }
195
196 if err := os.MkdirAll(codiconDir, 0o777); err != nil {
197 return fmt.Errorf("failed to create codicon directory: %w", err)
198 }
199
200 // Copy Monaco editor CSS
201 editorCssPath := "node_modules/monaco-editor/min/vs/editor/editor.main.css"
202 editorCss, err := os.ReadFile(filepath.Join(buildDir, editorCssPath))
203 if err != nil {
204 return fmt.Errorf("failed to read monaco editor CSS: %w", err)
205 }
206
207 if err := os.WriteFile(filepath.Join(monacoEditorDir, "editor.main.css"), editorCss, 0o666); err != nil {
208 return fmt.Errorf("failed to write monaco editor CSS: %w", err)
209 }
210
211 // Copy Codicon font
212 codiconFontPath := "node_modules/monaco-editor/min/vs/base/browser/ui/codicons/codicon/codicon.ttf"
213 codiconFont, err := os.ReadFile(filepath.Join(buildDir, codiconFontPath))
214 if err != nil {
215 return fmt.Errorf("failed to read codicon font: %w", err)
216 }
217
218 if err := os.WriteFile(filepath.Join(codiconDir, "codicon.ttf"), codiconFont, 0o666); err != nil {
219 return fmt.Errorf("failed to write codicon font: %w", err)
220 }
221
222 return nil
223}
224
Earl Lee2e463fb2025-04-17 11:22:22 -0700225// Build unpacks and esbuild's all bundleTs typescript files
226func Build() (fs.FS, error) {
David Crawshaw8bff16a2025-04-18 01:16:49 -0700227 cacheDir, hashZip, err := zipPath()
Earl Lee2e463fb2025-04-17 11:22:22 -0700228 if err != nil {
229 return nil, err
230 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700231 buildDir := filepath.Join(cacheDir, "build")
232 if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
233 return nil, err
234 }
David Crawshaw8bff16a2025-04-18 01:16:49 -0700235 if b, err := os.ReadFile(hashZip); err == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700236 // Build already done, serve it out.
David Crawshaw8bff16a2025-04-18 01:16:49 -0700237 return zip.NewReader(bytes.NewReader(b), int64(len(b)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700238 }
239
David Crawshaw8bff16a2025-04-18 01:16:49 -0700240 // TODO: try downloading "https://sketch.dev/webui/"+filepath.Base(hashZip)
241
Earl Lee2e463fb2025-04-17 11:22:22 -0700242 // We need to do a build.
243
244 // Clear everything out of the build directory except node_modules.
245 if err := cleanBuildDir(buildDir); err != nil {
246 return nil, err
247 }
248 tmpHashDir := filepath.Join(buildDir, "out")
Philip Zeyliger09b86f42025-07-16 16:23:03 -0700249 if err := os.MkdirAll(tmpHashDir, 0o777); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700250 return nil, err
251 }
252
253 // Unpack everything from embedded into build dir.
254 if err := unpackFS(buildDir, embedded); err != nil {
255 return nil, err
256 }
257
Sean McCullough86b56862025-04-18 13:04:03 -0700258 // Do the build. Don't install dev dependencies, because they can be large
259 // and slow enough to install that the /init requests from the host process
260 // will run out of retries and the whole thing exits. We do need better health
261 // checking in general, but that's a separate issue. Don't do slow stuff here:
Philip Zeyliger09b86f42025-07-16 16:23:03 -0700262 if err := ensureNodeModules(buildDir); err != nil {
263 return nil, fmt.Errorf("ensure node modules: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700264 }
Sean McCullough39995932025-06-25 19:32:08 +0000265
266 // Generate Tailwind CSS
267 if err := generateTailwindCSS(buildDir, tmpHashDir); err != nil {
268 return nil, fmt.Errorf("generate tailwind css: %w", err)
269 }
philip.zeyligerc0a44592025-06-15 21:24:57 -0700270 // Create all bundles
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700271 bundleTs := []string{
272 "src/web-components/sketch-app-shell.ts",
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700273 "src/web-components/mobile-app-shell.ts",
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700274 "src/web-components/sketch-monaco-view.ts",
275 "node_modules/monaco-editor/esm/vs/editor/editor.worker.js",
276 "node_modules/monaco-editor/esm/vs/language/typescript/ts.worker.js",
277 "node_modules/monaco-editor/esm/vs/language/html/html.worker.js",
278 "node_modules/monaco-editor/esm/vs/language/css/css.worker.js",
279 "node_modules/monaco-editor/esm/vs/language/json/json.worker.js",
280 }
281
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000282 // Additionally create standalone bundles for caching
philip.zeyligerc0a44592025-06-15 21:24:57 -0700283 monacoHash, err := createStandaloneMonacoBundle(tmpHashDir, buildDir)
284 if err != nil {
285 return nil, fmt.Errorf("create monaco bundle: %w", err)
286 }
philip.zeyligerc0a44592025-06-15 21:24:57 -0700287
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000288 mermaidHash, err := createStandaloneMermaidBundle(tmpHashDir, buildDir)
289 if err != nil {
290 return nil, fmt.Errorf("create mermaid bundle: %w", err)
291 }
292
293 // Bundle all files with Monaco and Mermaid as external (since they may transitively import them)
Earl Lee2e463fb2025-04-17 11:22:22 -0700294 for _, tsName := range bundleTs {
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000295 // Use external Monaco and Mermaid for all TypeScript files to ensure consistency
philip.zeyligerc0a44592025-06-15 21:24:57 -0700296 if strings.HasSuffix(tsName, ".ts") {
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000297 if err := esbuildBundleWithExternals(tmpHashDir, filepath.Join(buildDir, tsName), monacoHash, mermaidHash); err != nil {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700298 return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
299 }
300 } else {
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000301 // Bundle worker files normally (they don't use Monaco or Mermaid)
philip.zeyligerc0a44592025-06-15 21:24:57 -0700302 if err := esbuildBundle(tmpHashDir, filepath.Join(buildDir, tsName), ""); err != nil {
303 return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
304 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700305 }
306 }
307
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700308 // Copy Monaco editor assets
309 if err := copyMonacoAssets(buildDir, tmpHashDir); err != nil {
310 return nil, fmt.Errorf("failed to copy Monaco assets: %w", err)
311 }
312
Earl Lee2e463fb2025-04-17 11:22:22 -0700313 // Copy src files used directly into the new hash output dir.
314 err = fs.WalkDir(embedded, "src", func(path string, d fs.DirEntry, err error) error {
315 if d.IsDir() {
Sean McCullough86b56862025-04-18 13:04:03 -0700316 if path == "src/web-components/demo" {
317 return fs.SkipDir
318 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700319 return nil
320 }
Pokey Rule8cac59a2025-04-24 12:21:19 +0100321 if strings.HasSuffix(path, "mockServiceWorker.js") {
322 return nil
323 }
Sean McCullough39995932025-06-25 19:32:08 +0000324 // Skip src/tailwind.css as it will be generated
325 if path == "src/tailwind.css" {
326 return nil
327 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700328 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".css") || strings.HasSuffix(path, ".js") {
329 b, err := embedded.ReadFile(path)
330 if err != nil {
331 return err
332 }
333 dstPath := filepath.Join(tmpHashDir, strings.TrimPrefix(path, "src/"))
334 if err := os.WriteFile(dstPath, b, 0o777); err != nil {
335 return err
336 }
337 return nil
338 }
339 return nil
340 })
341 if err != nil {
342 return nil, err
343 }
344
345 // Copy xterm.css from node_modules
346 const xtermCssPath = "node_modules/@xterm/xterm/css/xterm.css"
347 xtermCss, err := os.ReadFile(filepath.Join(buildDir, xtermCssPath))
348 if err != nil {
349 return nil, fmt.Errorf("failed to read xterm.css: %w", err)
350 }
351 if err := os.WriteFile(filepath.Join(tmpHashDir, "xterm.css"), xtermCss, 0o666); err != nil {
352 return nil, fmt.Errorf("failed to write xterm.css: %w", err)
353 }
354
Philip Zeyliger176de792025-04-21 12:25:18 -0700355 // Compress all .js, .js.map, and .css files with gzip, leaving the originals in place
356 err = filepath.Walk(tmpHashDir, func(path string, info os.FileInfo, err error) error {
357 if err != nil {
358 return err
359 }
360 if info.IsDir() {
361 return nil
362 }
363 // Check if file is a .js or .js.map file
364 if !strings.HasSuffix(path, ".js") && !strings.HasSuffix(path, ".js.map") && !strings.HasSuffix(path, ".css") {
365 return nil
366 }
367
368 // Read the original file
369 origData, err := os.ReadFile(path)
370 if err != nil {
371 return fmt.Errorf("failed to read file %s: %w", path, err)
372 }
373
374 // Create a gzipped file
375 gzipPath := path + ".gz"
376 gzipFile, err := os.Create(gzipPath)
377 if err != nil {
378 return fmt.Errorf("failed to create gzip file %s: %w", gzipPath, err)
379 }
380 defer gzipFile.Close()
381
382 // Create a gzip writer
383 gzWriter := gzip.NewWriter(gzipFile)
384 defer gzWriter.Close()
385
386 // Write the original file content to the gzip writer
387 _, err = gzWriter.Write(origData)
388 if err != nil {
389 return fmt.Errorf("failed to write to gzip file %s: %w", gzipPath, err)
390 }
391
392 // Ensure we flush and close properly
393 if err := gzWriter.Close(); err != nil {
394 return fmt.Errorf("failed to close gzip writer for %s: %w", gzipPath, err)
395 }
396 if err := gzipFile.Close(); err != nil {
397 return fmt.Errorf("failed to close gzip file %s: %w", gzipPath, err)
398 }
399
Josh Bleecher Snydera002a232025-07-09 19:38:03 +0000400 // The gzip handler will decompress on-the-fly for browsers that don't support gzip.
401 if err := os.Remove(path); err != nil {
402 return fmt.Errorf("failed to remove uncompressed file %s: %w", path, err)
403 }
404
Philip Zeyliger176de792025-04-21 12:25:18 -0700405 return nil
406 })
407 if err != nil {
408 return nil, fmt.Errorf("failed to compress .js/.js.map/.css files: %w", err)
409 }
410
Josh Bleecher Snyder1c18ec92025-07-08 10:55:54 -0700411 return os.DirFS(tmpHashDir), nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700412}
413
Sean McCullough86b56862025-04-18 13:04:03 -0700414func esbuildBundle(outDir, src, metafilePath string) error {
415 args := []string{
Earl Lee2e463fb2025-04-17 11:22:22 -0700416 src,
417 "--bundle",
418 "--sourcemap",
419 "--log-level=error",
philip.zeyligerc0a44592025-06-15 21:24:57 -0700420 "--minify",
Earl Lee2e463fb2025-04-17 11:22:22 -0700421 "--outdir=" + outDir,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700422 "--loader:.ttf=file",
423 "--loader:.eot=file",
424 "--loader:.woff=file",
425 "--loader:.woff2=file",
426 // This changes where the sourcemap points to; we need relative dirs if we're proxied into a subdirectory.
427 "--public-path=.",
Sean McCullough86b56862025-04-18 13:04:03 -0700428 }
429
430 // Add metafile option if path is provided
431 if metafilePath != "" {
432 args = append(args, "--metafile="+metafilePath)
433 }
434
435 ret := esbuildcli.Run(args)
Earl Lee2e463fb2025-04-17 11:22:22 -0700436 if ret != 0 {
437 return fmt.Errorf("esbuild %s failed: %d", filepath.Base(src), ret)
438 }
439 return nil
440}
Sean McCullough86b56862025-04-18 13:04:03 -0700441
442// unpackTS unpacks all the typescript-relevant files from the embedded filesystem into tmpDir.
443func unpackTS(outDir string, embedded fs.FS) error {
444 return fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error {
445 if err != nil {
446 return err
447 }
448 tgt := filepath.Join(outDir, path)
449 if d.IsDir() {
450 if err := os.MkdirAll(tgt, 0o777); err != nil {
451 return err
452 }
453 return nil
454 }
455 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".md") || strings.HasSuffix(path, ".css") {
456 return nil
457 }
458 data, err := fs.ReadFile(embedded, path)
459 if err != nil {
460 return err
461 }
462 if err := os.WriteFile(tgt, data, 0o666); err != nil {
463 return err
464 }
465 return nil
466 })
467}
468
469// GenerateBundleMetafile creates metafiles for bundle analysis with esbuild.
470//
471// The metafiles contain information about bundle size and dependencies
472// that can be visualized at https://esbuild.github.io/analyze/
473//
474// It takes the output directory where the metafiles will be written.
475// Returns the file path of the generated metafiles.
476func GenerateBundleMetafile(outputDir string) (string, error) {
477 tmpDir, err := os.MkdirTemp("", "bundle-analysis-")
478 if err != nil {
479 return "", err
480 }
481 defer os.RemoveAll(tmpDir)
482
483 // Create output directory if it doesn't exist
Philip Zeyligerd1402952025-04-23 03:54:37 +0000484 if err := os.MkdirAll(outputDir, 0o755); err != nil {
Sean McCullough86b56862025-04-18 13:04:03 -0700485 return "", err
486 }
487
488 cacheDir, _, err := zipPath()
489 if err != nil {
490 return "", err
491 }
492 buildDir := filepath.Join(cacheDir, "build")
493 if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
494 return "", err
495 }
496
497 // Ensure we have a source to bundle
498 if err := unpackTS(tmpDir, embedded); err != nil {
499 return "", err
500 }
501
502 // All bundles to analyze
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700503 bundleTs := []string{
504 "src/web-components/sketch-app-shell.ts",
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700505 "src/web-components/mobile-app-shell.ts",
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700506 "src/web-components/sketch-monaco-view.ts",
507 }
Sean McCullough86b56862025-04-18 13:04:03 -0700508 metafiles := make([]string, len(bundleTs))
509
510 for i, tsName := range bundleTs {
511 // Create a metafile path for this bundle
512 baseFileName := filepath.Base(tsName)
513 metaFileName := strings.TrimSuffix(baseFileName, ".ts") + ".meta.json"
514 metafilePath := filepath.Join(outputDir, metaFileName)
515 metafiles[i] = metafilePath
516
517 // Bundle with metafile generation
518 outTmpDir, err := os.MkdirTemp("", "metafile-bundle-")
519 if err != nil {
520 return "", err
521 }
522 defer os.RemoveAll(outTmpDir)
523
524 if err := esbuildBundle(outTmpDir, filepath.Join(buildDir, tsName), metafilePath); err != nil {
525 return "", fmt.Errorf("failed to generate metafile for %s: %w", tsName, err)
526 }
527 }
528
529 return outputDir, nil
530}
philip.zeyligerc0a44592025-06-15 21:24:57 -0700531
532// createStandaloneMonacoBundle creates a separate Monaco editor bundle with content-based hash
533// This is useful for caching Monaco separately from the main application bundles
534func createStandaloneMonacoBundle(outDir, buildDir string) (string, error) {
535 // Create a temporary entry file that imports Monaco and exposes it globally
536 monacoEntryContent := `import * as monaco from 'monaco-editor';
537window.monaco = monaco;
538export default monaco;
539`
540 monacoEntryPath := filepath.Join(buildDir, "monaco-standalone-entry.js")
541 if err := os.WriteFile(monacoEntryPath, []byte(monacoEntryContent), 0o666); err != nil {
542 return "", fmt.Errorf("write monaco entry: %w", err)
543 }
544
545 // Calculate hash of monaco-editor package for content-based naming
546 monacoPackageJson := filepath.Join(buildDir, "node_modules", "monaco-editor", "package.json")
547 monacoContent, err := os.ReadFile(monacoPackageJson)
548 if err != nil {
549 return "", fmt.Errorf("read monaco package.json: %w", err)
550 }
551
552 h := sha256.New()
553 h.Write(monacoContent)
554 monacoHash := hex.EncodeToString(h.Sum(nil))[:16]
555
556 // Bundle Monaco with content-based filename
557 monacoOutputName := fmt.Sprintf("monaco-standalone-%s.js", monacoHash)
558 monacoOutputPath := filepath.Join(outDir, monacoOutputName)
559
560 args := []string{
561 monacoEntryPath,
562 "--bundle",
563 "--sourcemap",
564 "--minify",
565 "--log-level=error",
566 "--outfile=" + monacoOutputPath,
567 "--format=iife",
568 "--global-name=__MonacoLoader__",
569 "--loader:.ttf=file",
570 "--loader:.eot=file",
571 "--loader:.woff=file",
572 "--loader:.woff2=file",
573 "--public-path=.",
574 }
575
576 ret := esbuildcli.Run(args)
577 if ret != 0 {
578 return "", fmt.Errorf("esbuild monaco bundle failed: %d", ret)
579 }
580
581 return monacoHash, nil
582}
583
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000584// createStandaloneMermaidBundle creates a separate Mermaid bundle with content-based hash
585// This is useful for caching Mermaid separately from the main application bundles
586func createStandaloneMermaidBundle(outDir, buildDir string) (string, error) {
587 // Create a temporary entry file that imports Mermaid and exposes it globally
588 mermaidEntryContent := `import mermaid from 'mermaid';
589window.mermaid = mermaid;
590export default mermaid;
591`
592 mermaidEntryPath := filepath.Join(buildDir, "mermaid-standalone-entry.js")
593 if err := os.WriteFile(mermaidEntryPath, []byte(mermaidEntryContent), 0o666); err != nil {
594 return "", fmt.Errorf("write mermaid entry: %w", err)
595 }
596
597 // Calculate hash of mermaid package for content-based naming
598 mermaidPackageJson := filepath.Join(buildDir, "node_modules", "mermaid", "package.json")
599 mermaidContent, err := os.ReadFile(mermaidPackageJson)
600 if err != nil {
601 return "", fmt.Errorf("read mermaid package.json: %w", err)
602 }
603
604 h := sha256.New()
605 h.Write(mermaidContent)
606 mermaidHash := hex.EncodeToString(h.Sum(nil))[:16]
607
608 // Bundle Mermaid with content-based filename
609 mermaidOutputName := fmt.Sprintf("mermaid-standalone-%s.js", mermaidHash)
610 mermaidOutputPath := filepath.Join(outDir, mermaidOutputName)
611
612 args := []string{
613 mermaidEntryPath,
614 "--bundle",
615 "--sourcemap",
616 "--minify",
617 "--log-level=error",
618 "--outfile=" + mermaidOutputPath,
619 "--format=iife",
620 "--global-name=__MermaidLoader__",
621 "--loader:.ttf=file",
622 "--loader:.eot=file",
623 "--loader:.woff=file",
624 "--loader:.woff2=file",
625 "--public-path=.",
626 }
627
628 ret := esbuildcli.Run(args)
629 if ret != 0 {
630 return "", fmt.Errorf("esbuild mermaid bundle failed: %d", ret)
631 }
632
633 return mermaidHash, nil
634}
635
636// esbuildBundleWithExternals bundles a file with Monaco and Mermaid as external dependencies
637func esbuildBundleWithExternals(outDir, src, monacoHash, mermaidHash string) error {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700638 args := []string{
639 src,
640 "--bundle",
641 "--sourcemap",
642 "--minify",
643 "--log-level=error",
644 "--outdir=" + outDir,
645 "--external:monaco-editor",
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000646 "--external:mermaid",
philip.zeyligerc0a44592025-06-15 21:24:57 -0700647 "--loader:.ttf=file",
648 "--loader:.eot=file",
649 "--loader:.woff=file",
650 "--loader:.woff2=file",
651 "--public-path=.",
652 "--define:__MONACO_HASH__=\"" + monacoHash + "\"",
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000653 "--define:__MERMAID_HASH__=\"" + mermaidHash + "\"",
philip.zeyligerc0a44592025-06-15 21:24:57 -0700654 }
655
656 ret := esbuildcli.Run(args)
657 if ret != 0 {
658 return fmt.Errorf("esbuild %s failed: %d", filepath.Base(src), ret)
659 }
660 return nil
661}