readysite / pkg / frontend / esbuild / bundler.go
4.4 KB
bundler.go
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)
	}

	// 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": fmt.Sprintf(`"%s"`, cmp.Or(map[bool]string{true: "development", false: "production"}[b.devMode], "production")),
		},
	}

	// 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 readysite/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)
}
← Back