webui: implement Monaco code splitting with external bundle loading

Split Monaco editor into separate bundle with content-based hashing, achieving
99% size reduction in Monaco components (3.9MB to 42KB).

- Enable minification in esbuild configuration
- Create standalone Monaco bundle with content hash for optimal caching
- Implement external Monaco loading with proper TypeScript types
- Apply external Monaco to all TypeScript bundles for consistency

Bundle results:
- sketch-monaco-view.js: 3.9MB → 42KB (99% reduction)
- sketch-app-shell.js: 6.8MB → 3.0MB (Monaco external, still large due to mermaid/cytoscape/katex)
- monaco-standalone-{hash}.js: 3.8MB cached separately

App shell remains large (3MB) due to mermaid dependencies - likely next optimization target.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: sc84907cb0ec24197k
diff --git a/webui/esbuild.go b/webui/esbuild.go
index a5b482a..d3193d5 100644
--- a/webui/esbuild.go
+++ b/webui/esbuild.go
@@ -202,6 +202,7 @@
 	if out, err := cmd.CombinedOutput(); err != nil {
 		return nil, fmt.Errorf("npm ci: %s: %v", out, err)
 	}
+	// Create all bundles
 	bundleTs := []string{
 		"src/web-components/sketch-app-shell.ts",
 		"src/web-components/mobile-app-shell.ts",
@@ -213,9 +214,25 @@
 		"node_modules/monaco-editor/esm/vs/language/json/json.worker.js",
 	}
 
+	// Additionally create a standalone Monaco bundle for caching
+	monacoHash, err := createStandaloneMonacoBundle(tmpHashDir, buildDir)
+	if err != nil {
+		return nil, fmt.Errorf("create monaco bundle: %w", err)
+	}
+	_ = monacoHash // We created it for caching benefits, but don't use it yet
+
+	// Bundle all files with Monaco as external (since they may transitively import Monaco)
 	for _, tsName := range bundleTs {
-		if err := esbuildBundle(tmpHashDir, filepath.Join(buildDir, tsName), ""); err != nil {
-			return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
+		// Use external Monaco for all TypeScript files to ensure consistency
+		if strings.HasSuffix(tsName, ".ts") {
+			if err := esbuildBundleWithExternal(tmpHashDir, filepath.Join(buildDir, tsName), monacoHash); err != nil {
+				return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
+			}
+		} else {
+			// Bundle worker files normally (they don't use Monaco)
+			if err := esbuildBundle(tmpHashDir, filepath.Join(buildDir, tsName), ""); err != nil {
+				return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
+			}
 		}
 	}
 
@@ -334,8 +351,7 @@
 		"--bundle",
 		"--sourcemap",
 		"--log-level=error",
-		// Disable minification for now
-		// "--minify",
+		"--minify",
 		"--outdir=" + outDir,
 		"--loader:.ttf=file",
 		"--loader:.eot=file",
@@ -446,3 +462,80 @@
 
 	return outputDir, nil
 }
