blob: d310f95eaa1ae99b1f8378c5515f0491a9a8e248 [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
50func cleanBuildDir(buildDir string) error {
51 err := fs.WalkDir(os.DirFS(buildDir), ".", func(path string, d fs.DirEntry, err error) error {
52 if d.Name() == "." {
53 return nil
54 }
55 if d.Name() == "node_modules" {
56 return fs.SkipDir
57 }
58 osPath := filepath.Join(buildDir, path)
Earl Lee2e463fb2025-04-17 11:22:22 -070059 os.RemoveAll(osPath)
60 if d.IsDir() {
61 return fs.SkipDir
62 }
63 return nil
64 })
65 if err != nil {
66 return fmt.Errorf("clean build dir: %w", err)
67 }
68 return nil
69}
70
71func unpackFS(out string, srcFS fs.FS) error {
72 err := fs.WalkDir(srcFS, ".", func(path string, d fs.DirEntry, err error) error {
73 if d.Name() == "." {
74 return nil
75 }
76 if d.IsDir() {
77 if err := os.Mkdir(filepath.Join(out, path), 0o777); err != nil {
78 return err
79 }
80 return nil
81 }
82 f, err := srcFS.Open(path)
83 if err != nil {
84 return err
85 }
86 defer f.Close()
87 dst, err := os.Create(filepath.Join(out, path))
88 if err != nil {
89 return err
90 }
91 defer dst.Close()
92 if _, err := io.Copy(dst, f); err != nil {
93 return err
94 }
95 if err := dst.Close(); err != nil {
96 return err
97 }
98 return nil
99 })
100 if err != nil {
101 return fmt.Errorf("unpack fs into out dir %s: %w", out, err)
102 }
103 return nil
104}
105
David Crawshaw8bff16a2025-04-18 01:16:49 -0700106func ZipPath() (string, error) {
107 _, hashZip, err := zipPath()
108 return hashZip, err
109}
110
Philip Zeyliger983b58a2025-07-02 19:42:08 -0700111// TODO: This path being /root/.cache/sketch/webui/skui-....zip means that the Dockerfile
112// in createdockerfile.go needs to create the parent directory. Ideally we bundle the built webui
113// into the binary and avoid this altogether.
David Crawshaw8bff16a2025-04-18 01:16:49 -0700114func zipPath() (cacheDir, hashZip string, err error) {
115 homeDir, err := os.UserHomeDir()
116 if err != nil {
117 return "", "", err
118 }
119 hash, err := embeddedHash()
120 if err != nil {
121 return "", "", err
122 }
123 cacheDir = filepath.Join(homeDir, ".cache", "sketch", "webui")
124 return cacheDir, filepath.Join(cacheDir, "skui-"+hash+".zip"), nil
125}
126
Sean McCullough39995932025-06-25 19:32:08 +0000127// generateTailwindCSS generates tailwind.css from global.css and outputs it to the specified directory
128func generateTailwindCSS(buildDir, outDir string) error {
129 // Run tailwindcss CLI to generate the CSS
130 cmd := exec.Command("npx", "tailwindcss", "-i", "./src/global.css", "-o", filepath.Join(outDir, "tailwind.css"))
131 cmd.Dir = buildDir
132 if out, err := cmd.CombinedOutput(); err != nil {
133 return fmt.Errorf("tailwindcss generation failed: %s: %v", out, err)
134 }
135 return nil
136}
137
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700138// copyMonacoAssets copies Monaco editor assets to the output directory
139func copyMonacoAssets(buildDir, outDir string) error {
140 // Create Monaco directories
141 monacoEditorDir := filepath.Join(outDir, "monaco", "min", "vs", "editor")
142 codiconDir := filepath.Join(outDir, "monaco", "min", "vs", "base", "browser", "ui", "codicons", "codicon")
143
144 if err := os.MkdirAll(monacoEditorDir, 0o777); err != nil {
145 return fmt.Errorf("failed to create monaco editor directory: %w", err)
146 }
147
148 if err := os.MkdirAll(codiconDir, 0o777); err != nil {
149 return fmt.Errorf("failed to create codicon directory: %w", err)
150 }
151
152 // Copy Monaco editor CSS
153 editorCssPath := "node_modules/monaco-editor/min/vs/editor/editor.main.css"
154 editorCss, err := os.ReadFile(filepath.Join(buildDir, editorCssPath))
155 if err != nil {
156 return fmt.Errorf("failed to read monaco editor CSS: %w", err)
157 }
158
159 if err := os.WriteFile(filepath.Join(monacoEditorDir, "editor.main.css"), editorCss, 0o666); err != nil {
160 return fmt.Errorf("failed to write monaco editor CSS: %w", err)
161 }
162
163 // Copy Codicon font
164 codiconFontPath := "node_modules/monaco-editor/min/vs/base/browser/ui/codicons/codicon/codicon.ttf"
165 codiconFont, err := os.ReadFile(filepath.Join(buildDir, codiconFontPath))
166 if err != nil {
167 return fmt.Errorf("failed to read codicon font: %w", err)
168 }
169
170 if err := os.WriteFile(filepath.Join(codiconDir, "codicon.ttf"), codiconFont, 0o666); err != nil {
171 return fmt.Errorf("failed to write codicon font: %w", err)
172 }
173
174 return nil
175}
176
Earl Lee2e463fb2025-04-17 11:22:22 -0700177// Build unpacks and esbuild's all bundleTs typescript files
178func Build() (fs.FS, error) {
David Crawshaw8bff16a2025-04-18 01:16:49 -0700179 cacheDir, hashZip, err := zipPath()
Earl Lee2e463fb2025-04-17 11:22:22 -0700180 if err != nil {
181 return nil, err
182 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700183 buildDir := filepath.Join(cacheDir, "build")
184 if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
185 return nil, err
186 }
David Crawshaw8bff16a2025-04-18 01:16:49 -0700187 if b, err := os.ReadFile(hashZip); err == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700188 // Build already done, serve it out.
David Crawshaw8bff16a2025-04-18 01:16:49 -0700189 return zip.NewReader(bytes.NewReader(b), int64(len(b)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700190 }
191
David Crawshaw8bff16a2025-04-18 01:16:49 -0700192 // TODO: try downloading "https://sketch.dev/webui/"+filepath.Base(hashZip)
193
Earl Lee2e463fb2025-04-17 11:22:22 -0700194 // We need to do a build.
195
196 // Clear everything out of the build directory except node_modules.
197 if err := cleanBuildDir(buildDir); err != nil {
198 return nil, err
199 }
200 tmpHashDir := filepath.Join(buildDir, "out")
201 if err := os.Mkdir(tmpHashDir, 0o777); err != nil {
202 return nil, err
203 }
204
205 // Unpack everything from embedded into build dir.
206 if err := unpackFS(buildDir, embedded); err != nil {
207 return nil, err
208 }
209
Sean McCullough86b56862025-04-18 13:04:03 -0700210 // Do the build. Don't install dev dependencies, because they can be large
211 // and slow enough to install that the /init requests from the host process
212 // will run out of retries and the whole thing exits. We do need better health
213 // checking in general, but that's a separate issue. Don't do slow stuff here:
214 cmd := exec.Command("npm", "ci", "--omit", "dev")
Earl Lee2e463fb2025-04-17 11:22:22 -0700215 cmd.Dir = buildDir
216 if out, err := cmd.CombinedOutput(); err != nil {
217 return nil, fmt.Errorf("npm ci: %s: %v", out, err)
218 }
Sean McCullough39995932025-06-25 19:32:08 +0000219
220 // Generate Tailwind CSS
221 if err := generateTailwindCSS(buildDir, tmpHashDir); err != nil {
222 return nil, fmt.Errorf("generate tailwind css: %w", err)
223 }
philip.zeyligerc0a44592025-06-15 21:24:57 -0700224 // Create all bundles
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700225 bundleTs := []string{
226 "src/web-components/sketch-app-shell.ts",
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700227 "src/web-components/mobile-app-shell.ts",
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700228 "src/web-components/sketch-monaco-view.ts",
Sean McCullough021231a2025-06-12 09:35:24 -0700229 "src/messages-viewer.ts",
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700230 "node_modules/monaco-editor/esm/vs/editor/editor.worker.js",
231 "node_modules/monaco-editor/esm/vs/language/typescript/ts.worker.js",
232 "node_modules/monaco-editor/esm/vs/language/html/html.worker.js",
233 "node_modules/monaco-editor/esm/vs/language/css/css.worker.js",
234 "node_modules/monaco-editor/esm/vs/language/json/json.worker.js",
235 }
236
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000237 // Additionally create standalone bundles for caching
philip.zeyligerc0a44592025-06-15 21:24:57 -0700238 monacoHash, err := createStandaloneMonacoBundle(tmpHashDir, buildDir)
239 if err != nil {
240 return nil, fmt.Errorf("create monaco bundle: %w", err)
241 }
philip.zeyligerc0a44592025-06-15 21:24:57 -0700242
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000243 mermaidHash, err := createStandaloneMermaidBundle(tmpHashDir, buildDir)
244 if err != nil {
245 return nil, fmt.Errorf("create mermaid bundle: %w", err)
246 }
247
248 // Bundle all files with Monaco and Mermaid as external (since they may transitively import them)
Earl Lee2e463fb2025-04-17 11:22:22 -0700249 for _, tsName := range bundleTs {
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000250 // Use external Monaco and Mermaid for all TypeScript files to ensure consistency
philip.zeyligerc0a44592025-06-15 21:24:57 -0700251 if strings.HasSuffix(tsName, ".ts") {
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000252 if err := esbuildBundleWithExternals(tmpHashDir, filepath.Join(buildDir, tsName), monacoHash, mermaidHash); err != nil {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700253 return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
254 }
255 } else {
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000256 // Bundle worker files normally (they don't use Monaco or Mermaid)
philip.zeyligerc0a44592025-06-15 21:24:57 -0700257 if err := esbuildBundle(tmpHashDir, filepath.Join(buildDir, tsName), ""); err != nil {
258 return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
259 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700260 }
261 }
262
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700263 // Copy Monaco editor assets
264 if err := copyMonacoAssets(buildDir, tmpHashDir); err != nil {
265 return nil, fmt.Errorf("failed to copy Monaco assets: %w", err)
266 }
267
Earl Lee2e463fb2025-04-17 11:22:22 -0700268 // Copy src files used directly into the new hash output dir.
269 err = fs.WalkDir(embedded, "src", func(path string, d fs.DirEntry, err error) error {
270 if d.IsDir() {
Sean McCullough86b56862025-04-18 13:04:03 -0700271 if path == "src/web-components/demo" {
272 return fs.SkipDir
273 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700274 return nil
275 }
Pokey Rule8cac59a2025-04-24 12:21:19 +0100276 if strings.HasSuffix(path, "mockServiceWorker.js") {
277 return nil
278 }
Sean McCullough39995932025-06-25 19:32:08 +0000279 // Skip src/tailwind.css as it will be generated
280 if path == "src/tailwind.css" {
281 return nil
282 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700283 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".css") || strings.HasSuffix(path, ".js") {
284 b, err := embedded.ReadFile(path)
285 if err != nil {
286 return err
287 }
288 dstPath := filepath.Join(tmpHashDir, strings.TrimPrefix(path, "src/"))
289 if err := os.WriteFile(dstPath, b, 0o777); err != nil {
290 return err
291 }
292 return nil
293 }
294 return nil
295 })
296 if err != nil {
297 return nil, err
298 }
299
300 // Copy xterm.css from node_modules
301 const xtermCssPath = "node_modules/@xterm/xterm/css/xterm.css"
302 xtermCss, err := os.ReadFile(filepath.Join(buildDir, xtermCssPath))
303 if err != nil {
304 return nil, fmt.Errorf("failed to read xterm.css: %w", err)
305 }
306 if err := os.WriteFile(filepath.Join(tmpHashDir, "xterm.css"), xtermCss, 0o666); err != nil {
307 return nil, fmt.Errorf("failed to write xterm.css: %w", err)
308 }
309
Philip Zeyliger176de792025-04-21 12:25:18 -0700310 // Compress all .js, .js.map, and .css files with gzip, leaving the originals in place
311 err = filepath.Walk(tmpHashDir, func(path string, info os.FileInfo, err error) error {
312 if err != nil {
313 return err
314 }
315 if info.IsDir() {
316 return nil
317 }
318 // Check if file is a .js or .js.map file
319 if !strings.HasSuffix(path, ".js") && !strings.HasSuffix(path, ".js.map") && !strings.HasSuffix(path, ".css") {
320 return nil
321 }
322
323 // Read the original file
324 origData, err := os.ReadFile(path)
325 if err != nil {
326 return fmt.Errorf("failed to read file %s: %w", path, err)
327 }
328
329 // Create a gzipped file
330 gzipPath := path + ".gz"
331 gzipFile, err := os.Create(gzipPath)
332 if err != nil {
333 return fmt.Errorf("failed to create gzip file %s: %w", gzipPath, err)
334 }
335 defer gzipFile.Close()
336
337 // Create a gzip writer
338 gzWriter := gzip.NewWriter(gzipFile)
339 defer gzWriter.Close()
340
341 // Write the original file content to the gzip writer
342 _, err = gzWriter.Write(origData)
343 if err != nil {
344 return fmt.Errorf("failed to write to gzip file %s: %w", gzipPath, err)
345 }
346
347 // Ensure we flush and close properly
348 if err := gzWriter.Close(); err != nil {
349 return fmt.Errorf("failed to close gzip writer for %s: %w", gzipPath, err)
350 }
351 if err := gzipFile.Close(); err != nil {
352 return fmt.Errorf("failed to close gzip file %s: %w", gzipPath, err)
353 }
354
355 return nil
356 })
357 if err != nil {
358 return nil, fmt.Errorf("failed to compress .js/.js.map/.css files: %w", err)
359 }
360
David Crawshaw8bff16a2025-04-18 01:16:49 -0700361 // Everything succeeded, so we write tmpHashDir to hashZip
362 buf := new(bytes.Buffer)
363 w := zip.NewWriter(buf)
364 if err := w.AddFS(os.DirFS(tmpHashDir)); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700365 return nil, err
366 }
David Crawshaw8bff16a2025-04-18 01:16:49 -0700367 if err := w.Close(); err != nil {
368 return nil, err
369 }
370 if err := os.WriteFile(hashZip, buf.Bytes(), 0o666); err != nil {
371 return nil, err
372 }
373 return zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
Earl Lee2e463fb2025-04-17 11:22:22 -0700374}
375
Sean McCullough86b56862025-04-18 13:04:03 -0700376func esbuildBundle(outDir, src, metafilePath string) error {
377 args := []string{
Earl Lee2e463fb2025-04-17 11:22:22 -0700378 src,
379 "--bundle",
380 "--sourcemap",
381 "--log-level=error",
philip.zeyligerc0a44592025-06-15 21:24:57 -0700382 "--minify",
Earl Lee2e463fb2025-04-17 11:22:22 -0700383 "--outdir=" + outDir,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700384 "--loader:.ttf=file",
385 "--loader:.eot=file",
386 "--loader:.woff=file",
387 "--loader:.woff2=file",
388 // This changes where the sourcemap points to; we need relative dirs if we're proxied into a subdirectory.
389 "--public-path=.",
Sean McCullough86b56862025-04-18 13:04:03 -0700390 }
391
392 // Add metafile option if path is provided
393 if metafilePath != "" {
394 args = append(args, "--metafile="+metafilePath)
395 }
396
397 ret := esbuildcli.Run(args)
Earl Lee2e463fb2025-04-17 11:22:22 -0700398 if ret != 0 {
399 return fmt.Errorf("esbuild %s failed: %d", filepath.Base(src), ret)
400 }
401 return nil
402}
Sean McCullough86b56862025-04-18 13:04:03 -0700403
404// unpackTS unpacks all the typescript-relevant files from the embedded filesystem into tmpDir.
405func unpackTS(outDir string, embedded fs.FS) error {
406 return fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error {
407 if err != nil {
408 return err
409 }
410 tgt := filepath.Join(outDir, path)
411 if d.IsDir() {
412 if err := os.MkdirAll(tgt, 0o777); err != nil {
413 return err
414 }
415 return nil
416 }
417 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".md") || strings.HasSuffix(path, ".css") {
418 return nil
419 }
420 data, err := fs.ReadFile(embedded, path)
421 if err != nil {
422 return err
423 }
424 if err := os.WriteFile(tgt, data, 0o666); err != nil {
425 return err
426 }
427 return nil
428 })
429}
430
431// GenerateBundleMetafile creates metafiles for bundle analysis with esbuild.
432//
433// The metafiles contain information about bundle size and dependencies
434// that can be visualized at https://esbuild.github.io/analyze/
435//
436// It takes the output directory where the metafiles will be written.
437// Returns the file path of the generated metafiles.
438func GenerateBundleMetafile(outputDir string) (string, error) {
439 tmpDir, err := os.MkdirTemp("", "bundle-analysis-")
440 if err != nil {
441 return "", err
442 }
443 defer os.RemoveAll(tmpDir)
444
445 // Create output directory if it doesn't exist
Philip Zeyligerd1402952025-04-23 03:54:37 +0000446 if err := os.MkdirAll(outputDir, 0o755); err != nil {
Sean McCullough86b56862025-04-18 13:04:03 -0700447 return "", err
448 }
449
450 cacheDir, _, err := zipPath()
451 if err != nil {
452 return "", err
453 }
454 buildDir := filepath.Join(cacheDir, "build")
455 if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
456 return "", err
457 }
458
459 // Ensure we have a source to bundle
460 if err := unpackTS(tmpDir, embedded); err != nil {
461 return "", err
462 }
463
464 // All bundles to analyze
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700465 bundleTs := []string{
466 "src/web-components/sketch-app-shell.ts",
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700467 "src/web-components/mobile-app-shell.ts",
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700468 "src/web-components/sketch-monaco-view.ts",
Sean McCullough021231a2025-06-12 09:35:24 -0700469 "src/messages-viewer.ts",
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700470 }
Sean McCullough86b56862025-04-18 13:04:03 -0700471 metafiles := make([]string, len(bundleTs))
472
473 for i, tsName := range bundleTs {
474 // Create a metafile path for this bundle
475 baseFileName := filepath.Base(tsName)
476 metaFileName := strings.TrimSuffix(baseFileName, ".ts") + ".meta.json"
477 metafilePath := filepath.Join(outputDir, metaFileName)
478 metafiles[i] = metafilePath
479
480 // Bundle with metafile generation
481 outTmpDir, err := os.MkdirTemp("", "metafile-bundle-")
482 if err != nil {
483 return "", err
484 }
485 defer os.RemoveAll(outTmpDir)
486
487 if err := esbuildBundle(outTmpDir, filepath.Join(buildDir, tsName), metafilePath); err != nil {
488 return "", fmt.Errorf("failed to generate metafile for %s: %w", tsName, err)
489 }
490 }
491
492 return outputDir, nil
493}
philip.zeyligerc0a44592025-06-15 21:24:57 -0700494
495// createStandaloneMonacoBundle creates a separate Monaco editor bundle with content-based hash
496// This is useful for caching Monaco separately from the main application bundles
497func createStandaloneMonacoBundle(outDir, buildDir string) (string, error) {
498 // Create a temporary entry file that imports Monaco and exposes it globally
499 monacoEntryContent := `import * as monaco from 'monaco-editor';
500window.monaco = monaco;
501export default monaco;
502`
503 monacoEntryPath := filepath.Join(buildDir, "monaco-standalone-entry.js")
504 if err := os.WriteFile(monacoEntryPath, []byte(monacoEntryContent), 0o666); err != nil {
505 return "", fmt.Errorf("write monaco entry: %w", err)
506 }
507
508 // Calculate hash of monaco-editor package for content-based naming
509 monacoPackageJson := filepath.Join(buildDir, "node_modules", "monaco-editor", "package.json")
510 monacoContent, err := os.ReadFile(monacoPackageJson)
511 if err != nil {
512 return "", fmt.Errorf("read monaco package.json: %w", err)
513 }
514
515 h := sha256.New()
516 h.Write(monacoContent)
517 monacoHash := hex.EncodeToString(h.Sum(nil))[:16]
518
519 // Bundle Monaco with content-based filename
520 monacoOutputName := fmt.Sprintf("monaco-standalone-%s.js", monacoHash)
521 monacoOutputPath := filepath.Join(outDir, monacoOutputName)
522
523 args := []string{
524 monacoEntryPath,
525 "--bundle",
526 "--sourcemap",
527 "--minify",
528 "--log-level=error",
529 "--outfile=" + monacoOutputPath,
530 "--format=iife",
531 "--global-name=__MonacoLoader__",
532 "--loader:.ttf=file",
533 "--loader:.eot=file",
534 "--loader:.woff=file",
535 "--loader:.woff2=file",
536 "--public-path=.",
537 }
538
539 ret := esbuildcli.Run(args)
540 if ret != 0 {
541 return "", fmt.Errorf("esbuild monaco bundle failed: %d", ret)
542 }
543
544 return monacoHash, nil
545}
546
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000547// createStandaloneMermaidBundle creates a separate Mermaid bundle with content-based hash
548// This is useful for caching Mermaid separately from the main application bundles
549func createStandaloneMermaidBundle(outDir, buildDir string) (string, error) {
550 // Create a temporary entry file that imports Mermaid and exposes it globally
551 mermaidEntryContent := `import mermaid from 'mermaid';
552window.mermaid = mermaid;
553export default mermaid;
554`
555 mermaidEntryPath := filepath.Join(buildDir, "mermaid-standalone-entry.js")
556 if err := os.WriteFile(mermaidEntryPath, []byte(mermaidEntryContent), 0o666); err != nil {
557 return "", fmt.Errorf("write mermaid entry: %w", err)
558 }
559
560 // Calculate hash of mermaid package for content-based naming
561 mermaidPackageJson := filepath.Join(buildDir, "node_modules", "mermaid", "package.json")
562 mermaidContent, err := os.ReadFile(mermaidPackageJson)
563 if err != nil {
564 return "", fmt.Errorf("read mermaid package.json: %w", err)
565 }
566
567 h := sha256.New()
568 h.Write(mermaidContent)
569 mermaidHash := hex.EncodeToString(h.Sum(nil))[:16]
570
571 // Bundle Mermaid with content-based filename
572 mermaidOutputName := fmt.Sprintf("mermaid-standalone-%s.js", mermaidHash)
573 mermaidOutputPath := filepath.Join(outDir, mermaidOutputName)
574
575 args := []string{
576 mermaidEntryPath,
577 "--bundle",
578 "--sourcemap",
579 "--minify",
580 "--log-level=error",
581 "--outfile=" + mermaidOutputPath,
582 "--format=iife",
583 "--global-name=__MermaidLoader__",
584 "--loader:.ttf=file",
585 "--loader:.eot=file",
586 "--loader:.woff=file",
587 "--loader:.woff2=file",
588 "--public-path=.",
589 }
590
591 ret := esbuildcli.Run(args)
592 if ret != 0 {
593 return "", fmt.Errorf("esbuild mermaid bundle failed: %d", ret)
594 }
595
596 return mermaidHash, nil
597}
598
599// esbuildBundleWithExternals bundles a file with Monaco and Mermaid as external dependencies
600func esbuildBundleWithExternals(outDir, src, monacoHash, mermaidHash string) error {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700601 args := []string{
602 src,
603 "--bundle",
604 "--sourcemap",
605 "--minify",
606 "--log-level=error",
607 "--outdir=" + outDir,
608 "--external:monaco-editor",
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000609 "--external:mermaid",
philip.zeyligerc0a44592025-06-15 21:24:57 -0700610 "--loader:.ttf=file",
611 "--loader:.eot=file",
612 "--loader:.woff=file",
613 "--loader:.woff2=file",
614 "--public-path=.",
615 "--define:__MONACO_HASH__=\"" + monacoHash + "\"",
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000616 "--define:__MERMAID_HASH__=\"" + mermaidHash + "\"",
philip.zeyligerc0a44592025-06-15 21:24:57 -0700617 }
618
619 ret := esbuildcli.Run(args)
620 if ret != 0 {
621 return fmt.Errorf("esbuild %s failed: %d", filepath.Base(src), ret)
622 }
623 return nil
624}