blob: 1c356ce591a97639ebf9254be5410b559b425e57 [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
111func zipPath() (cacheDir, hashZip string, err error) {
112 homeDir, err := os.UserHomeDir()
113 if err != nil {
114 return "", "", err
115 }
116 hash, err := embeddedHash()
117 if err != nil {
118 return "", "", err
119 }
120 cacheDir = filepath.Join(homeDir, ".cache", "sketch", "webui")
121 return cacheDir, filepath.Join(cacheDir, "skui-"+hash+".zip"), nil
122}
123
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700124// copyMonacoAssets copies Monaco editor assets to the output directory
125func copyMonacoAssets(buildDir, outDir string) error {
126 // Create Monaco directories
127 monacoEditorDir := filepath.Join(outDir, "monaco", "min", "vs", "editor")
128 codiconDir := filepath.Join(outDir, "monaco", "min", "vs", "base", "browser", "ui", "codicons", "codicon")
129
130 if err := os.MkdirAll(monacoEditorDir, 0o777); err != nil {
131 return fmt.Errorf("failed to create monaco editor directory: %w", err)
132 }
133
134 if err := os.MkdirAll(codiconDir, 0o777); err != nil {
135 return fmt.Errorf("failed to create codicon directory: %w", err)
136 }
137
138 // Copy Monaco editor CSS
139 editorCssPath := "node_modules/monaco-editor/min/vs/editor/editor.main.css"
140 editorCss, err := os.ReadFile(filepath.Join(buildDir, editorCssPath))
141 if err != nil {
142 return fmt.Errorf("failed to read monaco editor CSS: %w", err)
143 }
144
145 if err := os.WriteFile(filepath.Join(monacoEditorDir, "editor.main.css"), editorCss, 0o666); err != nil {
146 return fmt.Errorf("failed to write monaco editor CSS: %w", err)
147 }
148
149 // Copy Codicon font
150 codiconFontPath := "node_modules/monaco-editor/min/vs/base/browser/ui/codicons/codicon/codicon.ttf"
151 codiconFont, err := os.ReadFile(filepath.Join(buildDir, codiconFontPath))
152 if err != nil {
153 return fmt.Errorf("failed to read codicon font: %w", err)
154 }
155
156 if err := os.WriteFile(filepath.Join(codiconDir, "codicon.ttf"), codiconFont, 0o666); err != nil {
157 return fmt.Errorf("failed to write codicon font: %w", err)
158 }
159
160 return nil
161}
162
Earl Lee2e463fb2025-04-17 11:22:22 -0700163// Build unpacks and esbuild's all bundleTs typescript files
164func Build() (fs.FS, error) {
David Crawshaw8bff16a2025-04-18 01:16:49 -0700165 cacheDir, hashZip, err := zipPath()
Earl Lee2e463fb2025-04-17 11:22:22 -0700166 if err != nil {
167 return nil, err
168 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700169 buildDir := filepath.Join(cacheDir, "build")
170 if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
171 return nil, err
172 }
David Crawshaw8bff16a2025-04-18 01:16:49 -0700173 if b, err := os.ReadFile(hashZip); err == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700174 // Build already done, serve it out.
David Crawshaw8bff16a2025-04-18 01:16:49 -0700175 return zip.NewReader(bytes.NewReader(b), int64(len(b)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700176 }
177
David Crawshaw8bff16a2025-04-18 01:16:49 -0700178 // TODO: try downloading "https://sketch.dev/webui/"+filepath.Base(hashZip)
179
Earl Lee2e463fb2025-04-17 11:22:22 -0700180 // We need to do a build.
181
182 // Clear everything out of the build directory except node_modules.
183 if err := cleanBuildDir(buildDir); err != nil {
184 return nil, err
185 }
186 tmpHashDir := filepath.Join(buildDir, "out")
187 if err := os.Mkdir(tmpHashDir, 0o777); err != nil {
188 return nil, err
189 }
190
191 // Unpack everything from embedded into build dir.
192 if err := unpackFS(buildDir, embedded); err != nil {
193 return nil, err
194 }
195
Sean McCullough86b56862025-04-18 13:04:03 -0700196 // Do the build. Don't install dev dependencies, because they can be large
197 // and slow enough to install that the /init requests from the host process
198 // will run out of retries and the whole thing exits. We do need better health
199 // checking in general, but that's a separate issue. Don't do slow stuff here:
200 cmd := exec.Command("npm", "ci", "--omit", "dev")
Earl Lee2e463fb2025-04-17 11:22:22 -0700201 cmd.Dir = buildDir
202 if out, err := cmd.CombinedOutput(); err != nil {
203 return nil, fmt.Errorf("npm ci: %s: %v", out, err)
204 }
philip.zeyligerc0a44592025-06-15 21:24:57 -0700205 // Create all bundles
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700206 bundleTs := []string{
207 "src/web-components/sketch-app-shell.ts",
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700208 "src/web-components/mobile-app-shell.ts",
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700209 "src/web-components/sketch-monaco-view.ts",
Sean McCullough021231a2025-06-12 09:35:24 -0700210 "src/messages-viewer.ts",
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700211 "node_modules/monaco-editor/esm/vs/editor/editor.worker.js",
212 "node_modules/monaco-editor/esm/vs/language/typescript/ts.worker.js",
213 "node_modules/monaco-editor/esm/vs/language/html/html.worker.js",
214 "node_modules/monaco-editor/esm/vs/language/css/css.worker.js",
215 "node_modules/monaco-editor/esm/vs/language/json/json.worker.js",
216 }
217
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000218 // Additionally create standalone bundles for caching
philip.zeyligerc0a44592025-06-15 21:24:57 -0700219 monacoHash, err := createStandaloneMonacoBundle(tmpHashDir, buildDir)
220 if err != nil {
221 return nil, fmt.Errorf("create monaco bundle: %w", err)
222 }
philip.zeyligerc0a44592025-06-15 21:24:57 -0700223
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000224 mermaidHash, err := createStandaloneMermaidBundle(tmpHashDir, buildDir)
225 if err != nil {
226 return nil, fmt.Errorf("create mermaid bundle: %w", err)
227 }
228
229 // Bundle all files with Monaco and Mermaid as external (since they may transitively import them)
Earl Lee2e463fb2025-04-17 11:22:22 -0700230 for _, tsName := range bundleTs {
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000231 // Use external Monaco and Mermaid for all TypeScript files to ensure consistency
philip.zeyligerc0a44592025-06-15 21:24:57 -0700232 if strings.HasSuffix(tsName, ".ts") {
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000233 if err := esbuildBundleWithExternals(tmpHashDir, filepath.Join(buildDir, tsName), monacoHash, mermaidHash); err != nil {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700234 return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
235 }
236 } else {
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000237 // Bundle worker files normally (they don't use Monaco or Mermaid)
philip.zeyligerc0a44592025-06-15 21:24:57 -0700238 if err := esbuildBundle(tmpHashDir, filepath.Join(buildDir, tsName), ""); err != nil {
239 return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
240 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700241 }
242 }
243
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700244 // Copy Monaco editor assets
245 if err := copyMonacoAssets(buildDir, tmpHashDir); err != nil {
246 return nil, fmt.Errorf("failed to copy Monaco assets: %w", err)
247 }
248
Earl Lee2e463fb2025-04-17 11:22:22 -0700249 // Copy src files used directly into the new hash output dir.
250 err = fs.WalkDir(embedded, "src", func(path string, d fs.DirEntry, err error) error {
251 if d.IsDir() {
Sean McCullough86b56862025-04-18 13:04:03 -0700252 if path == "src/web-components/demo" {
253 return fs.SkipDir
254 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700255 return nil
256 }
Pokey Rule8cac59a2025-04-24 12:21:19 +0100257 if strings.HasSuffix(path, "mockServiceWorker.js") {
258 return nil
259 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700260 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".css") || strings.HasSuffix(path, ".js") {
261 b, err := embedded.ReadFile(path)
262 if err != nil {
263 return err
264 }
265 dstPath := filepath.Join(tmpHashDir, strings.TrimPrefix(path, "src/"))
266 if err := os.WriteFile(dstPath, b, 0o777); err != nil {
267 return err
268 }
269 return nil
270 }
271 return nil
272 })
273 if err != nil {
274 return nil, err
275 }
276
277 // Copy xterm.css from node_modules
278 const xtermCssPath = "node_modules/@xterm/xterm/css/xterm.css"
279 xtermCss, err := os.ReadFile(filepath.Join(buildDir, xtermCssPath))
280 if err != nil {
281 return nil, fmt.Errorf("failed to read xterm.css: %w", err)
282 }
283 if err := os.WriteFile(filepath.Join(tmpHashDir, "xterm.css"), xtermCss, 0o666); err != nil {
284 return nil, fmt.Errorf("failed to write xterm.css: %w", err)
285 }
286
Philip Zeyliger176de792025-04-21 12:25:18 -0700287 // Compress all .js, .js.map, and .css files with gzip, leaving the originals in place
288 err = filepath.Walk(tmpHashDir, func(path string, info os.FileInfo, err error) error {
289 if err != nil {
290 return err
291 }
292 if info.IsDir() {
293 return nil
294 }
295 // Check if file is a .js or .js.map file
296 if !strings.HasSuffix(path, ".js") && !strings.HasSuffix(path, ".js.map") && !strings.HasSuffix(path, ".css") {
297 return nil
298 }
299
300 // Read the original file
301 origData, err := os.ReadFile(path)
302 if err != nil {
303 return fmt.Errorf("failed to read file %s: %w", path, err)
304 }
305
306 // Create a gzipped file
307 gzipPath := path + ".gz"
308 gzipFile, err := os.Create(gzipPath)
309 if err != nil {
310 return fmt.Errorf("failed to create gzip file %s: %w", gzipPath, err)
311 }
312 defer gzipFile.Close()
313
314 // Create a gzip writer
315 gzWriter := gzip.NewWriter(gzipFile)
316 defer gzWriter.Close()
317
318 // Write the original file content to the gzip writer
319 _, err = gzWriter.Write(origData)
320 if err != nil {
321 return fmt.Errorf("failed to write to gzip file %s: %w", gzipPath, err)
322 }
323
324 // Ensure we flush and close properly
325 if err := gzWriter.Close(); err != nil {
326 return fmt.Errorf("failed to close gzip writer for %s: %w", gzipPath, err)
327 }
328 if err := gzipFile.Close(); err != nil {
329 return fmt.Errorf("failed to close gzip file %s: %w", gzipPath, err)
330 }
331
332 return nil
333 })
334 if err != nil {
335 return nil, fmt.Errorf("failed to compress .js/.js.map/.css files: %w", err)
336 }
337
David Crawshaw8bff16a2025-04-18 01:16:49 -0700338 // Everything succeeded, so we write tmpHashDir to hashZip
339 buf := new(bytes.Buffer)
340 w := zip.NewWriter(buf)
341 if err := w.AddFS(os.DirFS(tmpHashDir)); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700342 return nil, err
343 }
David Crawshaw8bff16a2025-04-18 01:16:49 -0700344 if err := w.Close(); err != nil {
345 return nil, err
346 }
347 if err := os.WriteFile(hashZip, buf.Bytes(), 0o666); err != nil {
348 return nil, err
349 }
350 return zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
Earl Lee2e463fb2025-04-17 11:22:22 -0700351}
352
Sean McCullough86b56862025-04-18 13:04:03 -0700353func esbuildBundle(outDir, src, metafilePath string) error {
354 args := []string{
Earl Lee2e463fb2025-04-17 11:22:22 -0700355 src,
356 "--bundle",
357 "--sourcemap",
358 "--log-level=error",
philip.zeyligerc0a44592025-06-15 21:24:57 -0700359 "--minify",
Earl Lee2e463fb2025-04-17 11:22:22 -0700360 "--outdir=" + outDir,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700361 "--loader:.ttf=file",
362 "--loader:.eot=file",
363 "--loader:.woff=file",
364 "--loader:.woff2=file",
365 // This changes where the sourcemap points to; we need relative dirs if we're proxied into a subdirectory.
366 "--public-path=.",
Sean McCullough86b56862025-04-18 13:04:03 -0700367 }
368
369 // Add metafile option if path is provided
370 if metafilePath != "" {
371 args = append(args, "--metafile="+metafilePath)
372 }
373
374 ret := esbuildcli.Run(args)
Earl Lee2e463fb2025-04-17 11:22:22 -0700375 if ret != 0 {
376 return fmt.Errorf("esbuild %s failed: %d", filepath.Base(src), ret)
377 }
378 return nil
379}
Sean McCullough86b56862025-04-18 13:04:03 -0700380
381// unpackTS unpacks all the typescript-relevant files from the embedded filesystem into tmpDir.
382func unpackTS(outDir string, embedded fs.FS) error {
383 return fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error {
384 if err != nil {
385 return err
386 }
387 tgt := filepath.Join(outDir, path)
388 if d.IsDir() {
389 if err := os.MkdirAll(tgt, 0o777); err != nil {
390 return err
391 }
392 return nil
393 }
394 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".md") || strings.HasSuffix(path, ".css") {
395 return nil
396 }
397 data, err := fs.ReadFile(embedded, path)
398 if err != nil {
399 return err
400 }
401 if err := os.WriteFile(tgt, data, 0o666); err != nil {
402 return err
403 }
404 return nil
405 })
406}
407
408// GenerateBundleMetafile creates metafiles for bundle analysis with esbuild.
409//
410// The metafiles contain information about bundle size and dependencies
411// that can be visualized at https://esbuild.github.io/analyze/
412//
413// It takes the output directory where the metafiles will be written.
414// Returns the file path of the generated metafiles.
415func GenerateBundleMetafile(outputDir string) (string, error) {
416 tmpDir, err := os.MkdirTemp("", "bundle-analysis-")
417 if err != nil {
418 return "", err
419 }
420 defer os.RemoveAll(tmpDir)
421
422 // Create output directory if it doesn't exist
Philip Zeyligerd1402952025-04-23 03:54:37 +0000423 if err := os.MkdirAll(outputDir, 0o755); err != nil {
Sean McCullough86b56862025-04-18 13:04:03 -0700424 return "", err
425 }
426
427 cacheDir, _, err := zipPath()
428 if err != nil {
429 return "", err
430 }
431 buildDir := filepath.Join(cacheDir, "build")
432 if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
433 return "", err
434 }
435
436 // Ensure we have a source to bundle
437 if err := unpackTS(tmpDir, embedded); err != nil {
438 return "", err
439 }
440
441 // All bundles to analyze
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700442 bundleTs := []string{
443 "src/web-components/sketch-app-shell.ts",
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700444 "src/web-components/mobile-app-shell.ts",
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700445 "src/web-components/sketch-monaco-view.ts",
Sean McCullough021231a2025-06-12 09:35:24 -0700446 "src/messages-viewer.ts",
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700447 }
Sean McCullough86b56862025-04-18 13:04:03 -0700448 metafiles := make([]string, len(bundleTs))
449
450 for i, tsName := range bundleTs {
451 // Create a metafile path for this bundle
452 baseFileName := filepath.Base(tsName)
453 metaFileName := strings.TrimSuffix(baseFileName, ".ts") + ".meta.json"
454 metafilePath := filepath.Join(outputDir, metaFileName)
455 metafiles[i] = metafilePath
456
457 // Bundle with metafile generation
458 outTmpDir, err := os.MkdirTemp("", "metafile-bundle-")
459 if err != nil {
460 return "", err
461 }
462 defer os.RemoveAll(outTmpDir)
463
464 if err := esbuildBundle(outTmpDir, filepath.Join(buildDir, tsName), metafilePath); err != nil {
465 return "", fmt.Errorf("failed to generate metafile for %s: %w", tsName, err)
466 }
467 }
468
469 return outputDir, nil
470}
philip.zeyligerc0a44592025-06-15 21:24:57 -0700471
472// createStandaloneMonacoBundle creates a separate Monaco editor bundle with content-based hash
473// This is useful for caching Monaco separately from the main application bundles
474func createStandaloneMonacoBundle(outDir, buildDir string) (string, error) {
475 // Create a temporary entry file that imports Monaco and exposes it globally
476 monacoEntryContent := `import * as monaco from 'monaco-editor';
477window.monaco = monaco;
478export default monaco;
479`
480 monacoEntryPath := filepath.Join(buildDir, "monaco-standalone-entry.js")
481 if err := os.WriteFile(monacoEntryPath, []byte(monacoEntryContent), 0o666); err != nil {
482 return "", fmt.Errorf("write monaco entry: %w", err)
483 }
484
485 // Calculate hash of monaco-editor package for content-based naming
486 monacoPackageJson := filepath.Join(buildDir, "node_modules", "monaco-editor", "package.json")
487 monacoContent, err := os.ReadFile(monacoPackageJson)
488 if err != nil {
489 return "", fmt.Errorf("read monaco package.json: %w", err)
490 }
491
492 h := sha256.New()
493 h.Write(monacoContent)
494 monacoHash := hex.EncodeToString(h.Sum(nil))[:16]
495
496 // Bundle Monaco with content-based filename
497 monacoOutputName := fmt.Sprintf("monaco-standalone-%s.js", monacoHash)
498 monacoOutputPath := filepath.Join(outDir, monacoOutputName)
499
500 args := []string{
501 monacoEntryPath,
502 "--bundle",
503 "--sourcemap",
504 "--minify",
505 "--log-level=error",
506 "--outfile=" + monacoOutputPath,
507 "--format=iife",
508 "--global-name=__MonacoLoader__",
509 "--loader:.ttf=file",
510 "--loader:.eot=file",
511 "--loader:.woff=file",
512 "--loader:.woff2=file",
513 "--public-path=.",
514 }
515
516 ret := esbuildcli.Run(args)
517 if ret != 0 {
518 return "", fmt.Errorf("esbuild monaco bundle failed: %d", ret)
519 }
520
521 return monacoHash, nil
522}
523
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000524// createStandaloneMermaidBundle creates a separate Mermaid bundle with content-based hash
525// This is useful for caching Mermaid separately from the main application bundles
526func createStandaloneMermaidBundle(outDir, buildDir string) (string, error) {
527 // Create a temporary entry file that imports Mermaid and exposes it globally
528 mermaidEntryContent := `import mermaid from 'mermaid';
529window.mermaid = mermaid;
530export default mermaid;
531`
532 mermaidEntryPath := filepath.Join(buildDir, "mermaid-standalone-entry.js")
533 if err := os.WriteFile(mermaidEntryPath, []byte(mermaidEntryContent), 0o666); err != nil {
534 return "", fmt.Errorf("write mermaid entry: %w", err)
535 }
536
537 // Calculate hash of mermaid package for content-based naming
538 mermaidPackageJson := filepath.Join(buildDir, "node_modules", "mermaid", "package.json")
539 mermaidContent, err := os.ReadFile(mermaidPackageJson)
540 if err != nil {
541 return "", fmt.Errorf("read mermaid package.json: %w", err)
542 }
543
544 h := sha256.New()
545 h.Write(mermaidContent)
546 mermaidHash := hex.EncodeToString(h.Sum(nil))[:16]
547
548 // Bundle Mermaid with content-based filename
549 mermaidOutputName := fmt.Sprintf("mermaid-standalone-%s.js", mermaidHash)
550 mermaidOutputPath := filepath.Join(outDir, mermaidOutputName)
551
552 args := []string{
553 mermaidEntryPath,
554 "--bundle",
555 "--sourcemap",
556 "--minify",
557 "--log-level=error",
558 "--outfile=" + mermaidOutputPath,
559 "--format=iife",
560 "--global-name=__MermaidLoader__",
561 "--loader:.ttf=file",
562 "--loader:.eot=file",
563 "--loader:.woff=file",
564 "--loader:.woff2=file",
565 "--public-path=.",
566 }
567
568 ret := esbuildcli.Run(args)
569 if ret != 0 {
570 return "", fmt.Errorf("esbuild mermaid bundle failed: %d", ret)
571 }
572
573 return mermaidHash, nil
574}
575
576// esbuildBundleWithExternals bundles a file with Monaco and Mermaid as external dependencies
577func esbuildBundleWithExternals(outDir, src, monacoHash, mermaidHash string) error {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700578 args := []string{
579 src,
580 "--bundle",
581 "--sourcemap",
582 "--minify",
583 "--log-level=error",
584 "--outdir=" + outDir,
585 "--external:monaco-editor",
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000586 "--external:mermaid",
philip.zeyligerc0a44592025-06-15 21:24:57 -0700587 "--loader:.ttf=file",
588 "--loader:.eot=file",
589 "--loader:.woff=file",
590 "--loader:.woff2=file",
591 "--public-path=.",
592 "--define:__MONACO_HASH__=\"" + monacoHash + "\"",
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000593 "--define:__MERMAID_HASH__=\"" + mermaidHash + "\"",
philip.zeyligerc0a44592025-06-15 21:24:57 -0700594 }
595
596 ret := esbuildcli.Run(args)
597 if ret != 0 {
598 return fmt.Errorf("esbuild %s failed: %d", filepath.Base(src), ret)
599 }
600 return nil
601}