bundler.go
171 lines1
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)
}