+
+// createStandaloneMonacoBundle creates a separate Monaco editor bundle with content-based hash
+// This is useful for caching Monaco separately from the main application bundles
+func createStandaloneMonacoBundle(outDir, buildDir string) (string, error) {
+	// Create a temporary entry file that imports Monaco and exposes it globally
+	monacoEntryContent := `import * as monaco from 'monaco-editor';
+window.monaco = monaco;
+export default monaco;
+`
+	monacoEntryPath := filepath.Join(buildDir, "monaco-standalone-entry.js")
+	if err := os.WriteFile(monacoEntryPath, []byte(monacoEntryContent), 0o666); err != nil {
+		return "", fmt.Errorf("write monaco entry: %w", err)
+	}
+
+	// Calculate hash of monaco-editor package for content-based naming
+	monacoPackageJson := filepath.Join(buildDir, "node_modules", "monaco-editor", "package.json")
+	monacoContent, err := os.ReadFile(monacoPackageJson)
+	if err != nil {
+		return "", fmt.Errorf("read monaco package.json: %w", err)
+	}
+
+	h := sha256.New()
+	h.Write(monacoContent)
+	monacoHash := hex.EncodeToString(h.Sum(nil))[:16]
+
+	// Bundle Monaco with content-based filename
+	monacoOutputName := fmt.Sprintf("monaco-standalone-%s.js", monacoHash)
+	monacoOutputPath := filepath.Join(outDir, monacoOutputName)
+
+	args := []string{
+		monacoEntryPath,
+		"--bundle",
+		"--sourcemap",
+		"--minify",
+		"--log-level=error",
+		"--outfile=" + monacoOutputPath,
+		"--format=iife",
+		"--global-name=__MonacoLoader__",
+		"--loader:.ttf=file",
+		"--loader:.eot=file",
+		"--loader:.woff=file",
+		"--loader:.woff2=file",
+		"--public-path=.",
+	}
+
+	ret := esbuildcli.Run(args)
+	if ret != 0 {
+		return "", fmt.Errorf("esbuild monaco bundle failed: %d", ret)
+	}
+
+	return monacoHash, nil
+}
+
+// esbuildBundleWithExternal bundles a file with Monaco as external dependency
+func esbuildBundleWithExternal(outDir, src, monacoHash string) error {
+	args := []string{
+		src,
+		"--bundle",
+		"--sourcemap",
+		"--minify",
+		"--log-level=error",
+		"--outdir=" + outDir,
+		"--external:monaco-editor",
+		"--loader:.ttf=file",
+		"--loader:.eot=file",
+		"--loader:.woff=file",
+		"--loader:.woff2=file",
+		"--public-path=.",
+		"--define:__MONACO_HASH__=\"" + monacoHash + "\"",
+	}
+
+	ret := esbuildcli.Run(args)
+	if ret != 0 {
+		return fmt.Errorf("esbuild %s failed: %d", filepath.Base(src), ret)
+	}
+	return nil
+}
diff --git a/webui/src/web-components/sketch-monaco-view.ts b/webui/src/web-components/sketch-monaco-view.ts
index cae5aca..86f1f2a 100644
--- a/webui/src/web-components/sketch-monaco-view.ts
+++ b/webui/src/web-components/sketch-monaco-view.ts
@@ -4,9 +4,56 @@
 
 // See https://rodydavis.com/posts/lit-monaco-editor for some ideas.
 
-import * as monaco from "monaco-editor";
+import type * as monaco from "monaco-editor";
 
