bundler.go

171 lines
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171
package esbuild

import (
	"cmp"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"

	"github.com/evanw/esbuild/pkg/api"
)

// Bundler compiles frontend components using esbuild.
type Bundler struct {
	config    *Config
	outputDir string
	devMode   bool
}

// NewBundler creates a bundler with the given configuration.
func NewBundler(cfg *Config, outputDir string, devMode bool) *Bundler {
	return &Bundler{
		config:    cfg,
		outputDir: outputDir,
		devMode:   devMode,
	}
}

// Config returns the bundler configuration.
func (b *Bundler) Config() *Config {
	return b.config
}

// Build compiles the components bundle.
// If a build.mjs file exists, it uses Node.js to run it (for plugin support).
// Otherwise, it uses esbuild's Go API directly.
func (b *Bundler) Build() error {
	// Check for Node.js build script (supports plugins like esbuild-plugin-solid)
	if _, err := os.Stat("build.mjs"); err == nil {
		return b.buildWithNode()
	}

	return b.buildWithGoAPI()
}

// buildWithNode runs the build.mjs script using Node.js.
func (b *Bundler) buildWithNode() error {
	// Create output directory
	if err := os.MkdirAll(b.outputDir, 0755); err != nil {
		return fmt.Errorf("creating output dir: %w", err)
	}

	env := os.Environ()
	if b.devMode {
		env = append(env, "NODE_ENV=development")
	} else {
		env = append(env, "NODE_ENV=production")
	}

	cmd := exec.Command("node", "build.mjs")
	cmd.Env = env
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	if err := cmd.Run(); err != nil {
		return fmt.Errorf("node build.mjs failed: %w", err)
	}

	return nil
}

// buildWithGoAPI uses esbuild's Go API directly (no plugin support).
func (b *Bundler) buildWithGoAPI() error {
	// Get entry path (default to components/index.ts)
	entryPath := cmp.Or(b.config.Entry, "components/index.ts")

	// Check if entry file exists
	if _, err := os.Stat(entryPath); os.IsNotExist(err) {
		// No entry found, create empty bundle
		if err := os.MkdirAll(b.outputDir, 0755); err != nil {
			return fmt.Errorf("creating output dir: %w", err)
		}
		return os.WriteFile(filepath.Join(b.outputDir, "components.js"), []byte("// No components\nwindow.__render = () => {};\nwindow.__unmount = () => {};\n"), 0644)
	}

	// Generate virtual entry that wraps user exports
	virtualEntry := generateVirtualEntry(entryPath)

	// Create output directory
	if err := os.MkdirAll(b.outputDir, 0755); err != nil {
		return fmt.Errorf("creating output dir: %w", err)
	}

	// Get absolute working directory for node_modules resolution
	wd, err := os.Getwd()
	if err != nil {
		return fmt.Errorf("getting working directory: %w", err)
	}

	nodeEnv := `"production"`
	if b.devMode {
		nodeEnv = `"development"`
	}

	// Build options
	opts := api.BuildOptions{
		Stdin: &api.StdinOptions{
			Contents:   virtualEntry,
			ResolveDir: wd,
			Sourcefile: "_virtual_entry.ts",
			Loader:     api.LoaderTS,
		},
		Bundle:        true,
		Outfile:       filepath.Join(b.outputDir, "components.js"),
		Format:        api.FormatESModule,
		Target:        api.ES2020,
		Platform:      api.PlatformBrowser,
		Sourcemap:     api.SourceMapLinked,
		Write:         true,
		LogLevel:      api.LogLevelWarning,
		JSX:           api.JSXAutomatic,
		AbsWorkingDir: wd,
		Define: map[string]string{
			"process.env.NODE_ENV": nodeEnv,
		},
	}

	// Minify in production
	if !b.devMode {
		opts.MinifyWhitespace = true
		opts.MinifyIdentifiers = true
		opts.MinifySyntax = true
	}

	// Run esbuild
	result := api.Build(opts)

	// Check for errors
	if len(result.Errors) > 0 {
		var errMsgs []string
		for _, e := range result.Errors {
			errMsgs = append(errMsgs, e.Text)
		}
		return fmt.Errorf("build errors: %s", strings.Join(errMsgs, "; "))
	}

	return nil
}

// generateVirtualEntry creates a virtual entry point that wraps user exports.
// User's entry must export: render, unmount, and named component exports.
func generateVirtualEntry(entryPath string) string {
	// Convert to relative path with ./ prefix for import
	importPath := "./" + entryPath

	return fmt.Sprintf(`// Virtual entry - generated by congo/frontend/esbuild
import * as __all from '%s';

// Assign render/unmount to window globals
(window as any).__render = __all.render;
(window as any).__unmount = __all.unmount;

// Assign all other exports (components) to window
for (const [name, value] of Object.entries(__all)) {
    if (name !== 'render' && name !== 'unmount') {
        (window as any)[name] = value;
    }
}
`, importPath)
}