diff --git a/config.go b/config.go index b57d0f3..c4c9c68 100644 --- a/config.go +++ b/config.go @@ -38,20 +38,22 @@ func cfgToESBuildCfg(cfg options) api.BuildOptions { } type options struct { + Extends string `yaml:"extends"` // inherit from the first profile of the referenced config file ESBuild struct { - EntryPoints []string `yaml:"entryPoints"` - 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"` + 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"` @@ -119,6 +121,38 @@ func readCfg(cfgPath string) []options { 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: `; 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 { @@ -140,9 +174,27 @@ func readCfg(cfgPath string) []options { optsSetups = append(optsSetups, opt) } - // Process all paths in each options setup + cfgDir := filepath.Dir(cfgPath) + 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 @@ -178,6 +230,10 @@ func processPaths(opts *options) { 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) diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..726244d --- /dev/null +++ b/config_test.go @@ -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)) + } +} diff --git a/main.go b/main.go index 595e6dc..793dd31 100644 --- a/main.go +++ b/main.go @@ -26,7 +26,7 @@ func main() { app := &cli.App{ Name: "gowebbuild", Usage: "All in one tool to build web frontend projects.", - Version: "7.5.0", + Version: "7.7.0", Authors: []*cli.Author{{ Name: "trading-peter (https://github.com/trading-peter)", }}, diff --git a/merge.go b/merge.go new file mode 100644 index 0000000..9a93b0d --- /dev/null +++ b/merge.go @@ -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() +} diff --git a/templates/tpl.gowebbuild.yaml b/templates/tpl.gowebbuild.yaml index 8709d98..38d53e1 100644 --- a/templates/tpl.gowebbuild.yaml +++ b/templates/tpl.gowebbuild.yaml @@ -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: entryPoints: - 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 sourcemap: 1 format: 3