diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
new file mode 100644
index 0000000..c812ee8
--- /dev/null
+++ b/.github/workflows/ci.yaml
@@ -0,0 +1,88 @@
+name: Squish CI/CD
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+ release:
+ types: [ created ]
+
+jobs:
+
+ test:
+ name: Test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Set up Go
+ uses: actions/setup-go@v3
+ with:
+ go-version: '1.22'
+
+ - name: Build
+ run: go build -v ./...
+
+ - name: Test
+ run: go test -v ./...
+
+ release:
+ name: Release
+ needs: test
+ if: github.event_name == 'release' && github.event.action == 'created'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Set up Go
+ uses: actions/setup-go@v3
+ with:
+ go-version: '1.22'
+
+ - name: Build
+ run: |
+ GOOS=linux GOARCH=amd64 go build -o squish-linux-amd64
+ GOOS=darwin GOARCH=amd64 go build -o squish-darwin-amd64
+ GOOS=windows GOARCH=amd64 go build -o squish-windows-amd64.exe
+
+ - name: Upload Release Assets
+ uses: actions/upload-release-asset@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ upload_url: ${{ github.event.release.upload_url }}
+ asset_path: ./squish-linux-amd64
+ asset_name: squish-linux-amd64
+ asset_content_type: application/octet-stream
+
+ - name: Upload Release Assets (MacOS)
+ uses: actions/upload-release-asset@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ upload_url: ${{ github.event.release.upload_url }}
+ asset_path: ./squish-darwin-amd64
+ asset_name: squish-darwin-amd64
+ asset_content_type: application/octet-stream
+
+ - name: Upload Release Assets (Windows)
+ uses: actions/upload-release-asset@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ upload_url: ${{ github.event.release.upload_url }}
+ asset_path: ./squish-windows-amd64.exe
+ asset_name: squish-windows-amd64.exe
+ asset_content_type: application/octet-stream
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ registry-url: 'https://registry.npmjs.org'
+
+ - name: Publish to npm
+ run: pnpm publish --access public
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d4c845e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+.DS_Store
+node_modules
+/squish
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..13566b8
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..7e97e1a
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/squish.iml b/.idea/squish.iml
new file mode 100644
index 0000000..5e764c4
--- /dev/null
+++ b/.idea/squish.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..96f9eda
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,60 @@
+# Go parameters
+GOCMD=go
+GOBUILD=$(GOCMD) build
+GOCLEAN=$(GOCMD) clean
+GOTEST=$(GOCMD) test
+GOGET=$(GOCMD) get
+GOMOD=$(GOCMD) mod
+BINARY_NAME=squish
+BINARY_UNIX=$(BINARY_NAME)_unix
+
+all: test build
+
+build:
+ $(GOBUILD) -o $(BINARY_NAME) -v ./cmd/squish
+
+test:
+ $(GOTEST) -v ./...
+
+clean:
+ $(GOCLEAN)
+ rm -f $(BINARY_NAME)
+ rm -f $(BINARY_UNIX)
+
+run:
+ $(GOBUILD) -o $(BINARY_NAME) -v ./cmd/squish
+ ./$(BINARY_NAME)
+
+deps:
+ $(GOGET) ./...
+ $(GOMOD) tidy
+
+# Cross compilation
+build-linux:
+ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BINARY_UNIX) -v ./cmd/squish
+
+# Linting
+lint:
+ golangci-lint run
+
+# Format code
+fmt:
+ gofmt -s -w .
+
+# Check if code is formatted
+fmt-check:
+ test -z $$(gofmt -l .)
+
+# Generate mocks for testing
+mocks:
+ mockgen -source=pkg/esbuild/plugin.go -destination=pkg/esbuild/mocks/mock_plugin.go
+
+# Self-bundle (assuming squish can bundle itself)
+self-bundle: build
+ ./$(BINARY_NAME) --src ./cmd/squish --dist ./dist
+
+# Install golangci-lint
+install-linter:
+ curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.42.1
+
+.PHONY: all build test clean run deps build-linux lint fmt fmt-check mocks self-bundle install-linter
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5709827
--- /dev/null
+++ b/README.md
@@ -0,0 +1,103 @@
+# Squish ๐
+
+Squish is a minimalistic package bundler for TypeScript, built with Go. It's designed to be fast, efficient, and incredibly easy to use, with zero configuration required to get started.
+
+![Squish Logo](https://via.placeholder.com/150x150.png?text=Squish)
+
+## Features
+
+- ๐ Lightning-fast bundling
+- ๐ฆ TypeScript support out of the box
+- ๐ง Zero configuration needed to start
+- ๐๏ธ Customizable when you need it
+- ๐ Watch mode for development
+- ๐ Source map generation
+- ๐งน Clean dist directory option
+- ๐ Plugin system for extensibility
+
+## Why Squish?
+
+Squish stands out from other bundlers by prioritizing simplicity and ease of use. With Squish, you can start building your TypeScript project immediately, without the need for complex configuration files or setup processes.
+
+### Zero Configuration
+
+Squish works out of the box with zero configuration. It automatically reads your `package.json` file to determine:
+
+- Entry points
+- Output formats
+- Package type (CommonJS or ES Module)
+- TypeScript configuration (using `tsconfig.json` if present)
+
+This means you can focus on writing code, not configuring your build tool.
+
+## Installation
+
+To install Squish, you need to have Go installed on your system. Then, run:
+
+```bash
+go get -u github.com/foxycorps/squish
+```
+
+## Quick Start
+
+1. Navigate to your TypeScript project directory.
+2. Ensure your `package.json` file is set up with the appropriate `main`, `module`, `types`, and/or `exports` fields.
+3. Run Squish:
+
+```bash
+squish
+```
+
+That's it! Squish will automatically bundle your TypeScript files based on your `package.json` configuration.
+
+## Usage
+
+While Squish works without configuration, you can customize its behavior when needed:
+
+```
+squish [flags]
+```
+
+### Flags
+
+- `--src string`: Source directory (default "./src")
+- `--dist string`: Output directory (default "./dist")
+- `--minify`: Minify output
+- `--watch, -w`: Watch mode
+- `--target stringSlice`: Environments to support (default [es2022])
+- `--tsconfig string`: Custom tsconfig.json file path
+- `--env stringSlice`: Compile-time environment variables (e.g., --env NODE_ENV=production)
+- `--export-condition stringSlice`: Export conditions for resolving dependency export and import maps
+- `--sourcemap string`: Sourcemap generation. Provide 'inline' for inline sourcemap
+- `--clean-dist`: Clean dist before bundling
+
+## Configuration
+
+Squish is designed to work without a dedicated configuration file. Instead, it intelligently reads your project's `package.json` file to determine the entry points and output formats. It supports various package.json fields including `main`, `module`, `types`, and `exports`.
+
+This approach allows you to manage your project configuration in one place, reducing complexity and potential conflicts.
+
+## Plugin System
+
+While Squish aims for simplicity, it also provides a flexible plugin system for when you need to extend its functionality. Built-in plugins include:
+
+- Create Require Plugin
+- Externalize Node Builtins Plugin
+- Patch Binary Plugin
+- Strip Hashbang Plugin
+
+## Contributing
+
+We welcome contributions to Squish! Please see our [Contributing Guide](CONTRIBUTING.md) for more details.
+
+## License
+
+Squish is [MIT licensed](LICENSE).
+
+## Support
+
+If you encounter any issues or have questions, please file an issue on the [GitHub issue tracker](https://github.com/yourusername/squish/issues).
+
+## Acknowledgements
+
+Squish is built with the awesome [esbuild](https://github.com/evanw/esbuild) under the hood. Many thanks to the esbuild team and all our contributors!
\ No newline at end of file
diff --git a/cmd/squish/main.go b/cmd/squish/main.go
new file mode 100644
index 0000000..6a70de7
--- /dev/null
+++ b/cmd/squish/main.go
@@ -0,0 +1,14 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "squish/internal/cli"
+)
+
+func main() {
+ if err := cli.Execute(); err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ os.Exit(1)
+ }
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..d0af765
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,15 @@
+module squish
+
+go 1.22
+
+require (
+ github.com/evanw/esbuild v0.23.0
+ github.com/fsnotify/fsnotify v1.7.0
+ github.com/spf13/cobra v1.8.1
+)
+
+require (
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/spf13/pflag v1.0.5 // indirect
+ golang.org/x/sys v0.4.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..22d6052
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,17 @@
+github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/evanw/esbuild v0.23.0 h1:PLUwTn2pzQfIBRrMKcD3M0g1ALOKIHMDefdFCk7avwM=
+github.com/evanw/esbuild v0.23.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
+github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
+github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
+github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
+golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/install.js b/install.js
new file mode 100644
index 0000000..bef231b
--- /dev/null
+++ b/install.js
@@ -0,0 +1,54 @@
+const { execSync } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+const fetch = require('node-fetch');
+
+const version = require('./package.json').version;
+const binPath = path.join(__dirname, 'bin');
+
+async function getLatestRelease() {
+ const response = await fetch('https://api.github.com/repos/foxycorps/squish/releases/latest');
+ const data = await response.json();
+ return data.tag_name.replace('v', '');
+}
+
+async function install() {
+ const latestVersion = await getLatestRelease();
+ const platform = process.platform;
+ const arch = process.arch === 'x64' ? 'amd64' : process.arch;
+
+ let filename;
+ switch (platform) {
+ case 'linux':
+ filename = `squish-linux-${arch}`;
+ break;
+ case 'darwin':
+ filename = `squish-darwin-${arch}`;
+ break;
+ case 'win32':
+ filename = `squish-windows-${arch}.exe`;
+ break;
+ default:
+ throw new Error(`Unsupported platform: ${platform}`);
+ }
+
+ const url = `https://github.com/foxycorps/squish/releases/download/v${latestVersion}/${filename}`;
+ const outputPath = path.join(binPath, platform === 'win32' ? 'squish.exe' : 'squish');
+
+ if (!fs.existsSync(binPath)) {
+ fs.mkdirSync(binPath, { recursive: true });
+ }
+
+ console.log(`Downloading Squish v${latestVersion} for ${platform} ${arch}...`);
+
+ try {
+ execSync(`curl -L ${url} -o ${outputPath}`);
+ fs.chmodSync(outputPath, 0o755); // Make the file executable
+ console.log('Squish has been installed successfully!');
+ } catch (error) {
+ console.error('Failed to download Squish:', error);
+ process.exit(1);
+ }
+}
+
+install().catch(console.error);
\ No newline at end of file
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
new file mode 100644
index 0000000..9244716
--- /dev/null
+++ b/internal/cli/cli.go
@@ -0,0 +1,102 @@
+package cli
+
+import (
+ "github.com/spf13/cobra"
+ "os"
+ "squish/internal/config"
+ "squish/internal/utils"
+ "squish/internal/watcher"
+ "squish/pkg/esbuild"
+ "strings"
+ "time"
+)
+
+var (
+ srcFlag string
+ distFlag string
+ minify bool
+ watchMode bool
+ target []string
+ tsconfigPath string
+ env []string
+ exportConditions []string
+ sourcemap string
+ cleanDist bool
+)
+
+var rootCmd = &cobra.Command{
+ Use: "squish",
+ Short: "Squish is a minimalistic package bundler for TypeScript",
+ Run: run,
+}
+
+func Execute() error {
+ return rootCmd.Execute()
+}
+
+func init() {
+ rootCmd.Flags().StringVar(&srcFlag, "src", "./src", "Source directory")
+ rootCmd.Flags().StringVar(&distFlag, "dist", "./dist", "Output directory")
+ rootCmd.Flags().BoolVar(&minify, "minify", false, "Minify output")
+ rootCmd.Flags().BoolVarP(&watchMode, "watch", "w", false, "Watch mode")
+ rootCmd.Flags().StringSliceVar(&target, "target", []string{"es2022"}, "Environments to support")
+ rootCmd.Flags().StringVar(&tsconfigPath, "tsconfig", "", "Custom tsconfig.json file path")
+ rootCmd.Flags().StringSliceVar(&env, "env", []string{}, "Compile-time environment variables (e.g., --env NODE_ENV=production)")
+ rootCmd.Flags().StringSliceVar(&exportConditions, "export-condition", []string{}, "Export conditions for resolving dependency export and import maps")
+ rootCmd.Flags().StringVar(&sourcemap, "sourcemap", "", "Sourcemap generation. Provide 'inline' for inline sourcemap")
+ rootCmd.Flags().BoolVar(&cleanDist, "clean-dist", false, "Clean dist before bundling")
+}
+
+func run(cmd *cobra.Command, args []string) {
+ startTime := time.Now()
+ cwd, err := os.Getwd()
+ if err != nil {
+ utils.Log("Error getting current working directory:", err)
+ os.Exit(1)
+ }
+
+ pkg, err := config.ReadPackageJSON(cwd)
+ if err != nil {
+ utils.Log("Error reading package.json:", err)
+ os.Exit(1)
+ }
+
+ utils.Log("Bundling package:", pkg.Name)
+
+ bundlerConfig := &esbuild.BundlerConfig{
+ SrcDir: srcFlag,
+ DistDir: distFlag,
+ Minify: minify,
+ Target: target,
+ TsconfigPath: tsconfigPath,
+ Env: parseEnvFlags(env),
+ ExportConditions: exportConditions,
+ Sourcemap: sourcemap,
+ CleanDist: cleanDist,
+ }
+
+ bundler := esbuild.NewBundler(bundlerConfig, pkg)
+
+ if watchMode {
+ w := watcher.NewWatcher(bundler, srcFlag)
+ if err := w.Watch(); err != nil {
+ utils.Log("Error watching:", err)
+ os.Exit(1)
+ }
+ } else {
+ if err := bundler.Bundle(); err != nil {
+ utils.Log("Error bundling:", err)
+ os.Exit(1)
+ }
+ utils.Log("Bundle created successfully in ", time.Since(startTime))
+ }
+}
+
+func parseEnvFlags(envFlags []string) map[string]string {
+ envMap := make(map[string]string)
+ for _, env := range envFlags {
+ key, value, _ := strings.Cut(env, "=")
+ envMap[key] = value
+ }
+ return envMap
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..19a4e1d
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,222 @@
+package config
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+type PackageType string
+type BinField struct {
+ Single string
+ Multi map[string]string
+}
+
+const (
+ PackageTypeModule PackageType = "module"
+ PackageTypeCommonJS PackageType = "commonjs"
+ PackageTypeTypes PackageType = "types"
+)
+
+type ExportEntry struct {
+ OutputPath string
+ Type PackageType
+ Platform string
+ IsExecutable bool
+ From string
+}
+
+type PackageJSON struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Type PackageType `json:"type"`
+ Main string `json:"main"`
+ Module string `json:"module"`
+ Types string `json:"types"`
+ Bin BinField `json:"bin"`
+ Exports map[string]interface{} `json:"exports"`
+ Dependencies map[string]string `json:"dependencies"`
+ PeerDependencies map[string]string `json:"peerDependencies"`
+ DevDependencies map[string]string `json:"devDependencies"`
+}
+
+func ReadPackageJSON(dir string) (*PackageJSON, error) {
+ path := filepath.Join(dir, "package.json")
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+
+ var pkg PackageJSON
+ if err := json.Unmarshal(data, &pkg); err != nil {
+ return nil, err
+ }
+
+ if pkg.Type == "" {
+ pkg.Type = PackageTypeCommonJS
+ }
+
+ return &pkg, nil
+}
+
+func (p *PackageJSON) GetExportEntries() ([]ExportEntry, error) {
+ entries := []ExportEntry{}
+
+ // Handle main entry point
+ if p.Main != "" {
+ entries = append(entries, ExportEntry{
+ OutputPath: p.Main,
+ Type: getFileType(p.Main, p.Type),
+ From: "main",
+ })
+ }
+
+ // Handle module entry point
+ if p.Module != "" {
+ entries = append(entries, ExportEntry{
+ OutputPath: p.Module,
+ Type: PackageTypeModule,
+ From: "module",
+ })
+ }
+
+ // Handle types entry point
+ if p.Types != "" {
+ entries = append(entries, ExportEntry{
+ OutputPath: p.Types,
+ Type: PackageTypeTypes,
+ From: "types",
+ })
+ }
+
+ // Handle bin entries
+ if p.Bin.Single != "" {
+ entries = append(entries, ExportEntry{
+ OutputPath: p.Bin.Single,
+ Type: getFileType(p.Bin.Single, p.Type),
+ IsExecutable: true,
+ From: "bin",
+ })
+ } else if len(p.Bin.Multi) > 0 {
+ for binNames, binPath := range p.Bin.Multi {
+ entries = append(entries, ExportEntry{
+ OutputPath: binPath,
+ Type: getFileType(binPath, p.Type),
+ IsExecutable: true,
+ From: fmt.Sprintf("bin.%s", binNames),
+ })
+ }
+ }
+
+ // Handle exports
+ if err := p.parseExports(p.Exports, &entries, "exports"); err != nil {
+ return nil, err
+ }
+
+ return entries, nil
+}
+
+func (p *PackageJSON) parseExports(exports interface{}, entries *[]ExportEntry, from string) error {
+ switch e := exports.(type) {
+ case string:
+ if strings.HasPrefix(e, "./") {
+ *entries = append(*entries, ExportEntry{
+ OutputPath: e,
+ Type: getFileType(e, p.Type),
+ From: from,
+ })
+ }
+ case map[string]interface{}:
+ for key, value := range e {
+ newFrom := fmt.Sprintf("%s.%s", from, key)
+ switch key {
+ case "require":
+ if path, ok := value.(string); ok {
+ *entries = append(*entries, ExportEntry{
+ OutputPath: path,
+ Type: PackageTypeCommonJS,
+ From: newFrom,
+ })
+ }
+ case "import":
+ if path, ok := value.(string); ok {
+ *entries = append(*entries, ExportEntry{
+ OutputPath: path,
+ Type: PackageTypeModule,
+ From: newFrom,
+ })
+ }
+ case "types":
+ if path, ok := value.(string); ok {
+ *entries = append(*entries, ExportEntry{
+ OutputPath: path,
+ Type: PackageTypeTypes,
+ From: newFrom,
+ })
+ }
+ case "node":
+ if path, ok := value.(string); ok {
+ *entries = append(*entries, ExportEntry{
+ OutputPath: path,
+ Type: getFileType(path, p.Type),
+ Platform: "node",
+ From: newFrom,
+ })
+ }
+ case "default":
+ if path, ok := value.(string); ok {
+ *entries = append(*entries, ExportEntry{
+ OutputPath: path,
+ Type: getFileType(path, p.Type),
+ From: newFrom,
+ })
+ }
+ default:
+ if err := p.parseExports(value, entries, newFrom); err != nil {
+ return err
+ }
+ }
+ }
+ case []interface{}:
+ for i, value := range e {
+ newFrom := fmt.Sprintf("%s[%d]", from, i)
+ if err := p.parseExports(value, entries, newFrom); err != nil {
+ return err
+ }
+ }
+ default:
+ return fmt.Errorf("unsupported export type for %s", from)
+ }
+ return nil
+}
+
+func (b *BinField) UnmarshalJSON(data []byte) error {
+ var s string
+ if err := json.Unmarshal(data, &s); err == nil {
+ b.Single = s
+ return nil
+ }
+
+ var m map[string]string
+ if err := json.Unmarshal(data, &m); err == nil {
+ b.Multi = m
+ return nil
+ }
+
+ return fmt.Errorf("bin field must be either a string or a map[string]string")
+}
+
+func getFileType(filePath string, defaultType PackageType) PackageType {
+ switch {
+ case strings.HasSuffix(filePath, ".mjs"):
+ return PackageTypeModule
+ case strings.HasSuffix(filePath, ".cjs"):
+ return PackageTypeCommonJS
+ case strings.HasSuffix(filePath, ".d.ts"):
+ return PackageTypeTypes
+ default:
+ return defaultType
+ }
+}
diff --git a/internal/utils/fs.go b/internal/utils/fs.go
new file mode 100644
index 0000000..ec5411b
--- /dev/null
+++ b/internal/utils/fs.go
@@ -0,0 +1,33 @@
+package utils
+
+import (
+ "os"
+ "path/filepath"
+)
+
+func FileExists(path string) bool {
+ _, err := os.Stat(path)
+ return !os.IsNotExist(err)
+}
+
+func CleanDirectory(dir string) error {
+ d, err := os.Open(dir)
+ if err != nil {
+ return err
+ }
+ defer d.Close()
+
+ names, err := d.Readdirnames(-1)
+ if err != nil {
+ return err
+ }
+
+ for _, name := range names {
+ err = os.RemoveAll(filepath.Join(dir, name))
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/internal/utils/log.go b/internal/utils/log.go
new file mode 100644
index 0000000..c1e83af
--- /dev/null
+++ b/internal/utils/log.go
@@ -0,0 +1,11 @@
+package utils
+
+import (
+ "fmt"
+ "time"
+)
+
+func Log(messages ...interface{}) {
+ currentTime := time.Now().Format("15:04:05")
+ fmt.Printf("[%s] %s\n", currentTime, fmt.Sprint(messages...))
+}
diff --git a/internal/utils/path.go b/internal/utils/path.go
new file mode 100644
index 0000000..97d3813
--- /dev/null
+++ b/internal/utils/path.go
@@ -0,0 +1,19 @@
+package utils
+
+import (
+ "path/filepath"
+ "strings"
+)
+
+// NormalizePath ensures the path has a trailing slash if it's a directory
+func NormalizePath(filePath string, isDirectory bool) string {
+ if !filepath.IsAbs(filePath) && !strings.HasPrefix(filePath, ".") {
+ filePath = "./" + filePath
+ }
+
+ if isDirectory && !strings.HasSuffix(filePath, "/") {
+ filePath += "/"
+ }
+
+ return filepath.Clean(filePath)
+}
diff --git a/internal/utils/resolve.go b/internal/utils/resolve.go
new file mode 100644
index 0000000..01a8636
--- /dev/null
+++ b/internal/utils/resolve.go
@@ -0,0 +1,64 @@
+package utils
+
+import (
+ "encoding/json"
+ "fmt"
+ "squish/internal/config"
+ "strings"
+)
+
+var extensionMap = map[string][]string{
+ ".d.ts": {".d.ts", ".d.mts", ".d.cts", ".ts", ".mts", ".cts"},
+ ".d.mts": {".d.mts", ".d.ts", ".d.cts", ".ts", ".mts", ".cts"},
+ ".d.cts": {".d.cts", ".d.ts", ".d.mts", ".ts", ".mts", ".cts"},
+ ".js": {".js", ".ts", ".tsx", ".mts", ".cts"},
+ ".mjs": {".mjs", ".js", ".cjs", ".mts", ".cts", ".ts"},
+ ".cjs": {".cjs", ".js", ".mjs", ".mts", ".cts", ".ts"},
+}
+
+type SourcePathResult struct {
+ Input string `json:"input"`
+ SrcExtension string `json:"srcExtension"`
+ DistExtension string `json:"distExtension"`
+}
+
+func GetSourcePath(exportEntry config.ExportEntry, source, dist string) (*SourcePathResult, error) {
+ sourcePathUnresolved := source + exportEntry.OutputPath[len(dist):]
+
+ for distExtension, sourceExts := range extensionMap {
+ if strings.HasSuffix(exportEntry.OutputPath, distExtension) {
+ sourcePath, err := tryExtensions(
+ sourcePathUnresolved[:len(sourcePathUnresolved)-len(distExtension)],
+ sourceExts,
+ )
+ if err == nil {
+ return &SourcePathResult{
+ Input: sourcePath.path,
+ SrcExtension: sourcePath.extension,
+ DistExtension: distExtension,
+ }, nil
+ }
+ }
+ }
+
+ outputPathJSON, _ := json.Marshal(exportEntry.OutputPath)
+ return nil, fmt.Errorf("could not find matching source file for export path %s", string(outputPathJSON))
+}
+
+type sourcePath struct {
+ path string
+ extension string
+}
+
+func tryExtensions(pathWithoutExtension string, extensions []string) (*sourcePath, error) {
+ for _, extension := range extensions {
+ pathWithExtension := pathWithoutExtension + extension
+ if FileExists(pathWithExtension) {
+ return &sourcePath{
+ path: pathWithExtension,
+ extension: extension,
+ }, nil
+ }
+ }
+ return nil, fmt.Errorf("no matching file found")
+}
diff --git a/internal/utils/tsc.go b/internal/utils/tsc.go
new file mode 100644
index 0000000..90318b9
--- /dev/null
+++ b/internal/utils/tsc.go
@@ -0,0 +1,34 @@
+package utils
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+)
+
+func RunTSC(srcDir, outDir, tsconfigPath string) error {
+ args := []string{
+ "tsc",
+ "--declaration",
+ "--emitDeclarationOnly",
+ "--outDir", outDir,
+ }
+
+ if tsconfigPath != "" {
+ args = append(args, "--project", tsconfigPath)
+ } else {
+ args = append(args, srcDir)
+ }
+
+ cmd := exec.Command("npx", args...)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+
+ Log("Running tsc command:", cmd.String())
+ err := cmd.Run()
+ if err != nil {
+ return fmt.Errorf("tsc command failed: %w", err)
+ }
+
+ return nil
+}
diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go
new file mode 100644
index 0000000..49e3426
--- /dev/null
+++ b/internal/watcher/watcher.go
@@ -0,0 +1,70 @@
+package watcher
+
+import (
+ "github.com/fsnotify/fsnotify"
+ "os"
+ "path/filepath"
+ "squish/internal/utils"
+
+ "squish/pkg/esbuild"
+)
+
+type Watcher struct {
+ bundler *esbuild.Bundler
+ srcDir string
+}
+
+func NewWatcher(bundler *esbuild.Bundler, srcDir string) *Watcher {
+ return &Watcher{
+ bundler: bundler,
+ srcDir: srcDir,
+ }
+}
+
+func (w *Watcher) Watch() error {
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ return err
+ }
+ defer watcher.Close()
+
+ done := make(chan bool)
+ go func() {
+ for {
+ select {
+ case event, ok := <-watcher.Events:
+ if !ok {
+ return
+ }
+ if event.Op&fsnotify.Write == fsnotify.Write {
+ utils.Log("Modified file:", event.Name)
+ if err := w.bundler.Bundle(); err != nil {
+ utils.Log("Error bundling:", err)
+ }
+ }
+ case err, ok := <-watcher.Errors:
+ if !ok {
+ return
+ }
+ utils.Log("Error:", err)
+ }
+ }
+ }()
+
+ err = filepath.Walk(w.srcDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if info.IsDir() {
+ return watcher.Add(path)
+ }
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+
+ utils.Log("Watching for changes in", w.srcDir)
+ <-done
+ return nil
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..05e21fa
--- /dev/null
+++ b/main.go
@@ -0,0 +1,24 @@
+package main
+
+import (
+ "fmt"
+)
+
+//TIP To run your code, right-click the code and select Run. Alternatively, click
+// the icon in the gutter and select the Run menu item from here.
+
+func main() {
+ //TIP Press when your caret is at the underlined or highlighted text
+ // to see how GoLand suggests fixing it.
+ s := "gopher"
+ fmt.Println("Hello and welcome, %s!", s)
+
+ for i := 1; i <= 5; i++ {
+ //TIP Press to start debugging your code. We have set one breakpoint
+ // for you, but you can always add more by pressing .
+ fmt.Println("i =", 100/i)
+ }
+}
+
+//TIP See GoLand help at jetbrains.com/help/go/.
+// Also, you can try interactive lessons for GoLand by selecting 'Help | Learn IDE Features' from the main menu.
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..8443f75
--- /dev/null
+++ b/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "@foxycorps/squish",
+ "version": "0.1.0",
+ "description": "A minimalistic package bundler for TypeScript",
+ "bin": {
+ "squish": "./bin/squish"
+ },
+ "scripts": {
+ "postinstall": "node install.js"
+ },
+ "files": [
+ "bin",
+ "install.js"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/foxycorps/squish.git"
+ },
+ "keywords": [
+ "typescript",
+ "bundler",
+ "esbuild",
+ "zero-config"
+ ],
+ "author": "FoxyCorps",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/foxycorps/squish/issues"
+ },
+ "homepage": "https://github.com/foxycorps/squish#readme",
+ "devDependencies": {
+ "node-fetch": "^3.2.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+}
\ No newline at end of file
diff --git a/pkg/esbuild/create-require.go b/pkg/esbuild/create-require.go
new file mode 100644
index 0000000..33c3c83
--- /dev/null
+++ b/pkg/esbuild/create-require.go
@@ -0,0 +1,63 @@
+package esbuild
+
+import (
+ "github.com/evanw/esbuild/pkg/api"
+)
+
+func CreateRequirePlugin() Plugin {
+ const virtualModuleName = "pkgroll:create-require"
+ const isEsmVariableName = "IS_ESM"
+
+ return NewPluginBuilder("create-require").
+ Setup(func(build api.PluginBuild) {
+ build.OnResolve(api.OnResolveOptions{Filter: "^" + virtualModuleName + "$"}, func(args api.OnResolveArgs) (api.OnResolveResult, error) {
+ return api.OnResolveResult{
+ Path: virtualModuleName,
+ Namespace: "create-require",
+ }, nil
+ })
+
+ build.OnLoad(api.OnLoadOptions{Filter: "^" + virtualModuleName + "$", Namespace: "create-require"}, func(args api.OnLoadArgs) (api.OnLoadResult, error) {
+ contents := `
+ import { createRequire } from 'module';
+ export default (
+ ` + isEsmVariableName + `
+ ? /* @__PURE__ */ createRequire(import.meta.url)
+ : require
+ );
+ `
+ return api.OnLoadResult{
+ Contents: &contents,
+ Loader: api.LoaderJS,
+ }, nil
+ })
+ }).
+ Build()
+}
+
+func IsFormatEsmPlugin(isEsm bool) Plugin {
+ const isEsmVariableName = "IS_ESM"
+
+ return NewPluginBuilder("is-format-esm").
+ Setup(func(build api.PluginBuild) {
+ build.OnResolve(api.OnResolveOptions{Filter: ".*"}, func(args api.OnResolveArgs) (api.OnResolveResult, error) {
+ return api.OnResolveResult{}, nil
+ })
+
+ build.OnLoad(api.OnLoadOptions{Filter: ".*"}, func(args api.OnLoadArgs) (api.OnLoadResult, error) {
+ contents := "const " + isEsmVariableName + " = " + boolToString(isEsm) + ";"
+ return api.OnLoadResult{
+ Contents: &contents,
+ Loader: api.LoaderJS,
+ }, nil
+ })
+ }).
+ Build()
+}
+
+func boolToString(b bool) string {
+ if b {
+ return "true"
+ }
+ return "false"
+}
diff --git a/pkg/esbuild/esbuild.go b/pkg/esbuild/esbuild.go
new file mode 100644
index 0000000..84c5f87
--- /dev/null
+++ b/pkg/esbuild/esbuild.go
@@ -0,0 +1,204 @@
+package esbuild
+
+import (
+ "fmt"
+ "github.com/evanw/esbuild/pkg/api"
+ "os"
+ "path/filepath"
+ "squish/internal/config"
+ "squish/internal/utils"
+)
+
+type BundlerConfig struct {
+ SrcDir string
+ DistDir string
+ Minify bool
+ Target []string
+ TsconfigPath string
+ Env map[string]string
+ ExportConditions []string
+ Sourcemap string
+ CleanDist bool
+}
+
+type Bundler struct {
+ config *BundlerConfig
+ pkg *config.PackageJSON
+}
+
+func NewBundler(config *BundlerConfig, pkg *config.PackageJSON) *Bundler {
+ return &Bundler{
+ config: config,
+ pkg: pkg,
+ }
+}
+
+func (b *Bundler) Bundle() error {
+ if b.config.CleanDist {
+ if err := utils.CleanDirectory(b.config.DistDir); err != nil {
+ return fmt.Errorf("failed to clean dist directory: %w", err)
+ }
+ }
+
+ entries, err := b.pkg.GetExportEntries()
+ if err != nil {
+ return err
+ }
+
+ // Generate TypeScript declaration files
+ //if err := utils.RunTSC(b.config.SrcDir, b.config.DistDir, b.config.TsconfigPath); err != nil {
+ // return err
+ //}
+
+ for _, entry := range entries {
+ sourcePath, err := utils.GetSourcePath(entry, b.config.SrcDir, b.config.DistDir)
+ if err != nil {
+ return fmt.Errorf("error resolving source path: %w", err)
+ }
+
+ if err := b.bundleEntry(sourcePath, entry); err != nil {
+ return fmt.Errorf("failed to bundle entry: %s, %w", entry.OutputPath, err)
+ }
+ }
+
+ return nil
+}
+
+func (b *Bundler) bundleEntry(sourcePath *utils.SourcePathResult, entry config.ExportEntry) error {
+
+ // Ensure the output directory exists
+ if err := os.MkdirAll(filepath.Dir(entry.OutputPath), 0755); err != nil {
+ return fmt.Errorf("failed to create output directory: %w", err)
+ }
+
+ //isEsm := b.getFormat(entry.Type) == api.FormatESModule
+
+ plugins := []api.Plugin{
+ createEsbuildPlugin(CreateRequirePlugin()),
+ //createEsbuildPlugin(IsFormatEsmPlugin(isEsm)),
+ createEsbuildPlugin(ExternalizeNodeBuiltinsPlugin(b.config.Target)),
+ //createEsbuildPlugin(StripHashbangPlugin()),
+ }
+
+ if entry.IsExecutable {
+ plugins = append(plugins, createEsbuildPlugin(PatchBinaryPlugin([]string{entry.OutputPath})))
+ }
+
+ buildOptions := api.BuildOptions{
+ EntryPoints: []string{sourcePath.Input},
+ Outfile: entry.OutputPath,
+ Bundle: true,
+ Write: true,
+ Format: b.getFormat(entry.Type),
+ Target: b.getEsbuildTarget(),
+ Platform: api.PlatformNode,
+ External: b.getExternalDependencies(),
+ Define: b.getDefine(),
+ Sourcemap: b.getSourcemap(),
+ MinifyWhitespace: b.config.Minify,
+ MinifyIdentifiers: b.config.Minify,
+ MinifySyntax: b.config.Minify,
+ Plugins: plugins,
+ }
+
+ if b.config.TsconfigPath != "" {
+ buildOptions.Tsconfig = b.config.TsconfigPath
+ }
+
+ if len(b.config.ExportConditions) > 0 {
+ buildOptions.Conditions = b.config.ExportConditions
+ }
+
+ result := api.Build(buildOptions)
+
+ if len(result.Errors) > 0 {
+ return fmt.Errorf("build failed for %s: %v", entry.OutputPath, result.Errors)
+ }
+
+ return nil
+}
+
+func (b *Bundler) getFormat(packageType config.PackageType) api.Format {
+ switch packageType {
+ case config.PackageTypeModule:
+ return api.FormatESModule
+ case config.PackageTypeCommonJS:
+ return api.FormatCommonJS
+ default:
+ return api.FormatESModule
+ }
+}
+
+func (b *Bundler) getEsbuildTarget() api.Target {
+ targets := make([]api.Target, 0, len(b.config.Target))
+ for _, t := range b.config.Target {
+ switch t {
+ case "es2022":
+ targets = append(targets, api.ES2022)
+ case "es2021":
+ targets = append(targets, api.ES2021)
+ case "es2020":
+ targets = append(targets, api.ES2020)
+ // Add more cases as needed
+ default:
+ targets = append(targets, api.ES2022) // Default to ES2022
+ }
+ }
+ return targets[0] // esbuild only accepts a single target, so we use the first one
+}
+
+func (b *Bundler) getSourcemap() api.SourceMap {
+ switch b.config.Sourcemap {
+ case "inline":
+ return api.SourceMapInline
+ case "":
+ return api.SourceMapNone
+ default:
+ return api.SourceMapLinked
+ }
+}
+
+func (b *Bundler) getDefine() map[string]string {
+ define := make(map[string]string)
+ for key, value := range b.config.Env {
+ define[fmt.Sprintf("process.env.%s", key)] = fmt.Sprintf("\"%s\"", value)
+ }
+ return define
+}
+
+func (b *Bundler) getExternalDependencies() []string {
+ externals := make([]string, 0)
+ for dep := range b.pkg.Dependencies {
+ externals = append(externals, dep)
+ }
+ for dep := range b.pkg.PeerDependencies {
+ externals = append(externals, dep)
+ }
+ for dep := range b.pkg.DevDependencies {
+ externals = append(externals, dep)
+ }
+ return externals
+}
+
+func createEsbuildPlugin(p Plugin) api.Plugin {
+ return api.Plugin{
+ Name: p.Hooks().Name,
+ Setup: func(build api.PluginBuild) {
+ if p.Hooks().Setup != nil {
+ p.Hooks().Setup(build)
+ }
+ if p.Hooks().OnStart != nil {
+ build.OnStart(p.Hooks().OnStart)
+ }
+ if p.Hooks().OnEnd != nil {
+ build.OnEnd(p.Hooks().OnEnd)
+ }
+ if p.Hooks().OnResolve != nil {
+ build.OnResolve(api.OnResolveOptions{Filter: ".*"}, p.Hooks().OnResolve)
+ }
+ if p.Hooks().OnLoad != nil {
+ build.OnLoad(api.OnLoadOptions{Filter: ".*"}, p.Hooks().OnLoad)
+ }
+ },
+ }
+}
diff --git a/pkg/esbuild/externalize-node-builtins.go b/pkg/esbuild/externalize-node-builtins.go
new file mode 100644
index 0000000..cd5d123
--- /dev/null
+++ b/pkg/esbuild/externalize-node-builtins.go
@@ -0,0 +1,56 @@
+package esbuild
+
+import (
+ "github.com/evanw/esbuild/pkg/api"
+ "strconv"
+ "strings"
+)
+
+var nodeBuiltins = []string{
+ "assert", "buffer", "child_process", "cluster", "crypto", "dgram", "dns", "domain", "events", "fs", "http", "https", "net",
+ "os", "path", "punycode", "querystring", "readline", "stream", "string_decoder", "tls", "tty", "url", "util", "v8", "vm", "zlib",
+}
+
+func ExternalizeNodeBuiltinsPlugin(target []string) Plugin {
+ stripNodeProtocol := shouldStripNodeProtocol(target)
+
+ return NewPluginBuilder("externalize-node-builtins").
+ Setup(func(build api.PluginBuild) {
+ build.OnResolve(api.OnResolveOptions{Filter: ".*"}, func(args api.OnResolveArgs) (api.OnResolveResult, error) {
+ if strings.HasPrefix(args.Path, "node:") {
+ if stripNodeProtocol {
+ return api.OnResolveResult{
+ Path: strings.TrimPrefix(args.Path, "node:"),
+ External: true,
+ }, nil
+ }
+ return api.OnResolveResult{External: true}, nil
+ }
+
+ for _, builtin := range nodeBuiltins {
+ if args.Path == builtin {
+ return api.OnResolveResult{External: true}, nil
+ }
+ }
+
+ return api.OnResolveResult{}, nil
+ })
+ }).
+ Build()
+}
+
+func shouldStripNodeProtocol(target []string) bool {
+ for _, t := range target {
+ if strings.HasPrefix(t, "node") {
+ version := strings.TrimPrefix(t, "node")
+ parts := strings.Split(version, ".")
+ major, _ := strconv.Atoi(parts[0])
+ minor, _ := strconv.Atoi(parts[1])
+
+ if (major == 12 && minor >= 20) || major >= 14 {
+ return false
+ }
+ }
+ }
+ return true
+}
diff --git a/pkg/esbuild/patch-binary.go b/pkg/esbuild/patch-binary.go
new file mode 100644
index 0000000..2710ac2
--- /dev/null
+++ b/pkg/esbuild/patch-binary.go
@@ -0,0 +1,35 @@
+package esbuild
+
+import (
+ "github.com/evanw/esbuild/pkg/api"
+ "os"
+ "path/filepath"
+)
+
+func PatchBinaryPlugin(executablePaths []string) Plugin {
+ return NewPluginBuilder("patch-binary").
+ Setup(func(build api.PluginBuild) {
+ build.OnEnd(func(result *api.BuildResult) (api.OnEndResult, error) {
+ for _, outputFile := range result.OutputFiles {
+ if isExecutable(outputFile.Path, executablePaths) {
+ content := "#!/usr/bin/env node\n" + string(outputFile.Contents)
+ err := os.WriteFile(outputFile.Path, []byte(content), 0755)
+ if err != nil {
+ return api.OnEndResult{}, err
+ }
+ }
+ }
+ return api.OnEndResult{}, nil
+ })
+ }).
+ Build()
+}
+
+func isExecutable(path string, executablePaths []string) bool {
+ for _, execPath := range executablePaths {
+ if filepath.Base(path) == filepath.Base(execPath) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/pkg/esbuild/plugin.go b/pkg/esbuild/plugin.go
new file mode 100644
index 0000000..fe77e29
--- /dev/null
+++ b/pkg/esbuild/plugin.go
@@ -0,0 +1,65 @@
+package esbuild
+
+import (
+ "github.com/evanw/esbuild/pkg/api"
+)
+
+type PluginHooks struct {
+ Name string
+ Setup func(build api.PluginBuild)
+ OnStart func() (api.OnStartResult, error)
+ OnEnd func(result *api.BuildResult) (api.OnEndResult, error)
+ OnResolve func(args api.OnResolveArgs) (api.OnResolveResult, error)
+ OnLoad func(args api.OnLoadArgs) (api.OnLoadResult, error)
+}
+
+type Plugin interface {
+ Hooks() PluginHooks
+}
+
+type PluginBuilder struct {
+ hooks PluginHooks
+}
+
+func NewPluginBuilder(name string) *PluginBuilder {
+ return &PluginBuilder{
+ hooks: PluginHooks{Name: name},
+ }
+}
+
+func (pb *PluginBuilder) Setup(fn func(build api.PluginBuild)) *PluginBuilder {
+ pb.hooks.Setup = fn
+ return pb
+}
+
+func (pb *PluginBuilder) OnStart(fn func() (api.OnStartResult, error)) *PluginBuilder {
+ pb.hooks.OnStart = fn
+ return pb
+}
+
+func (pb *PluginBuilder) OnEnd(fn func(result *api.BuildResult) (api.OnEndResult, error)) *PluginBuilder {
+ pb.hooks.OnEnd = fn
+ return pb
+}
+
+func (pb *PluginBuilder) OnResolve(fn func(args api.OnResolveArgs) (api.OnResolveResult, error)) *PluginBuilder {
+ pb.hooks.OnResolve = fn
+ return pb
+}
+
+func (pb *PluginBuilder) OnLoad(fn func(args api.OnLoadArgs) (api.OnLoadResult, error)) *PluginBuilder {
+ pb.hooks.OnLoad = fn
+ return pb
+}
+
+func (pb *PluginBuilder) Build() Plugin {
+ return &pluginImpl{hooks: pb.hooks}
+}
+
+type pluginImpl struct {
+ hooks PluginHooks
+}
+
+func (p *pluginImpl) Hooks() PluginHooks {
+ return p.hooks
+}
diff --git a/pkg/esbuild/strip-hashbang.go b/pkg/esbuild/strip-hashbang.go
new file mode 100644
index 0000000..d44101d
--- /dev/null
+++ b/pkg/esbuild/strip-hashbang.go
@@ -0,0 +1,29 @@
+package esbuild
+
+import (
+ "github.com/evanw/esbuild/pkg/api"
+ "os"
+ "regexp"
+)
+
+var hashbangPattern = regexp.MustCompile(`^#!.*`)
+
+func StripHashbangPlugin() Plugin {
+ return NewPluginBuilder("strip-hashbang").
+ Setup(func(build api.PluginBuild) {
+ build.OnLoad(api.OnLoadOptions{Filter: ".*"}, func(args api.OnLoadArgs) (api.OnLoadResult, error) {
+ contents, err := os.ReadFile(args.Path)
+ if err != nil {
+ return api.OnLoadResult{}, err
+ }
+
+ strippedContents := string(hashbangPattern.ReplaceAll(contents, []byte{}))
+
+ return api.OnLoadResult{
+ Contents: &strippedContents,
+ Loader: api.LoaderJS,
+ }, nil
+ })
+ }).
+ Build()
+}