Files
gowebbuild/config.go
T
pk 49c88da564 Add config extends and entry point exclusion
Profiles may inherit from another config file (extends: <path>): the
first profile of the referenced file is merged underneath (scalars from
the overlay win, slices concatenate base-first and de-duplicate, chains
resolve recursively, cycles are detected).

esbuild.excludeEntryPoints removes merged entry points by glob (** spans
path separators, * stays within a segment), applied after path
resolution so patterns and entries match in the same shape.

Lets product editions/flavors (e.g. the wmd4x Range Trader Pro edition)
build only their own entry set on top of a shared, merge-clean base
config. Includes unit tests and template docs.
2026-10-11 15:58:56 +02:00

280 lines
7.1 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/evanw/esbuild/pkg/api"
"github.com/trading-peter/gowebbuild/fsutils"
"gopkg.in/yaml.v3"
)
func cfgToESBuildCfg(cfg options) api.BuildOptions {
buildOptions := api.BuildOptions{
EntryPoints: cfg.ESBuild.EntryPoints,
Outdir: cfg.ESBuild.Outdir,
Outfile: cfg.ESBuild.Outfile,
Outbase: cfg.ESBuild.Outbase,
Sourcemap: api.SourceMap(cfg.ESBuild.Sourcemap),
Format: api.Format(cfg.ESBuild.Format),
Splitting: cfg.ESBuild.Splitting,
Platform: api.Platform(cfg.ESBuild.Platform),
Bundle: cfg.ESBuild.Bundle,
Write: cfg.ESBuild.Write,
LogLevel: api.LogLevel(cfg.ESBuild.LogLevel),
}
// Set target if specified, otherwise default to modern ES for decorator support
if cfg.ESBuild.Target > 0 {
buildOptions.Target = api.Target(cfg.ESBuild.Target)
} else {
buildOptions.Target = api.ES2022
}
return buildOptions
}
type options struct {
Extends string `yaml:"extends"` // inherit from the first profile of the referenced config file
ESBuild struct {
EntryPoints []string `yaml:"entryPoints"`
ExcludeEntryPoints []string `yaml:"excludeEntryPoints"`
Outdir string `yaml:"outdir"`
Outbase string `yaml:"outbase"`
Outfile string `yaml:"outfile"`
Sourcemap int `yaml:"sourcemap"`
Format int `yaml:"format"`
Splitting bool `yaml:"splitting"`
Platform int `yaml:"platform"`
Bundle bool `yaml:"bundle"`
Write bool `yaml:"write"`
LogLevel int `yaml:"logLevel"`
Target int `yaml:"target"`
PurgeBeforeBuild bool `yaml:"purgeBeforeBuild"`
} `yaml:"esbuild"`
Watch struct {
Paths []string `yaml:"paths"`
Exclude []string `yaml:"exclude"`
InjectLiveReload string `yaml:"injectLiveReload"`
SkipCSPInject bool `yaml:"skipCSPInject"`
}
Serve struct {
Path string `yaml:"path"`
Port int `yaml:"port"`
} `yaml:"serve"`
Copy []struct {
Src string `yaml:"src"`
Dest string `yaml:"dest"`
} `yaml:"copy"`
Download []struct {
Url string `yaml:"url"`
Dest string `yaml:"dest"`
} `yaml:"download"`
Replace []struct {
Pattern string `yaml:"pattern"`
Search string `yaml:"search"`
Replace string `yaml:"replace"`
} `yaml:"replace"`
ContentSwap []struct {
File string `yaml:"file"`
ReplaceWith string `yaml:"replaceWith"`
} `yaml:"contentSwap"`
Link struct {
From string `yaml:"from"`
To string `yaml:"to"`
} `yaml:"link"`
ProductionBuildOptions struct {
CmdPostBuild string `yaml:"cmdPostBuild"`
} `yaml:"productionBuildOptions"`
NpmProxy struct {
Overrides []NpmProxyOverride
} `yaml:"npm_proxy"`
}
type NpmProxyOverride struct {
Namespace string `yaml:"namespace"`
Upstream string `yaml:"upstream"`
PackageRoot string `yaml:"packageRoot"`
}
func readCfg(cfgPath string) []options {
if filepath.Ext(cfgPath) == ".json" {
jsonOpts := readJsonCfg(cfgPath)
data, err := yaml.Marshal(jsonOpts)
if err != nil {
fmt.Printf("%+v\n", err)
os.Exit(1)
}
yamlPath := strings.TrimSuffix(cfgPath, ".json") + ".yaml"
err = os.WriteFile(yamlPath, data, 0755)
if err != nil {
fmt.Printf("%+v\n", err)
os.Exit(1)
}
cfgPath = yamlPath
}
profiles := readCfgFile(cfgPath, map[string]bool{})
// Path resolution and entry point filtering run once, after all extends
// chains are merged, so de-duplication compares like with like and
// patterns match the same shape as the entries.
for i := range profiles {
processPaths(&profiles[i])
filterEntryPoints(&profiles[i])
}
return profiles
}
// readCfgFile loads a config file and resolves `extends` chains. A profile
// may declare `extends: <path>`; the first profile of the referenced file is
// merged underneath it (scalars: overlay wins when set, slices: concatenated
// base-first and de-duplicated). Chains are resolved recursively; visiting
// guards against inheritance cycles.
func readCfgFile(cfgPath string, visiting map[string]bool) []options {
absPath, err := filepath.Abs(cfgPath)
if err != nil {
fmt.Printf("%+v\n", err)
os.Exit(1)
}
if visiting[absPath] {
fmt.Printf("extends cycle detected at %s\n", absPath)
os.Exit(1)
}
visiting[absPath] = true
defer delete(visiting, absPath)
cfgContent, err := os.ReadFile(cfgPath)
if err != nil {
fmt.Printf("%+v\n", err)
os.Exit(1)
}
optsSetups := []options{}
err = yaml.Unmarshal(cfgContent, &optsSetups)
if err != nil {
opt := options{}
err = yaml.Unmarshal(cfgContent, &opt)
if err != nil {
fmt.Printf("%+v\n", err)
os.Exit(1)
}
optsSetups = append(optsSetups, opt)
}
cfgDir := filepath.Dir(cfgPath)
for i := range optsSetups {
base := strings.TrimSpace(optsSetups[i].Extends)
if base == "" {
continue
}
if !filepath.IsAbs(base) {
base = filepath.Join(cfgDir, base)
}
baseProfiles := readCfgFile(base, visiting)
if len(baseProfiles) == 0 {
fmt.Printf("extends target %s contains no profiles\n", base)
os.Exit(1)
}
optsSetups[i] = mergeOptions(baseProfiles[0], optsSetups[i])
}
return optsSetups
}
func readJsonCfg(cfgPath string) []options {
cfgContent, err := os.ReadFile(cfgPath)
if err != nil {
fmt.Printf("%+v\n", err)
os.Exit(1)
}
optsSetups := []options{}
err = json.Unmarshal(cfgContent, &optsSetups)
if err != nil {
opt := options{}
err = json.Unmarshal(cfgContent, &opt)
if err != nil {
fmt.Printf("%+v\n", err)
os.Exit(1)
}
optsSetups = append(optsSetups, opt)
}
return optsSetups
}
func processPaths(opts *options) {
// ESBuild paths
for i, entry := range opts.ESBuild.EntryPoints {
opts.ESBuild.EntryPoints[i] = fsutils.ResolvePath(entry)
}
for i, entry := range opts.ESBuild.ExcludeEntryPoints {
opts.ESBuild.ExcludeEntryPoints[i] = fsutils.ResolvePath(entry)
}
opts.ESBuild.Outdir = fsutils.ResolvePath(opts.ESBuild.Outdir)
opts.ESBuild.Outfile = fsutils.ResolvePath(opts.ESBuild.Outfile)
// Watch paths
for i, path := range opts.Watch.Paths {
opts.Watch.Paths[i] = fsutils.ResolvePath(path)
}
for i, path := range opts.Watch.Exclude {
opts.Watch.Exclude[i] = fsutils.ResolvePath(path)
}
// opts.Watch.Inject = fsutils.ResolvePath(opts.Watch.Inject)
// Serve path
opts.Serve.Path = fsutils.ResolvePath(opts.Serve.Path)
// Copy paths
for i := range opts.Copy {
opts.Copy[i].Src = fsutils.ResolvePath(opts.Copy[i].Src)
opts.Copy[i].Dest = fsutils.ResolvePath(opts.Copy[i].Dest)
}
// Download paths
for i := range opts.Download {
opts.Download[i].Dest = fsutils.ResolvePath(opts.Download[i].Dest)
}
// Content swap paths
for i := range opts.ContentSwap {
opts.ContentSwap[i].File = fsutils.ResolvePath(opts.ContentSwap[i].File)
opts.ContentSwap[i].ReplaceWith = fsutils.ResolvePath(opts.ContentSwap[i].ReplaceWith)
}
// Link paths
opts.Link.From = fsutils.ResolvePath(opts.Link.From)
opts.Link.To = fsutils.ResolvePath(opts.Link.To)
// Npm proxy paths
for i := range opts.NpmProxy.Overrides {
opts.NpmProxy.Overrides[i].PackageRoot = fsutils.ResolvePath(opts.NpmProxy.Overrides[i].PackageRoot)
}
}