├── js ├── 0.main.js └── 1.button.js ├── output.js ├── css ├── 1.util.css ├── 2.grid-hmf.css └── 0.reset.css ├── output.css ├── bonchi_test.go ├── go.mod ├── go.sum ├── LICENSE ├── README.md └── bonchi.go /js/0.main.js: -------------------------------------------------------------------------------- 1 | console.log('main') -------------------------------------------------------------------------------- /js/1.button.js: -------------------------------------------------------------------------------- 1 | console.log('button') -------------------------------------------------------------------------------- /output.js: -------------------------------------------------------------------------------- 1 | console.log("main"),console.log("button") -------------------------------------------------------------------------------- /css/1.util.css: -------------------------------------------------------------------------------- 1 | .hidden { 2 | visibility: hidden; 3 | } -------------------------------------------------------------------------------- /css/2.grid-hmf.css: -------------------------------------------------------------------------------- 1 | .grid-hmf { 2 | display:grid; 3 | bonchi-mix:".hidden"; 4 | } -------------------------------------------------------------------------------- /css/0.reset.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | min-height: 100vh; 3 | } 4 | 5 | * { 6 | margin:0; 7 | padding:0; 8 | box-sizing: border-box; 9 | } -------------------------------------------------------------------------------- /output.css: -------------------------------------------------------------------------------- 1 | html,body{min-height:100vh}*{margin:0;padding:0;box-sizing:border-box}.hidden{visibility:hidden}.grid-hmf{display:grid;visibility:hidden} -------------------------------------------------------------------------------- /bonchi_test.go: -------------------------------------------------------------------------------- 1 | package bonchi 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestBonchi(t *testing.T) { 8 | _, err := BundleCss("./css", "./output.css") 9 | if err != nil { 10 | panic(err) 11 | } 12 | _, err = BundleJs("./js", "./output.js") 13 | if err != nil { 14 | panic(err) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Phillip-England/bonchi 2 | 3 | go 1.23.3 4 | 5 | require ( 6 | github.com/tdewolff/minify v2.3.6+incompatible 7 | github.com/tdewolff/minify/v2 v2.22.2 8 | ) 9 | 10 | require ( 11 | github.com/tdewolff/parse v2.3.4+incompatible // indirect 12 | github.com/tdewolff/parse/v2 v2.7.21 // indirect 13 | ) 14 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/tdewolff/minify v2.3.6+incompatible h1:2hw5/9ZvxhWLvBUnHE06gElGYz+Jv9R4Eys0XUzItYo= 2 | github.com/tdewolff/minify v2.3.6+incompatible/go.mod h1:9Ov578KJUmAWpS6NeZwRZyT56Uf6o3Mcz9CEsg8USYs= 3 | github.com/tdewolff/minify/v2 v2.22.2 h1:hmCiEy0XJ5+w9Gytoys3xyULxp46vxufXLwp3m2Nzds= 4 | github.com/tdewolff/minify/v2 v2.22.2/go.mod h1:K/R8TT7aivpcU8QCNUU1UdR6etfnFPr7L11TO/X7shk= 5 | github.com/tdewolff/parse v2.3.4+incompatible h1:x05/cnGwIMf4ceLuDMBOdQ1qGniMoxpP46ghf0Qzh38= 6 | github.com/tdewolff/parse v2.3.4+incompatible/go.mod h1:8oBwCsVmUkgHO8M5iCzSIDtpzXOT0WXX9cWhz+bIzJQ= 7 | github.com/tdewolff/parse/v2 v2.7.21 h1:OCuPFtGr4mXdnfKikQlUb0n654ROJANhBqCk+wioJ/A= 8 | github.com/tdewolff/parse/v2 v2.7.21/go.mod h1:I7TXO37t3aSG9SlPUBefAhgIF8nt7yYUwVGgETIoBcA= 9 | github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE= 10 | github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Phillip England 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bonchi 2 | A minimal web bundler with css preprocessing and js support. 3 | 4 | ## Installation 5 | ```bash 6 | go get github.com/Phillip-England/bonchi 7 | ``` 8 | 9 | ## Hello, World! 10 | ```go 11 | package main 12 | 13 | import ( 14 | "fmt" 15 | 16 | "github.com/Phillip-England/bonchi" 17 | ) 18 | 19 | func main() { 20 | css, err := bonchi.BundleCss("./css", "./static/output.css") 21 | if err != nil { 22 | panic(err) 23 | } 24 | js, err := bonchi.BundleJs("./js", "./static/output.js") 25 | if err != nil { 26 | panic(err) 27 | } 28 | fmt.Println(css, js) 29 | } 30 | ``` 31 | 32 | ## Target Dir 33 | Bonchi is based off of a target directory. All the files in the directory will be bundled and processed for mixin support. 34 | 35 | ## File Names and Ordering 36 | File names can be used to order the way files are organized in the output. For example, `0.reset.css` will be displayed first in the output, followed by `1.header.css` and so on. The same goes for `.js` files as well. 37 | 38 | ## Mixin 39 | Any class can be used within another class using `bonchi-mix:".className1 .className2";` 40 | 41 | ```css 42 | .blue { 43 | background-color:skyblue; 44 | } 45 | 46 | .border { 47 | border:solid black 1px; 48 | } 49 | 50 | button { 51 | bonchi-mix:".blue .border"; 52 | } 53 | ``` -------------------------------------------------------------------------------- /bonchi.go: -------------------------------------------------------------------------------- 1 | package bonchi 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "io/fs" 8 | "os" 9 | "path/filepath" 10 | "regexp" 11 | "sort" 12 | "strconv" 13 | "strings" 14 | 15 | "github.com/tdewolff/minify/v2" 16 | "github.com/tdewolff/minify/v2/css" 17 | "github.com/tdewolff/minify/v2/html" 18 | "github.com/tdewolff/minify/v2/js" 19 | "github.com/tdewolff/minify/v2/json" 20 | "github.com/tdewolff/minify/v2/svg" 21 | "github.com/tdewolff/minify/v2/xml" 22 | ) 23 | 24 | func BundleJs(inputDir string, out string) (string, error) { 25 | var files []string 26 | err := filepath.Walk(inputDir, func(path string, info fs.FileInfo, err error) error { 27 | if err != nil { 28 | return err 29 | } 30 | if info.IsDir() { 31 | return nil 32 | } 33 | if strings.HasSuffix(path, ".js") { 34 | files = append(files, path) 35 | } 36 | return nil 37 | }) 38 | if err != nil { 39 | return "", err 40 | } 41 | sort.Slice(files, func(i, j int) bool { 42 | numI := extractPrefixNumber(files[i]) 43 | numJ := extractPrefixNumber(files[j]) 44 | return numI < numJ 45 | }) 46 | output := "" 47 | for _, file := range files { 48 | jsBytes, err := os.ReadFile(file) 49 | if err != nil { 50 | return output, err 51 | } 52 | output += string(jsBytes) + "\n" 53 | } 54 | 55 | m := prepareMinify() 56 | minifiedJS, err := m.String("application/javascript", output) 57 | if err != nil { 58 | return "", fmt.Errorf("failed to minify JavaScript: %w", err) 59 | } 60 | err = writeToFile(out, minifiedJS, true) 61 | if err != nil { 62 | return minifiedJS, err 63 | } 64 | return minifiedJS, nil 65 | } 66 | 67 | func BundleCss(inputDir string, out string) (string, error) { 68 | var files []string 69 | err := filepath.Walk(inputDir, func(path string, info fs.FileInfo, err error) error { 70 | if err != nil { 71 | return err 72 | } 73 | if info.IsDir() { 74 | return nil 75 | } 76 | if strings.HasSuffix(path, ".css") { 77 | files = append(files, path) 78 | } 79 | return nil 80 | }) 81 | if err != nil { 82 | return "", err 83 | } 84 | sort.Slice(files, func(i, j int) bool { 85 | numI := extractPrefixNumber(files[i]) 86 | numJ := extractPrefixNumber(files[j]) 87 | return numI < numJ 88 | }) 89 | output := "" 90 | for _, file := range files { 91 | cssBytes, err := os.ReadFile(file) 92 | if err != nil { 93 | return output, err 94 | } 95 | output += string(cssBytes) + "\n" 96 | } 97 | output = handleBonchiMix(output) 98 | m := prepareMinify() 99 | minifiedCSS, err := m.String("text/css", output) 100 | if err != nil { 101 | return "", fmt.Errorf("failed to minify CSS: %w", err) 102 | } 103 | err = writeToFile(out, minifiedCSS, true) 104 | if err != nil { 105 | return minifiedCSS, err 106 | } 107 | return minifiedCSS, nil 108 | } 109 | 110 | func extractPrefixNumber(filePath string) int { 111 | base := filepath.Base(filePath) 112 | parts := strings.Split(base, ".") 113 | if len(parts) < 2 { 114 | return 999999 115 | } 116 | num, err := strconv.Atoi(parts[0]) 117 | if err != nil { 118 | return 999999 119 | } 120 | return num 121 | } 122 | 123 | func handleBonchiMix(css string) string { 124 | out := "" 125 | lines := strings.Split(css, "\n") 126 | for _, line := range lines { 127 | if strings.Contains(line, "bonchi-mix") { 128 | parts := strings.Split(line, ":") 129 | if len(parts) != 2 { 130 | continue 131 | } 132 | classes := parts[1] 133 | classes = strings.ReplaceAll(classes, ";", "") 134 | classes = strings.ReplaceAll(classes, "'", "") 135 | classes = strings.ReplaceAll(classes, "\"", "") 136 | classNames := strings.Split(classes, " ") 137 | for _, name := range classNames { 138 | classCss := GetClassCssByName(css, name) 139 | out += classCss 140 | } 141 | continue 142 | } 143 | out += line + "\n" 144 | } 145 | return out 146 | } 147 | 148 | func GetClassCssByName(css string, className string) string { 149 | classContent := "" 150 | isInsideClass := false 151 | lines := strings.Split(css, "\n") 152 | for _, line := range lines { 153 | sqLine := strings.ReplaceAll(line, " ", "") 154 | locator := className + "{" 155 | if strings.Contains(sqLine, "}") { 156 | isInsideClass = false 157 | } 158 | if strings.Contains(sqLine, locator) { 159 | isInsideClass = true 160 | continue 161 | } 162 | if isInsideClass { 163 | classContent += line + "\n" 164 | } 165 | } 166 | return classContent 167 | } 168 | 169 | func writeToFile(path string, content string, overwrite bool) error { 170 | dir := filepath.Dir(path) 171 | if err := os.MkdirAll(dir, os.ModePerm); err != nil { 172 | return fmt.Errorf("failed to create directories: %w", err) 173 | } 174 | if _, err := os.Stat(path); err == nil { 175 | if !overwrite { 176 | return errors.New("file already exists and overwrite is false") 177 | } 178 | } else if !os.IsNotExist(err) { 179 | return fmt.Errorf("failed to check file existence: %w", err) 180 | } 181 | if err := os.WriteFile(path, []byte(content), 0644); err != nil { 182 | return fmt.Errorf("failed to write to file: %w", err) 183 | } 184 | return nil 185 | } 186 | 187 | func prepareMinify() *minify.M { 188 | m := minify.New() 189 | m.AddFunc("text/css", css.Minify) 190 | m.AddFunc("text/html", html.Minify) 191 | m.AddFunc("image/svg+xml", svg.Minify) 192 | m.AddFuncRegexp(regexp.MustCompile("^(application|text)/(x-)?(java|ecma)script$"), js.Minify) 193 | m.AddFuncRegexp(regexp.MustCompile("[/+]json$"), json.Minify) 194 | m.AddFuncRegexp(regexp.MustCompile("[/+]xml$"), xml.Minify) 195 | return m 196 | } 197 | 198 | func minifyStaticFiles(m *minify.M, dirPath string) { 199 | err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { 200 | if err != nil { 201 | return err 202 | } 203 | if info.IsDir() { 204 | return nil 205 | } 206 | ext := filepath.Ext(path) 207 | var mimetype string 208 | switch ext { 209 | case ".css": 210 | mimetype = "text/css" 211 | case ".html": 212 | mimetype = "text/html" 213 | case ".js": 214 | mimetype = "application/javascript" 215 | case ".json": 216 | mimetype = "application/json" 217 | case ".svg": 218 | mimetype = "image/svg+xml" 219 | case ".xml": 220 | mimetype = "text/xml" 221 | default: 222 | return nil 223 | } 224 | f, err := os.Open(path) 225 | if err != nil { 226 | fmt.Printf("Error opening file: %s\n", err) 227 | return err 228 | } 229 | defer f.Close() 230 | fileBytes, err := io.ReadAll(f) 231 | if err != nil { 232 | fmt.Printf("Error reading file: %s\n", err) 233 | return err 234 | } 235 | minifiedBytes, err := m.Bytes(mimetype, fileBytes) 236 | if err != nil { 237 | fmt.Printf("Error minifying file: %s\n", err) 238 | return err 239 | } 240 | err = os.WriteFile(path, minifiedBytes, info.Mode()) // Preserving original file permissions 241 | if err != nil { 242 | fmt.Printf("Error writing minified file: %s\n", err) 243 | return err 244 | } 245 | return nil 246 | }) 247 | if err != nil { 248 | fmt.Printf("Error walking the directory: %s\n", err) 249 | } 250 | } 251 | --------------------------------------------------------------------------------