blob: ee959430185787ad98eca056d8283641a07c6ffb [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
Sean McCullough39995932025-06-25 19:32:08 +0000124// generateTailwindCSS generates tailwind.css from global.css and outputs it to the specified directory
125func generateTailwindCSS(buildDir, outDir string) error {
126 // Run tailwindcss CLI to generate the CSS
127 cmd := exec.Command("npx", "tailwindcss", "-i", "./src/global.css", "-o", filepath.Join(outDir, "tailwind.css"))
128 cmd.Dir = buildDir
129 if out, err := cmd.CombinedOutput(); err != nil {
130 return fmt.Errorf("tailwindcss generation failed: %s: %v", out, err)
131 }
132 return nil
133}
134
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700135// copyMonacoAssets copies Monaco editor assets to the output directory
136func copyMonacoAssets(buildDir, outDir string) error {
137 // Create Monaco directories
138 monacoEditorDir := filepath.Join(outDir, "monaco", "min", "vs", "editor")
139 codiconDir := filepath.Join(outDir, "monaco", "min", "vs", "base", "browser", "ui", "codicons", "codicon")
140
141 if err := os.MkdirAll(monacoEditorDir, 0o777); err != nil {
142 return fmt.Errorf("failed to create monaco editor directory: %w", err)
143 }
144
145 if err := os.MkdirAll(codiconDir, 0o777); err != nil {
146 return fmt.Errorf("failed to create codicon directory: %w", err)
147 }
148
149 // Copy Monaco editor CSS
150 editorCssPath := "node_modules/monaco-editor/min/vs/editor/editor.main.css"
151 editorCss, err := os.ReadFile(filepath.Join(buildDir, editorCssPath))
152 if err != nil {
153 return fmt.Errorf("failed to read monaco editor CSS: %w", err)
154 }
155
156 if err := os.WriteFile(filepath.Join(monacoEditorDir, "editor.main.css"), editorCss, 0o666); err != nil {
157 return fmt.Errorf("failed to write monaco editor CSS: %w", err)
158 }
159
160 // Copy Codicon font
161 codiconFontPath := "node_modules/monaco-editor/min/vs/base/browser/ui/codicons/codicon/codicon.ttf"
162 codiconFont, err := os.ReadFile(filepath.Join(buildDir, codiconFontPath))
163 if err != nil {
164 return fmt.Errorf("failed to read codicon font: %w", err)
165 }
166
167 if err := os.WriteFile(filepath.Join(codiconDir, "codicon.ttf"), codiconFont, 0o666); err != nil {
168 return fmt.Errorf("failed to write codicon font: %w", err)
169 }
170
171 return nil
172}
173
Earl Lee2e463fb2025-04-17 11:22:22 -0700174// Build unpacks and esbuild's all bundleTs typescript files
175func Build() (fs.FS, error) {
David Crawshaw8bff16a2025-04-18 01:16:49 -0700176 cacheDir, hashZip, err := zipPath()
Earl Lee2e463fb2025-04-17 11:22:22 -0700177 if err != nil {
178 return nil, err
179 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700180 buildDir := filepath.Join(cacheDir, "build")
181 if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
182 return nil, err
183 }
David Crawshaw8bff16a2025-04-18 01:16:49 -0700184 if b, err := os.ReadFile(hashZip); err == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700185 // Build already done, serve it out.
David Crawshaw8bff16a2025-04-18 01:16:49 -0700186 return zip.NewReader(bytes.NewReader(b), int64(len(b)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700187 }
188
David Crawshaw8bff16a2025-04-18 01:16:49 -0700189 // TODO: try downloading "https://sketch.dev/webui/"+filepath.Base(hashZip)
190
Earl Lee2e463fb2025-04-17 11:22:22 -0700191 // We need to do a build.
192
193 // Clear everything out of the build directory except node_modules.
194 if err := cleanBuildDir(buildDir); err != nil {
195 return nil, err
196 }
197 tmpHashDir := filepath.Join(buildDir, "out")
198 if err := os.Mkdir(tmpHashDir, 0o777); err != nil {
199 return nil, err
200 }
201
202 // Unpack everything from embedded into build dir.
203 if err := unpackFS(buildDir, embedded); err != nil {
204 return nil, err
205 }
206
Sean McCullough86b56862025-04-18 13:04:03 -0700207 // Do the build. Don't install dev dependencies, because they can be large
208 // and slow enough to install that the /init requests from the host process
209 // will run out of retries and the whole thing exits. We do need better health
210 // checking in general, but that's a separate issue. Don't do slow stuff here:
211 cmd := exec.Command("npm", "ci", "--omit", "dev")
Earl Lee2e463fb2025-04-17 11:22:22 -0700212 cmd.Dir = buildDir
213 if out, err := cmd.CombinedOutput(); err != nil {
214 return nil, fmt.Errorf("npm ci: %s: %v", out, err)
215 }
Sean McCullough39995932025-06-25 19:32:08 +0000216
217 // Generate Tailwind CSS
218 if err := generateTailwindCSS(buildDir, tmpHashDir); err != nil {
219 return nil, fmt.Errorf("generate tailwind css: %w", err)
220 }
philip.zeyligerc0a44592025-06-15 21:24:57 -0700221 // Create all bundles
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700222 bundleTs := []string{
223 "src/web-components/sketch-app-shell.ts",
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700224 "src/web-components/mobile-app-shell.ts",
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700225 "src/web-components/sketch-monaco-view.ts",
Sean McCullough021231a2025-06-12 09:35:24 -0700226 "src/messages-viewer.ts",
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700227 "node_modules/monaco-editor/esm/vs/editor/editor.worker.js",
228 "node_modules/monaco-editor/esm/vs/language/typescript/ts.worker.js",
229 "node_modules/monaco-editor/esm/vs/language/html/html.worker.js",
230 "node_modules/monaco-editor/esm/vs/language/css/css.worker.js",
231 "node_modules/monaco-editor/esm/vs/language/json/json.worker.js",
232 }
233
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000234 // Additionally create standalone bundles for caching
philip.zeyligerc0a44592025-06-15 21:24:57 -0700235 monacoHash, err := createStandaloneMonacoBundle(tmpHashDir, buildDir)
236 if err != nil {
237 return nil, fmt.Errorf("create monaco bundle: %w", err)
238 }
philip.zeyligerc0a44592025-06-15 21:24:57 -0700239
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000240 mermaidHash, err := createStandaloneMermaidBundle(tmpHashDir, buildDir)
241 if err != nil {
242 return nil, fmt.Errorf("create mermaid bundle: %w", err)
243 }
244
245 // Bundle all files with Monaco and Mermaid as external (since they may transitively import them)
Earl Lee2e463fb2025-04-17 11:22:22 -0700246 for _, tsName := range bundleTs {
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000247 // Use external Monaco and Mermaid for all TypeScript files to ensure consistency
philip.zeyligerc0a44592025-06-15 21:24:57 -0700248 if strings.HasSuffix(tsName, ".ts") {
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000249 if err := esbuildBundleWithExternals(tmpHashDir, filepath.Join(buildDir, tsName), monacoHash, mermaidHash); err != nil {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700250 return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
251 }
252 } else {
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000253 // Bundle worker files normally (they don't use Monaco or Mermaid)
philip.zeyligerc0a44592025-06-15 21:24:57 -0700254 if err := esbuildBundle(tmpHashDir, filepath.Join(buildDir, tsName), ""); err != nil {
255 return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
256 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700257 }
258 }
259
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700260 // Copy Monaco editor assets
261 if err := copyMonacoAssets(buildDir, tmpHashDir); err != nil {
262 return nil, fmt.Errorf("failed to copy Monaco assets: %w", err)
263 }
264
Earl Lee2e463fb2025-04-17 11:22:22 -0700265 // Copy src files used directly into the new hash output dir.
266 err = fs.WalkDir(embedded, "src", func(path string, d fs.DirEntry, err error) error {
267 if d.IsDir() {
Sean McCullough86b56862025-04-18 13:04:03 -0700268 if path == "src/web-components/demo" {
269 return fs.SkipDir
270 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700271 return nil
272 }
Pokey Rule8cac59a2025-04-24 12:21:19 +0100273 if strings.HasSuffix(path, "mockServiceWorker.js") {
274 return nil
275 }
Sean McCullough39995932025-06-25 19:32:08 +0000276 // Skip src/tailwind.css as it will be generated
277 if path == "src/tailwind.css" {
278 return nil
279 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700280 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".css") || strings.HasSuffix(path, ".js") {
281 b, err := embedded.ReadFile(path)
282 if err != nil {
283 return err
284 }
285 dstPath := filepath.Join(tmpHashDir, strings.TrimPrefix(path, "src/"))
286 if err := os.WriteFile(dstPath, b, 0o777); err != nil {
287 return err
288 }
289 return nil
290 }
291 return nil
292 })
293 if err != nil {
294 return nil, err
295 }
296
297 // Copy xterm.css from node_modules
298 const xtermCssPath = "node_modules/@xterm/xterm/css/xterm.css"
299 xtermCss, err := os.ReadFile(filepath.Join(buildDir, xtermCssPath))
300 if err != nil {
301 return nil, fmt.Errorf("failed to read xterm.css: %w", err)
302 }
303 if err := os.WriteFile(filepath.Join(tmpHashDir, "xterm.css"), xtermCss, 0o666); err != nil {
304 return nil, fmt.Errorf("failed to write xterm.css: %w", err)
305 }
306
Philip Zeyliger176de792025-04-21 12:25:18 -0700307 // Compress all .js, .js.map, and .css files with gzip, leaving the originals in place
308 err = filepath.Walk(tmpHashDir, func(path string, info os.FileInfo, err error) error {
309 if err != nil {
310 return err
311 }
312 if info.IsDir() {
313 return nil
314 }
315 // Check if file is a .js or .js.map file
316 if !strings.HasSuffix(path, ".js") && !strings.HasSuffix(path, ".js.map") && !strings.HasSuffix(path, ".css") {
317 return nil
318 }
319
320 // Read the original file
321 origData, err := os.ReadFile(path)
322 if err != nil {
323 return fmt.Errorf("failed to read file %s: %w", path, err)
324 }
325
326 // Create a gzipped file
327 gzipPath := path + ".gz"
328 gzipFile, err := os.Create(gzipPath)
329 if err != nil {
330 return fmt.Errorf("failed to create gzip file %s: %w", gzipPath, err)
331 }
332 defer gzipFile.Close()
333
334 // Create a gzip writer
335 gzWriter := gzip.NewWriter(gzipFile)
336 defer gzWriter.Close()
337
338 // Write the original file content to the gzip writer
339 _, err = gzWriter.Write(origData)
340 if err != nil {
341 return fmt.Errorf("failed to write to gzip file %s: %w", gzipPath, err)
342 }
343
344 // Ensure we flush and close properly
345 if err := gzWriter.Close(); err != nil {
346 return fmt.Errorf("failed to close gzip writer for %s: %w", gzipPath, err)
347 }
348 if err := gzipFile.Close(); err != nil {
349 return fmt.Errorf("failed to close gzip file %s: %w", gzipPath, err)
350 }
351
352 return nil
353 })
354 if err != nil {
355 return nil, fmt.Errorf("failed to compress .js/.js.map/.css files: %w", err)
356 }
357
David Crawshaw8bff16a2025-04-18 01:16:49 -0700358 // Everything succeeded, so we write tmpHashDir to hashZip
359 buf := new(bytes.Buffer)
360 w := zip.NewWriter(buf)
361 if err := w.AddFS(os.DirFS(tmpHashDir)); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700362 return nil, err
363 }
David Crawshaw8bff16a2025-04-18 01:16:49 -0700364 if err := w.Close(); err != nil {
365 return nil, err
366 }
367 if err := os.WriteFile(hashZip, buf.Bytes(), 0o666); err != nil {
368 return nil, err
369 }
370 return zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
Earl Lee2e463fb2025-04-17 11:22:22 -0700371}
372
Sean McCullough86b56862025-04-18 13:04:03 -0700373func esbuildBundle(outDir, src, metafilePath string) error {
374 args := []string{
Earl Lee2e463fb2025-04-17 11:22:22 -0700375 src,
376 "--bundle",
377 "--sourcemap",
378 "--log-level=error",
philip.zeyligerc0a44592025-06-15 21:24:57 -0700379 "--minify",
Earl Lee2e463fb2025-04-17 11:22:22 -0700380 "--outdir=" + outDir,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700381 "--loader:.ttf=file",
382 "--loader:.eot=file",
383 "--loader:.woff=file",
384 "--loader:.woff2=file",
385 // This changes where the sourcemap points to; we need relative dirs if we're proxied into a subdirectory.
386 "--public-path=.",
Sean McCullough86b56862025-04-18 13:04:03 -0700387 }
388
389 // Add metafile option if path is provided
390 if metafilePath != "" {
391 args = append(args, "--metafile="+metafilePath)
392 }
393
394 ret := esbuildcli.Run(args)
Earl Lee2e463fb2025-04-17 11:22:22 -0700395 if ret != 0 {
396 return fmt.Errorf("esbuild %s failed: %d", filepath.Base(src), ret)
397 }
398 return nil
399}
Sean McCullough86b56862025-04-18 13:04:03 -0700400
401// unpackTS unpacks all the typescript-relevant files from the embedded filesystem into tmpDir.
402func unpackTS(outDir string, embedded fs.FS) error {
403 return fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error {
404 if err != nil {
405 return err
406 }
407 tgt := filepath.Join(outDir, path)
408 if d.IsDir() {
409 if err := os.MkdirAll(tgt, 0o777); err != nil {
410 return err
411 }
412 return nil
413 }
414 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".md") || strings.HasSuffix(path, ".css") {
415 return nil
416 }
417 data, err := fs.ReadFile(embedded, path)
418 if err != nil {
419 return err
420 }
421 if err := os.WriteFile(tgt, data, 0o666); err != nil {
422 return err
423 }
424 return nil
425 })
426}
427
428// GenerateBundleMetafile creates metafiles for bundle analysis with esbuild.
429//
430// The metafiles contain information about bundle size and dependencies
431// that can be visualized at https://esbuild.github.io/analyze/
432//
433// It takes the output directory where the metafiles will be written.
434// Returns the file path of the generated metafiles.
435func GenerateBundleMetafile(outputDir string) (string, error) {
436 tmpDir, err := os.MkdirTemp("", "bundle-analysis-")
437 if err != nil {
438 return "", err
439 }
440 defer os.RemoveAll(tmpDir)
441
442 // Create output directory if it doesn't exist
Philip Zeyligerd1402952025-04-23 03:54:37 +0000443 if err := os.MkdirAll(outputDir, 0o755); err != nil {
Sean McCullough86b56862025-04-18 13:04:03 -0700444 return "", err
445 }
446
447 cacheDir, _, err := zipPath()
448 if err != nil {
449 return "", err
450 }
451 buildDir := filepath.Join(cacheDir, "build")
452 if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
453 return "", err
454 }
455
456 // Ensure we have a source to bundle
457 if err := unpackTS(tmpDir, embedded); err != nil {
458 return "", err
459 }
460
461 // All bundles to analyze
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700462 bundleTs := []string{
463 "src/web-components/sketch-app-shell.ts",
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700464 "src/web-components/mobile-app-shell.ts",
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700465 "src/web-components/sketch-monaco-view.ts",
Sean McCullough021231a2025-06-12 09:35:24 -0700466 "src/messages-viewer.ts",
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700467 }
Sean McCullough86b56862025-04-18 13:04:03 -0700468 metafiles := make([]string, len(bundleTs))
469
470 for i, tsName := range bundleTs {
471 // Create a metafile path for this bundle
472 baseFileName := filepath.Base(tsName)
473 metaFileName := strings.TrimSuffix(baseFileName, ".ts") + ".meta.json"
474 metafilePath := filepath.Join(outputDir, metaFileName)
475 metafiles[i] = metafilePath
476
477 // Bundle with metafile generation
478 outTmpDir, err := os.MkdirTemp("", "metafile-bundle-")
479 if err != nil {
480 return "", err
481 }
482 defer os.RemoveAll(outTmpDir)
483
484 if err := esbuildBundle(outTmpDir, filepath.Join(buildDir, tsName), metafilePath); err != nil {
485 return "", fmt.Errorf("failed to generate metafile for %s: %w", tsName, err)
486 }
487 }
488
489 return outputDir, nil
490}
philip.zeyligerc0a44592025-06-15 21:24:57 -0700491
492// createStandaloneMonacoBundle creates a separate Monaco editor bundle with content-based hash
493// This is useful for caching Monaco separately from the main application bundles
494func createStandaloneMonacoBundle(outDir, buildDir string) (string, error) {
495 // Create a temporary entry file that imports Monaco and exposes it globally
496 monacoEntryContent := `import * as monaco from 'monaco-editor';
497window.monaco = monaco;
498export default monaco;
499`
500 monacoEntryPath := filepath.Join(buildDir, "monaco-standalone-entry.js")
501 if err := os.WriteFile(monacoEntryPath, []byte(monacoEntryContent), 0o666); err != nil {
502 return "", fmt.Errorf("write monaco entry: %w", err)
503 }
504
505 // Calculate hash of monaco-editor package for content-based naming
506 monacoPackageJson := filepath.Join(buildDir, "node_modules", "monaco-editor", "package.json")
507 monacoContent, err := os.ReadFile(monacoPackageJson)
508 if err != nil {
509 return "", fmt.Errorf("read monaco package.json: %w", err)
510 }
511
512 h := sha256.New()
513 h.Write(monacoContent)
514 monacoHash := hex.EncodeToString(h.Sum(nil))[:16]
515
516 // Bundle Monaco with content-based filename
517 monacoOutputName := fmt.Sprintf("monaco-standalone-%s.js", monacoHash)
518 monacoOutputPath := filepath.Join(outDir, monacoOutputName)
519
520 args := []string{
521 monacoEntryPath,
522 "--bundle",
523 "--sourcemap",
524 "--minify",
525 "--log-level=error",
526 "--outfile=" + monacoOutputPath,
527 "--format=iife",
528 "--global-name=__MonacoLoader__",
529 "--loader:.ttf=file",
530 "--loader:.eot=file",
531 "--loader:.woff=file",
532 "--loader:.woff2=file",
533 "--public-path=.",
534 }
535
536 ret := esbuildcli.Run(args)
537 if ret != 0 {
538 return "", fmt.Errorf("esbuild monaco bundle failed: %d", ret)
539 }
540
541 return monacoHash, nil
542}
543
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000544// createStandaloneMermaidBundle creates a separate Mermaid bundle with content-based hash
545// This is useful for caching Mermaid separately from the main application bundles
546func createStandaloneMermaidBundle(outDir, buildDir string) (string, error) {
547 // Create a temporary entry file that imports Mermaid and exposes it globally
548 mermaidEntryContent := `import mermaid from 'mermaid';
549window.mermaid = mermaid;
550export default mermaid;
551`
552 mermaidEntryPath := filepath.Join(buildDir, "mermaid-standalone-entry.js")
553 if err := os.WriteFile(mermaidEntryPath, []byte(mermaidEntryContent), 0o666); err != nil {
554 return "", fmt.Errorf("write mermaid entry: %w", err)
555 }
556
557 // Calculate hash of mermaid package for content-based naming
558 mermaidPackageJson := filepath.Join(buildDir, "node_modules", "mermaid", "package.json")
559 mermaidContent, err := os.ReadFile(mermaidPackageJson)
560 if err != nil {
561 return "", fmt.Errorf("read mermaid package.json: %w", err)
562 }
563
564 h := sha256.New()
565 h.Write(mermaidContent)
566 mermaidHash := hex.EncodeToString(h.Sum(nil))[:16]
567
568 // Bundle Mermaid with content-based filename
569 mermaidOutputName := fmt.Sprintf("mermaid-standalone-%s.js", mermaidHash)
570 mermaidOutputPath := filepath.Join(outDir, mermaidOutputName)
571
572 args := []string{
573 mermaidEntryPath,
574 "--bundle",
575 "--sourcemap",
576 "--minify",
577 "--log-level=error",
578 "--outfile=" + mermaidOutputPath,
579 "--format=iife",
580 "--global-name=__MermaidLoader__",
581 "--loader:.ttf=file",
582 "--loader:.eot=file",
583 "--loader:.woff=file",
584 "--loader:.woff2=file",
585 "--public-path=.",
586 }
587
588 ret := esbuildcli.Run(args)
589 if ret != 0 {
590 return "", fmt.Errorf("esbuild mermaid bundle failed: %d", ret)
591 }
592
593 return mermaidHash, nil
594}
595
596// esbuildBundleWithExternals bundles a file with Monaco and Mermaid as external dependencies
597func esbuildBundleWithExternals(outDir, src, monacoHash, mermaidHash string) error {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700598 args := []string{
599 src,
600 "--bundle",
601 "--sourcemap",
602 "--minify",
603 "--log-level=error",
604 "--outdir=" + outDir,
605 "--external:monaco-editor",
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000606 "--external:mermaid",
philip.zeyligerc0a44592025-06-15 21:24:57 -0700607 "--loader:.ttf=file",
608 "--loader:.eot=file",
609 "--loader:.woff=file",
610 "--loader:.woff2=file",
611 "--public-path=.",
612 "--define:__MONACO_HASH__=\"" + monacoHash + "\"",
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000613 "--define:__MERMAID_HASH__=\"" + mermaidHash + "\"",
philip.zeyligerc0a44592025-06-15 21:24:57 -0700614 }
615
616 ret := esbuildcli.Run(args)
617 if ret != 0 {
618 return fmt.Errorf("esbuild %s failed: %d", filepath.Base(src), ret)
619 }
620 return nil
621}