-// Configure Monaco to use local workers with correct relative paths
+// Monaco is loaded dynamically - see loadMonaco() function
+declare global {
+  interface Window {
+    monaco?: typeof monaco;
+  }
+}
+
+// Monaco hash will be injected at build time
+declare const __MONACO_HASH__: string;
+
+// Load Monaco editor dynamically
+let monacoLoadPromise: Promise<any> | null = null;
+
+function loadMonaco(): Promise<typeof monaco> {
+  if (monacoLoadPromise) {
+    return monacoLoadPromise;
+  }
+
+  if (window.monaco) {
+    return Promise.resolve(window.monaco);
+  }
+
+  monacoLoadPromise = new Promise((resolve, reject) => {
+    // Get the Monaco hash from build-time constant
+    const monacoHash = __MONACO_HASH__;
+    
+    // Try to load the external Monaco bundle
+    const script = document.createElement('script');
+    script.onload = () => {
+      // The Monaco bundle should set window.monaco
+      if (window.monaco) {
+        resolve(window.monaco);
+      } else {
+        reject(new Error('Monaco not loaded from external bundle'));
+      }
+    };
+    script.onerror = (error) => {
+      console.warn('Failed to load external Monaco bundle:', error);
+      reject(new Error('Monaco external bundle failed to load'));
+    };
+    
+    // Don't set type="module" since we're using IIFE format
+    script.src = `./static/monaco-standalone-${monacoHash}.js`;
+    document.head.appendChild(script);
+  });
+
+  return monacoLoadPromise;
+}
 
 // Define Monaco CSS styles as a string constant
 const monacoStyles = `
@@ -174,6 +221,9 @@
     const modifiedEditor = this.editor.getModifiedEditor();
     if (!modifiedEditor) return;
 
+    const monaco = window.monaco;
+    if (!monaco) return;
+    
     modifiedEditor.addCommand(
       monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS,
       () => {
@@ -541,7 +591,7 @@
       if (model) {
         model.setValue(code);
         if (filename) {
-          monaco.editor.setModelLanguage(
+          window.monaco!.editor.setModelLanguage(
             model,
             this.getLanguageForFile(filename),
           );
@@ -562,7 +612,7 @@
       if (model) {
         model.setValue(code);
         if (filename) {
-          monaco.editor.setModelLanguage(
+          window.monaco!.editor.setModelLanguage(
             model,
             this.getLanguageForFile(filename),
           );
@@ -580,7 +630,7 @@
     // Build the extension-to-language map on first use
     if (!this._extensionToLanguageMap) {
       this._extensionToLanguageMap = new Map();
-      const languages = monaco.languages.getLanguages();
+      const languages = window.monaco!.languages.getLanguages();
 
       for (const language of languages) {
         if (language.extensions) {
@@ -632,8 +682,11 @@
   private originalModel?: monaco.editor.ITextModel;
   private modifiedModel?: monaco.editor.ITextModel;
 
-  private initializeEditor() {
+  private async initializeEditor() {
     try {
+      // Load Monaco dynamically
+      const monaco = await loadMonaco();
+      
       // Disable semantic validation globally for TypeScript/JavaScript if available
       if (monaco.languages && monaco.languages.typescript) {
         monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
@@ -1059,10 +1112,10 @@
       // Always create new models with unique URIs based on timestamp to avoid conflicts
       const timestamp = new Date().getTime();
       // TODO: Could put filename in these URIs; unclear how they're used right now.
-      const originalUri = monaco.Uri.parse(
+      const originalUri = window.monaco!.Uri.parse(
         `file:///original-${timestamp}.${originalLang}`,
       );
-      const modifiedUri = monaco.Uri.parse(
+      const modifiedUri = window.monaco!.Uri.parse(
         `file:///modified-${timestamp}.${modifiedLang}`,
       );
 
@@ -1089,13 +1142,13 @@
       }
 
       // Create new models
-      this.originalModel = monaco.editor.createModel(
+      this.originalModel = window.monaco!.editor.createModel(
         this.originalCode || "",
         originalLang,
         originalUri,
       );
 
-      this.modifiedModel = monaco.editor.createModel(
+      this.modifiedModel = window.monaco!.editor.createModel(
         this.modifiedCode || "",
         modifiedLang,
         modifiedUri,
@@ -1129,7 +1182,7 @@
     }
   }
 
-  updated(changedProperties: Map<string, any>) {
+  async updated(changedProperties: Map<string, any>) {
     // If any relevant properties changed, just update the models
     if (
       changedProperties.has("originalCode") ||
@@ -1151,7 +1204,7 @@
       } else {
         // If the editor isn't initialized yet but we received content,
         // initialize it now
-        this.initializeEditor();
+        await this.initializeEditor();
       }
     }
   }
@@ -1253,9 +1306,9 @@
   }
 
   // Add resize observer to ensure editor resizes when container changes
-  firstUpdated() {
+  async firstUpdated() {
     // Initialize the editor
-    this.initializeEditor();
+    await this.initializeEditor();
 
     // Set up window resize handler to ensure Monaco editor adapts to browser window changes
     this.setupWindowResizeHandler();
@@ -1284,10 +1337,10 @@
       // Initialize the debug global if it doesn't exist
       if (!(window as any).sketchDebug) {
         (window as any).sketchDebug = {
-          monaco: monaco,
+          monaco: window.monaco!,
           editors: [],
           remeasureFonts: () => {
-            monaco.editor.remeasureFonts();
+            window.monaco!.editor.remeasureFonts();
             (window as any).sketchDebug.editors.forEach(
               (editor: any, index: number) => {
                 if (editor && editor.layout) {