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.
This commit is contained in:
@@ -38,8 +38,10 @@ func cfgToESBuildCfg(cfg options) api.BuildOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type options struct {
|
type options struct {
|
||||||
|
Extends string `yaml:"extends"` // inherit from the first profile of the referenced config file
|
||||||
ESBuild struct {
|
ESBuild struct {
|
||||||
EntryPoints []string `yaml:"entryPoints"`
|
EntryPoints []string `yaml:"entryPoints"`
|
||||||
|
ExcludeEntryPoints []string `yaml:"excludeEntryPoints"`
|
||||||
Outdir string `yaml:"outdir"`
|
Outdir string `yaml:"outdir"`
|
||||||
Outbase string `yaml:"outbase"`
|
Outbase string `yaml:"outbase"`
|
||||||
Outfile string `yaml:"outfile"`
|
Outfile string `yaml:"outfile"`
|
||||||
@@ -119,6 +121,38 @@ func readCfg(cfgPath string) []options {
|
|||||||
cfgPath = yamlPath
|
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)
|
cfgContent, err := os.ReadFile(cfgPath)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -140,9 +174,27 @@ func readCfg(cfgPath string) []options {
|
|||||||
optsSetups = append(optsSetups, opt)
|
optsSetups = append(optsSetups, opt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process all paths in each options setup
|
cfgDir := filepath.Dir(cfgPath)
|
||||||
|
|
||||||
for i := range optsSetups {
|
for i := range optsSetups {
|
||||||
processPaths(&optsSetups[i])
|
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
|
return optsSetups
|
||||||
@@ -178,6 +230,10 @@ func processPaths(opts *options) {
|
|||||||
for i, entry := range opts.ESBuild.EntryPoints {
|
for i, entry := range opts.ESBuild.EntryPoints {
|
||||||
opts.ESBuild.EntryPoints[i] = fsutils.ResolvePath(entry)
|
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.Outdir = fsutils.ResolvePath(opts.ESBuild.Outdir)
|
||||||
opts.ESBuild.Outfile = fsutils.ResolvePath(opts.ESBuild.Outfile)
|
opts.ESBuild.Outfile = fsutils.ResolvePath(opts.ESBuild.Outfile)
|
||||||
|
|
||||||
|
|||||||
+156
@@ -0,0 +1,156 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMergeOptions(t *testing.T) {
|
||||||
|
base := options{}
|
||||||
|
base.ESBuild.EntryPoints = []string{"a.js", "b.js"}
|
||||||
|
base.ESBuild.LogLevel = 3
|
||||||
|
base.ESBuild.Splitting = true
|
||||||
|
base.Copy = []struct {
|
||||||
|
Src string `yaml:"src"`
|
||||||
|
Dest string `yaml:"dest"`
|
||||||
|
}{{Src: "index.html", Dest: "dist"}}
|
||||||
|
|
||||||
|
overlay := options{}
|
||||||
|
overlay.ESBuild.EntryPoints = []string{"b.js", "c.js"} // b.js duplicates the base
|
||||||
|
overlay.ESBuild.LogLevel = 2 // scalar override
|
||||||
|
overlay.Copy = []struct {
|
||||||
|
Src string `yaml:"src"`
|
||||||
|
Dest string `yaml:"dest"`
|
||||||
|
}{{Src: "index.html", Dest: "dist"}} // exact duplicate of the base entry
|
||||||
|
|
||||||
|
merged := mergeOptions(base, overlay)
|
||||||
|
|
||||||
|
want := []string{"a.js", "b.js", "c.js"}
|
||||||
|
if !reflect.DeepEqual(merged.ESBuild.EntryPoints, want) {
|
||||||
|
t.Fatalf("entryPoints = %v, want %v", merged.ESBuild.EntryPoints, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if merged.ESBuild.LogLevel != 2 {
|
||||||
|
t.Fatalf("logLevel = %d, want overlay value 2", merged.ESBuild.LogLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !merged.ESBuild.Splitting {
|
||||||
|
t.Fatal("splitting = false, want inherited true")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(merged.Copy) != 1 {
|
||||||
|
t.Fatalf("copy = %d entries, want de-duplicated 1", len(merged.Copy))
|
||||||
|
}
|
||||||
|
|
||||||
|
if merged.Extends != "" {
|
||||||
|
t.Fatalf("extends = %q, want cleared after merge", merged.Extends)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPatternToRegexp(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
pattern string
|
||||||
|
path string
|
||||||
|
match bool
|
||||||
|
}{
|
||||||
|
// Production shape: processPaths absolutizes patterns and entries
|
||||||
|
// alike, so matching happens between absolute forward-slash paths.
|
||||||
|
{"/proj/apps/taxes/**", "/proj/apps/taxes/taxes-app.js", true},
|
||||||
|
{"/proj/apps/taxes/**", "/proj/apps/taxes/elements/tax-script.js", true},
|
||||||
|
{"/proj/apps/taxes/**", "/proj/apps/journal/journal-app.js", false},
|
||||||
|
{"/proj/apps/vault/**", "/proj/apps/vaultx/vaultx-app.js", false},
|
||||||
|
{"/proj/src/*.js", "/proj/src/the-app.js", true},
|
||||||
|
{"/proj/src/*.js", "/proj/src/admin/the-admin.js", false},
|
||||||
|
{"/proj/src/the-?.js", "/proj/src/the-a.js", true},
|
||||||
|
{"/proj/src/the-?.js", "/proj/src/the-ab.js", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
re := regexp.MustCompile(patternToRegexp(c.pattern))
|
||||||
|
if re.MatchString(c.path) != c.match {
|
||||||
|
t.Errorf("pattern %q vs %q: got %v, want %v", c.pattern, c.path, re.MatchString(c.path), c.match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterEntryPoints(t *testing.T) {
|
||||||
|
opts := options{}
|
||||||
|
opts.ESBuild.EntryPoints = []string{
|
||||||
|
"/proj/apps/taxes/taxes-app.js",
|
||||||
|
"/proj/apps/chat/chat-app.js",
|
||||||
|
"/proj/the-terminal.js",
|
||||||
|
}
|
||||||
|
opts.ESBuild.ExcludeEntryPoints = []string{"/proj/apps/taxes/**"}
|
||||||
|
|
||||||
|
filterEntryPoints(&opts)
|
||||||
|
|
||||||
|
want := []string{"/proj/apps/chat/chat-app.js", "/proj/the-terminal.js"}
|
||||||
|
if !reflect.DeepEqual(opts.ESBuild.EntryPoints, want) {
|
||||||
|
t.Fatalf("entryPoints = %v, want %v", opts.ESBuild.EntryPoints, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadCfgExtends(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
prevWd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { os.Chdir(prevWd) })
|
||||||
|
|
||||||
|
writeFile := func(name, content string) {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFile("base.yaml", `- esbuild:
|
||||||
|
entryPoints:
|
||||||
|
- ./src/base-one.js
|
||||||
|
- ./src/base-two.js
|
||||||
|
outdir: ./dist
|
||||||
|
logLevel: 3
|
||||||
|
copy:
|
||||||
|
- src: ./index.html
|
||||||
|
dest: ./dist
|
||||||
|
`)
|
||||||
|
|
||||||
|
writeFile("overlay.yaml", `- extends: ./base.yaml
|
||||||
|
esbuild:
|
||||||
|
entryPoints:
|
||||||
|
- ./src/base-two.js
|
||||||
|
- ./src/overlay.js
|
||||||
|
excludeEntryPoints:
|
||||||
|
- ./src/base-one.js
|
||||||
|
`)
|
||||||
|
|
||||||
|
if err := os.Chdir(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
profiles := readCfg("./overlay.yaml")
|
||||||
|
if len(profiles) != 1 {
|
||||||
|
t.Fatalf("profiles = %d, want 1", len(profiles))
|
||||||
|
}
|
||||||
|
|
||||||
|
p := profiles[0]
|
||||||
|
want := []string{
|
||||||
|
filepath.Join(dir, "src/base-two.js"),
|
||||||
|
filepath.Join(dir, "src/overlay.js"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(p.ESBuild.EntryPoints, want) {
|
||||||
|
t.Fatalf("entryPoints = %v, want %v", p.ESBuild.EntryPoints, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.ESBuild.LogLevel != 3 {
|
||||||
|
t.Fatalf("logLevel = %d, want inherited 3", p.ESBuild.LogLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(p.Copy) != 1 {
|
||||||
|
t.Fatalf("copy = %d entries, want inherited 1", len(p.Copy))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ func main() {
|
|||||||
app := &cli.App{
|
app := &cli.App{
|
||||||
Name: "gowebbuild",
|
Name: "gowebbuild",
|
||||||
Usage: "All in one tool to build web frontend projects.",
|
Usage: "All in one tool to build web frontend projects.",
|
||||||
Version: "7.5.0",
|
Version: "7.7.0",
|
||||||
Authors: []*cli.Author{{
|
Authors: []*cli.Author{{
|
||||||
Name: "trading-peter (https://github.com/trading-peter)",
|
Name: "trading-peter (https://github.com/trading-peter)",
|
||||||
}},
|
}},
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mergeOptions layers an extending profile on top of the profile it extends:
|
||||||
|
// scalar fields from the overlay win when set, slices are concatenated
|
||||||
|
// (base first, de-duplicated), nested structs merge recursively.
|
||||||
|
func mergeOptions(base, overlay options) options {
|
||||||
|
merged := mergeValue(reflect.ValueOf(base), reflect.ValueOf(overlay)).Interface().(options)
|
||||||
|
merged.Extends = "" // already resolved; must not re-trigger
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeValue(base, overlay reflect.Value) reflect.Value {
|
||||||
|
switch base.Kind() {
|
||||||
|
case reflect.Struct:
|
||||||
|
out := reflect.New(base.Type()).Elem()
|
||||||
|
for i := range base.NumField() {
|
||||||
|
out.Field(i).Set(mergeValue(base.Field(i), overlay.Field(i)))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case reflect.Slice:
|
||||||
|
if base.Len() == 0 {
|
||||||
|
return overlay
|
||||||
|
}
|
||||||
|
if overlay.Len() == 0 {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return concatDedupe(base, overlay)
|
||||||
|
default:
|
||||||
|
if overlay.IsZero() {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return overlay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func concatDedupe(base, overlay reflect.Value) reflect.Value {
|
||||||
|
out := reflect.MakeSlice(base.Type(), 0, base.Len()+overlay.Len())
|
||||||
|
|
||||||
|
for _, src := range []reflect.Value{base, overlay} {
|
||||||
|
for i := range src.Len() {
|
||||||
|
v := src.Index(i)
|
||||||
|
|
||||||
|
dup := false
|
||||||
|
for j := range out.Len() {
|
||||||
|
if reflect.DeepEqual(out.Index(j).Interface(), v.Interface()) {
|
||||||
|
dup = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !dup {
|
||||||
|
out = reflect.Append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterEntryPoints removes entry points matched by any excludeEntryPoints
|
||||||
|
// pattern. Patterns are matched against the resolved (absolute, forward-slash)
|
||||||
|
// entry paths and support ** (any characters, including path separators),
|
||||||
|
// * (anything except a path separator) and ? (a single character).
|
||||||
|
func filterEntryPoints(opts *options) {
|
||||||
|
if len(opts.ESBuild.ExcludeEntryPoints) == 0 || len(opts.ESBuild.EntryPoints) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
patterns := make([]*regexp.Regexp, 0, len(opts.ESBuild.ExcludeEntryPoints))
|
||||||
|
for _, p := range opts.ESBuild.ExcludeEntryPoints {
|
||||||
|
re, err := regexp.Compile(patternToRegexp(filepath.ToSlash(p)))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("invalid excludeEntryPoints pattern %q: %+v\n", p, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
patterns = append(patterns, re)
|
||||||
|
}
|
||||||
|
|
||||||
|
kept := make([]string, 0, len(opts.ESBuild.EntryPoints))
|
||||||
|
for _, e := range opts.ESBuild.EntryPoints {
|
||||||
|
excluded := false
|
||||||
|
for _, re := range patterns {
|
||||||
|
if re.MatchString(filepath.ToSlash(e)) {
|
||||||
|
excluded = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !excluded {
|
||||||
|
kept = append(kept, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.ESBuild.EntryPoints = kept
|
||||||
|
}
|
||||||
|
|
||||||
|
func patternToRegexp(pattern string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("^")
|
||||||
|
|
||||||
|
// Classic loop: the body advances i to consume the second '*' of '**'.
|
||||||
|
for i := 0; i < len(pattern); i++ {
|
||||||
|
switch c := pattern[i]; c {
|
||||||
|
case '*':
|
||||||
|
if i+1 < len(pattern) && pattern[i+1] == '*' {
|
||||||
|
b.WriteString(".*")
|
||||||
|
i++
|
||||||
|
} else {
|
||||||
|
b.WriteString("[^/]*")
|
||||||
|
}
|
||||||
|
case '?':
|
||||||
|
b.WriteString("[^/]")
|
||||||
|
default:
|
||||||
|
b.WriteString(regexp.QuoteMeta(string(c)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("$")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
|
# A profile may inherit from another config file: the first profile of the
|
||||||
|
# referenced file is merged underneath this one (slices concatenate base-first,
|
||||||
|
# scalars from this profile win when set). Chains resolve recursively.
|
||||||
|
# extends: ./.gowebbuild.base.yaml
|
||||||
- esbuild:
|
- esbuild:
|
||||||
entryPoints:
|
entryPoints:
|
||||||
- frontend/the-app.js
|
- frontend/the-app.js
|
||||||
|
# Entry points matching any of these patterns are removed after the
|
||||||
|
# merge. ** spans path separators, * stays within one segment.
|
||||||
|
# excludeEntryPoints:
|
||||||
|
# - ./frontend/src/apps/experimental/**
|
||||||
outdir: ./frontend-dist
|
outdir: ./frontend-dist
|
||||||
sourcemap: 1
|
sourcemap: 1
|
||||||
format: 3
|
format: 3
|
||||||
|
|||||||
Reference in New Issue
Block a user