From b7f18f9e2aacc0196bc07d2cf1c500a158c296c5 Mon Sep 17 00:00:00 2001 From: Lukas Bachschwell Date: Wed, 28 Jan 2026 09:22:04 +0100 Subject: [PATCH] Add go version of helper tool Signed-off-by: Lukas Bachschwell --- tools/go.mod | 7 + tools/go.sum | 7 + tools/prepare_static_ui_sources.go | 359 +++++++++++++++++++++++++++++ tools/prepare_static_ui_sources.py | 2 + 4 files changed, 375 insertions(+) create mode 100644 tools/go.mod create mode 100644 tools/go.sum create mode 100644 tools/prepare_static_ui_sources.go diff --git a/tools/go.mod b/tools/go.mod new file mode 100644 index 0000000..48ce14d --- /dev/null +++ b/tools/go.mod @@ -0,0 +1,7 @@ +module github.com/me-no-dev/ESPUItools + +go 1.21 + +require github.com/tdewolff/minify/v2 v2.21.2 + +require github.com/tdewolff/parse/v2 v2.7.19 // indirect diff --git a/tools/go.sum b/tools/go.sum new file mode 100644 index 0000000..efdafc6 --- /dev/null +++ b/tools/go.sum @@ -0,0 +1,7 @@ +github.com/tdewolff/minify/v2 v2.21.2 h1:VfTvmGVtBYhMTlUAeHtXM7XOsW0JT/6uMwUPPqgUs9k= +github.com/tdewolff/minify/v2 v2.21.2/go.mod h1:Olje3eHdBnrMjINKffDsil/3NV98Iv7MhWf7556WQVg= +github.com/tdewolff/parse/v2 v2.7.19 h1:7Ljh26yj+gdLFEq/7q9LT4SYyKtwQX4ocNrj45UCePg= +github.com/tdewolff/parse/v2 v2.7.19/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA= +github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= +github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo= +github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= diff --git a/tools/prepare_static_ui_sources.go b/tools/prepare_static_ui_sources.go new file mode 100644 index 0000000..735e29c --- /dev/null +++ b/tools/prepare_static_ui_sources.go @@ -0,0 +1,359 @@ +// Package main provides a tool to prepare ESPUI header files by minifying +// and gzipping HTML, JS, and CSS source files. +// +// Usage: +// +// go run prepare_static_ui_sources.go -a +// go run prepare_static_ui_sources.go -s ../data -t ../src +// +// Or build and run: +// +// go build -o prepare_static_ui_sources prepare_static_ui_sources.go +// ./prepare_static_ui_sources -a +package main + +import ( + "bytes" + "compress/gzip" + "flag" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/tdewolff/minify/v2" + "github.com/tdewolff/minify/v2/css" + "github.com/tdewolff/minify/v2/html" + "github.com/tdewolff/minify/v2/js" +) + +const targetTemplate = `const char %s[] PROGMEM = R"=====( +%s +)====="; + +const uint8_t %s_GZIP[%d] PROGMEM = { %s }; +` + +type context struct { + infile string + outfile string + outdir string + outfilename string + minifile string + constant string + fileType string + name string + dir string + minidata string + gzipdata string + gziplen int +} + +func main() { + auto := flag.Bool("a", false, "Automatically find all source files in data/ and write C header files to src/") + flag.BoolVar(auto, "auto", false, "Automatically find all source files in data/ and write C header files to src/") + flag.BoolVar(auto, "all", false, "Automatically find all source files in data/ and write C header files to src/") + + sources := flag.String("s", "", "Sources directory containing CSS or JS files OR one specific file to minify") + flag.StringVar(sources, "source", "", "Sources directory containing CSS or JS files OR one specific file to minify") + flag.StringVar(sources, "sources", "", "Sources directory containing CSS or JS files OR one specific file to minify") + + target := flag.String("t", "", "Target directory containing C header files OR one C header file") + flag.StringVar(target, "target", "", "Target directory containing C header files OR one C header file") + + storeMini := flag.Bool("storemini", true, "Store intermediate minified files next to the originals") + noStoreMini := flag.Bool("nostoremini", false, "Do not store intermediate minified files") + flag.BoolVar(noStoreMini, "m", false, "Do not store intermediate minified files") + + flag.Parse() + + // Handle nostoremini flag + if *noStoreMini { + *storeMini = false + } + + if !*auto && (*sources == "" || *target == "") { + fmt.Println("ERROR: You need to specify either --auto/-a or both --source/-s and --target/-t") + fmt.Println() + flag.Usage() + os.Exit(1) + } + + // Get the directory where this script is located + execPath, err := os.Executable() + if err != nil { + // Fallback to current working directory + execPath, _ = os.Getwd() + execPath = filepath.Join(execPath, "tools", "dummy") + } + scriptDir := filepath.Dir(execPath) + + // For "go run", use the current working directory approach + if strings.Contains(execPath, "go-build") { + cwd, _ := os.Getwd() + scriptDir = cwd + } + + // Set default paths if auto mode + if *sources == "" { + *sources = filepath.Join(scriptDir, "..", "data") + } + if *target == "" { + *target = filepath.Join(scriptDir, "..", "src") + } + + // Resolve to absolute paths + *sources, _ = filepath.Abs(*sources) + *target, _ = filepath.Abs(*target) + + if err := checkArgs(*sources, *target); err != nil { + fmt.Printf("ERROR: %v\n", err) + fmt.Println("Aborting.") + os.Exit(1) + } + + info, err := os.Stat(*sources) + if err != nil { + fmt.Printf("ERROR: Cannot stat source: %v\n", err) + os.Exit(1) + } + + if info.IsDir() { + fmt.Printf("Source %s is a directory, searching for files recursively...\n", *sources) + if err := processDir(*sources, *target, *storeMini); err != nil { + fmt.Printf("ERROR: %v\n", err) + os.Exit(1) + } + } else { + fmt.Printf("Source %s is a file, will process one file only.\n", *sources) + if err := processFile(*sources, *target, *storeMini); err != nil { + fmt.Printf("ERROR: %v\n", err) + os.Exit(1) + } + } +} + +func checkArgs(sources, target string) error { + if _, err := os.Stat(sources); os.IsNotExist(err) { + return fmt.Errorf("source %s does not exist", sources) + } + + targetParent := filepath.Dir(target) + if info, err := os.Stat(targetParent); err != nil || !info.IsDir() { + return fmt.Errorf("parent directory of target %s does not exist", target) + } + + sourceInfo, _ := os.Stat(sources) + targetInfo, targetErr := os.Stat(target) + + if sourceInfo.IsDir() && (targetErr != nil || !targetInfo.IsDir()) { + return fmt.Errorf("source %s is a directory, target %s is not", sources, target) + } + + return nil +} + +func getContext(infile, outfile string) (*context, error) { + infile, err := filepath.Abs(infile) + if err != nil { + return nil, err + } + + // Extract directory, name, and type + dir := filepath.Base(filepath.Dir(infile)) + base := filepath.Base(infile) + ext := filepath.Ext(base) + name := strings.TrimSuffix(base, ext) + + // Remove any .min suffix from name for constant generation + name = strings.TrimSuffix(name, ".min") + + fileType := strings.TrimPrefix(strings.ToLower(ext), ".") + + // If directory name matches the file type, go up one level + if strings.ToLower(dir) == fileType { + dir = filepath.Base(filepath.Dir(filepath.Dir(infile))) + } + + // Normalize htm to html + if fileType == "htm" { + fileType = "html" + } + + indir := filepath.Dir(infile) + + c := &context{ + infile: infile, + fileType: fileType, + name: name, + dir: dir, + } + + // Determine output file + info, err := os.Stat(outfile) + if err == nil && info.IsDir() { + c.outdir, _ = filepath.Abs(outfile) + c.outfilename = fmt.Sprintf("%s%s%s.h", dir, capitalize(name), strings.ToUpper(fileType)) + c.outfile = filepath.Join(c.outdir, c.outfilename) + } else { + c.outfile, _ = filepath.Abs(outfile) + c.outdir = filepath.Dir(c.outfile) + c.outfilename = filepath.Base(c.outfile) + } + + // Determine minified file path + if strings.Contains(infile, ".min.") { + c.minifile = infile + } else { + // Replace .ext with .min.ext + c.minifile = filepath.Join(indir, strings.TrimSuffix(base, ext)+".min"+ext) + } + + // Generate constant name + c.constant = fmt.Sprintf("%s_%s", strings.ToUpper(fileType), strings.ToUpper(name)) + + return c, nil +} + +func capitalize(s string) string { + if len(s) == 0 { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +func performGzip(c *context) error { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + + if _, err := gz.Write([]byte(c.minidata)); err != nil { + return err + } + if err := gz.Close(); err != nil { + return err + } + + compressed := buf.Bytes() + c.gziplen = len(compressed) + + // Convert bytes to comma-separated string + parts := make([]string, len(compressed)) + for i, b := range compressed { + parts[i] = fmt.Sprintf("%d", b) + } + c.gzipdata = strings.Join(parts, ",") + + fmt.Printf(" GZIP data length: %d\n", c.gziplen) + return nil +} + +func performMinify(c *context) error { + data, err := os.ReadFile(c.infile) + if err != nil { + return err + } + + m := minify.New() + m.AddFunc("text/css", css.Minify) + m.AddFunc("text/html", html.Minify) + m.AddFunc("application/javascript", js.Minify) + m.AddFunc("text/javascript", js.Minify) + + var mediaType string + switch c.fileType { + case "css": + mediaType = "text/css" + case "js": + mediaType = "application/javascript" + case "html", "htm": + mediaType = "text/html" + default: + mediaType = "text/html" + } + + fmt.Printf(" Using %s minifier\n", c.fileType) + + minified, err := m.String(mediaType, string(data)) + if err != nil { + return fmt.Errorf("minification failed: %w", err) + } + + c.minidata = minified + return performGzip(c) +} + +func processFile(infile, outdir string, storeMini bool) error { + fmt.Printf("Processing file %s\n", infile) + + c, err := getContext(infile, outdir) + if err != nil { + return err + } + + if err := performMinify(c); err != nil { + return err + } + + if storeMini { + if c.infile == c.minifile { + fmt.Println(" Original file is already minified, refusing to overwrite it") + } else { + fmt.Printf(" Writing minified file %s\n", c.minifile) + if err := os.WriteFile(c.minifile, []byte(c.minidata), 0644); err != nil { + return err + } + } + } + + fmt.Printf(" Using C constant names %s and %s_GZIP\n", c.constant, c.constant) + fmt.Printf(" Writing C header file %s\n", c.outfile) + + output := fmt.Sprintf(targetTemplate, c.constant, c.minidata, c.constant, c.gziplen, c.gzipdata) + return os.WriteFile(c.outfile, []byte(output), 0644) +} + +func processDir(sourceDir, outDir string, storeMini bool) error { + pattern := regexp.MustCompile(`(?i)\.(css|js|htm|html)$`) + processed := make(map[string]bool) + + err := filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + return nil + } + + if !pattern.MatchString(path) { + return nil + } + + // Skip if already processed + if processed[path] { + return nil + } + + // Check if this is a .min. file + if strings.Contains(filepath.Base(path), ".min.") { + // Only process .min. files if the non-minified version doesn't exist + nonMinPath := strings.Replace(path, ".min.", ".", 1) + if _, err := os.Stat(nonMinPath); err == nil { + // Non-minified version exists, skip this .min. file + return nil + } + } else { + // Mark corresponding .min. file as processed to avoid duplicate processing + ext := filepath.Ext(path) + minPath := strings.TrimSuffix(path, ext) + ".min" + ext + processed[minPath] = true + } + + processed[path] = true + return processFile(path, outDir, storeMini) + }) + + return err +} diff --git a/tools/prepare_static_ui_sources.py b/tools/prepare_static_ui_sources.py index 7a3eb14..fe6783f 100755 --- a/tools/prepare_static_ui_sources.py +++ b/tools/prepare_static_ui_sources.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 +# script is kept for legacy reasons, please use go version instead! + from jsmin import jsmin as jsminify try: from htmlmin import minify as htmlminify