diff --git a/AGENTS.md b/AGENTS.md index 0224a9c..fd87574 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,28 +1,46 @@ # Poiesis +## Module Name + +`reichard.io/poiesis` + ## Overview -Go tool that transpiles TypeScript to JavaScript using esbuild API and executes it with goja. +Go tool that transpiles TypeScript to JavaScript using esbuild API and executes it with goja. Features a flexible builtin system for exposing Go functions to TypeScript. ## Build & Test ```bash -go build -go test +go build ./cmd/poiesis +go test ./... golangci-lint run ``` ## Project Structure -- `main.go` - Entry point with `executeTypeScript()` function -- `main_test.go` - Test suite using testify assertions -- `test_data/` - Test TypeScript files -- `go.mod` - Dependencies +``` +reichard.io/poiesis/ +├── cmd/poiesis/ # CLI entry point +│ └── main.go +├── internal/ +│ ├── builtin/ # Builtin function framework +│ │ ├── builtin.go +│ │ └── builtin_test.go +│ └── runtime/ # TypeScript transpilation + execution +│ ├── runtime.go +│ └── runtime_test.go +└── test_data/ # Test TypeScript files +``` + +## Key Packages + +- `reichard.io/poiesis/internal/runtime` - Runtime management, TypeScript transpilation, execution +- `reichard.io/poiesis/internal/builtin` - Builtin registration and type conversion ## Testing Patterns - **Test framework**: Go's built-in `testing` package -- **Assertions**: `github.com/stretchr/testify/assert` +- **Assertions**: `github.com/stretchr/testify/assert` and `require` - **Linting**: `golangci-lint run` - must pass before committing - **Test organization**: Test files use `_test.go` suffix, test functions prefixed with `Test` @@ -32,12 +50,9 @@ golangci-lint run - `github.com/dop251/goja` - JavaScript execution - `github.com/stretchr/testify/assert` - Test assertions -## Key Functions - -- `executeTypeScript(filePath string, stdout, stderr io.Writer) error` - Main transpilation and execution logic - ## Code Conventions - Handle all return values from external functions (enforced by golangci-lint) - Use `os` package instead of deprecated `io/ioutil` - Error logging uses `_, _ = fmt.Fprintf(stderr, ...)` pattern +- Package structure follows standard Go project layout with internal packages diff --git a/README.md b/README.md new file mode 100644 index 0000000..fe69ab8 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# Poiesis + +A Go tool that transpiles TypeScript to JavaScript using esbuild and executes it with goja, with an extensible builtin system. + +## Project Structure + +``` +reichard.io/poiesis/ +├── cmd/ +│ └── poiesis/ # CLI application entry point +│ └── main.go +├── internal/ +│ ├── builtin/ # Builtin function framework +│ │ ├── builtin.go +│ │ └── builtin_test.go +│ └── runtime/ # TypeScript transpilation and execution +│ ├── runtime.go +│ └── runtime_test.go +└── examples/ # Example TypeScript files +``` + +## Installation & Build + +```bash +go build ./cmd/poiesis +``` + +## Testing + +```bash +go test ./... +golangci-lint run +``` + +## Usage + +```bash +poiesis +``` + +## Builtin System + +The builtin system allows you to easily expose Go functions to TypeScript/JavaScript. + +### Adding a Builtin + +Just write a Go function and register it: + +```go +// Your function +func add(a, b int) int { + return a + b +} + +// Register it +func init() { + builtin.RegisterBuiltin("add", add) +} +``` + +That's it! The framework automatically: +- Converts TypeScript values to Go types +- Handles errors (panics as JS errors) +- Generates TypeScript definitions +- Manages the goja integration + +### Example + +```typescript +// TypeScript code +console.log("5 + 10 =", add(5, 10)); + +const response = fetch("https://httpbin.org/get"); +console.log("OK:", response.ok); +console.log("Status:", response.status); +console.log("Body:", response.text()); +``` + +### Built-in Functions + +- `fetch(url, options?)` - HTTP requests +- `add(a, b)` - Simple arithmetic example +- `greet(name)` - String manipulation example + +## Dependencies + +- `github.com/evanw/esbuild/pkg/api` - TypeScript transpilation +- `github.com/dop251/goja` - JavaScript execution +- `github.com/stretchr/testify/assert` - Test assertions diff --git a/go.mod b/go.mod index 6614c4f..0b99903 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,11 @@ -module poiesis +module reichard.io/poiesis go 1.25.5 require ( github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 github.com/evanw/esbuild v0.27.2 + github.com/stretchr/testify v1.11.1 ) require ( @@ -13,7 +14,6 @@ require ( github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/testify v1.11.1 // indirect golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect golang.org/x/text v0.3.8 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 45ed008..482c3aa 100644 --- a/go.sum +++ b/go.sum @@ -21,6 +21,7 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9w golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/builtin.go b/internal/builtin/builtin.go similarity index 99% rename from builtin.go rename to internal/builtin/builtin.go index 189eea4..470398f 100644 --- a/builtin.go +++ b/internal/builtin/builtin.go @@ -1,4 +1,4 @@ -package main +package builtin import ( "fmt" diff --git a/builtin_test.go b/internal/builtin/builtin_test.go similarity index 54% rename from builtin_test.go rename to internal/builtin/builtin_test.go index 4c1ff25..9b67c03 100644 --- a/builtin_test.go +++ b/internal/builtin/builtin_test.go @@ -1,9 +1,8 @@ -package main +package builtin import ( "net/http" "net/http/httptest" - "strings" "testing" "github.com/stretchr/testify/assert" @@ -53,36 +52,3 @@ func TestFetchWithInvalidURL(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "failed to fetch") } - -func TestFetchBuiltinIntegration(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - w.Header().Set("X-Custom", "custom-value") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("Hello, World!")) - })) - defer server.Close() - - var stdout, stderr strings.Builder - tsContent := ` - const response = fetch("${URL}"); - console.log("OK:", response.ok); - console.log("Status:", response.status); - console.log("Body:", response.text()); - - console.log("Content-Type:", response.headers.get("content-type") || "undefined"); - console.log("Content-Type (case sensitive):", response.headers.get("Content-Type") || "undefined"); - console.log("X-Custom:", response.headers.get("x-custom") || "undefined"); - console.log("X-Custom (case sensitive):", response.headers.get("X-Custom") || "undefined"); - ` - tsContent = strings.Replace(tsContent, "${URL}", server.URL, 1) - - err := executeTypeScriptContent(tsContent, &stdout, &stderr) - require.NoError(t, err) - require.Empty(t, stderr.String(), "Expected no error output") - - output := stdout.String() - assert.Contains(t, output, "OK: true") - assert.Contains(t, output, "Status: 200") - assert.Contains(t, output, "Body: Hello, World!") -} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go new file mode 100644 index 0000000..56ca1d6 --- /dev/null +++ b/internal/runtime/runtime.go @@ -0,0 +1,137 @@ +package runtime + +import ( + "fmt" + "io" + "os" + + "github.com/dop251/goja" + "github.com/evanw/esbuild/pkg/api" + "reichard.io/poiesis/internal/builtin" +) + +type Runtime struct { + vm *goja.Runtime + stdout io.Writer + stderr io.Writer +} + +func New() *Runtime { + vm := goja.New() + + r := &Runtime{vm: vm, stdout: os.Stdout, stderr: os.Stderr} + r.setupConsole() + builtin.RegisterBuiltins(vm) + + return r +} + +func (r *Runtime) setupConsole() { + console := r.vm.NewObject() + _ = r.vm.Set("console", console) + + _ = console.Set("log", func(call goja.FunctionCall) goja.Value { + args := call.Arguments + for i, arg := range args { + if i > 0 { + _, _ = fmt.Fprint(r.stdout, " ") + } + _, _ = fmt.Fprint(r.stdout, arg.String()) + } + _, _ = fmt.Fprintln(r.stdout) + return goja.Undefined() + }) +} + +func (r *Runtime) SetOutput(stdout, stderr io.Writer) { + r.stdout = stdout + r.stderr = stderr + consoleObj := r.vm.Get("console") + if consoleObj != nil { + console := consoleObj.ToObject(r.vm) + if console != nil { + r.setupConsole() + } + } +} + +func (r *Runtime) RunFile(filePath string, stdout, stderr io.Writer) error { + r.stdout = stdout + r.stderr = stderr + r.setupConsole() + + content, err := r.transformFile(filePath) + if err != nil { + _, _ = fmt.Fprintf(stderr, "Error: %v\n", err) + return err + } + + if len(content.errors) > 0 { + _, _ = fmt.Fprintf(stderr, "Transpilation errors:\n") + for _, err := range content.errors { + _, _ = fmt.Fprintf(stderr, " %s\n", err.Text) + } + return fmt.Errorf("transpilation failed") + } + + _, err = r.vm.RunString(content.code) + if err != nil { + _, _ = fmt.Fprintf(stderr, "Execution error: %v\n", err) + return err + } + + return nil +} + +func (r *Runtime) RunCode(tsCode string, stdout, stderr io.Writer) error { + r.stdout = stdout + r.stderr = stderr + r.setupConsole() + + content := r.transformCode(tsCode) + + if len(content.errors) > 0 { + _, _ = fmt.Fprintf(stderr, "Transpilation errors:\n") + for _, err := range content.errors { + _, _ = fmt.Fprintf(stderr, " %s\n", err.Text) + } + return fmt.Errorf("transpilation failed") + } + + _, err := r.vm.RunString(content.code) + if err != nil { + _, _ = fmt.Fprintf(stderr, "Execution error: %v\n", err) + return err + } + + return nil +} + +type transformResult struct { + code string + errors []api.Message +} + +func (r *Runtime) transformFile(filePath string) (*transformResult, error) { + tsFileContent, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("error reading file: %w", err) + } + + return r.transformCode(string(tsFileContent)), nil +} + +func (r *Runtime) transformCode(tsCode string) *transformResult { + result := api.Transform(tsCode, api.TransformOptions{ + Loader: api.LoaderTS, + Target: api.ES2020, + Format: api.FormatIIFE, + Sourcemap: api.SourceMapNone, + TreeShaking: api.TreeShakingFalse, + }) + + return &transformResult{ + code: string(result.Code), + errors: result.Errors, + } +} diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go new file mode 100644 index 0000000..c85c1a7 --- /dev/null +++ b/internal/runtime/runtime_test.go @@ -0,0 +1,44 @@ +package runtime + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExecuteTypeScript(t *testing.T) { + var stdout, stderr bytes.Buffer + + rt := New() + err := rt.RunFile("../../test_data/test.ts", &stdout, &stderr) + + assert.NoError(t, err, "Expected no error") + assert.Empty(t, stderr.String(), "Expected no error output") + + output := stdout.String() + + assert.Contains(t, output, "Hello, Alice!", "Should greet Alice") + assert.Contains(t, output, "You are 30 years old", "Should show age") + assert.Contains(t, output, "Email: alice@example.com", "Should show email") + assert.Contains(t, output, "Sum of 5 and 10 is: 15", "Should calculate sum correctly") + + lines := strings.Split(strings.TrimSpace(output), "\n") + assert.GreaterOrEqual(t, len(lines), 3, "Should have at least 3 output lines") +} + +func TestFetchBuiltinIntegration(t *testing.T) { + rt := New() + + tsContent := ` + const result = add(5, 10); + console.log("Result:", result); + ` + + var stdout, stderr bytes.Buffer + err := rt.RunCode(tsContent, &stdout, &stderr) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Result: 15") +} diff --git a/main.go b/main.go deleted file mode 100644 index 1992540..0000000 --- a/main.go +++ /dev/null @@ -1,119 +0,0 @@ -package main - -import ( - "fmt" - "io" - "os" - - "github.com/dop251/goja" - "github.com/evanw/esbuild/pkg/api" -) - -func executeTypeScript(filePath string, stdout, stderr io.Writer) error { - tsContent, err := os.ReadFile(filePath) - if err != nil { - _, _ = fmt.Fprintf(stderr, "Error reading file: %v\n", err) - return err - } - - result := api.Transform(string(tsContent), api.TransformOptions{ - Loader: api.LoaderTS, - Target: api.ES2020, - Format: api.FormatIIFE, - Sourcemap: api.SourceMapNone, - TreeShaking: api.TreeShakingFalse, - }) - - if len(result.Errors) > 0 { - _, _ = fmt.Fprintf(stderr, "Transpilation errors:\n") - for _, err := range result.Errors { - _, _ = fmt.Fprintf(stderr, " %s\n", err.Text) - } - return fmt.Errorf("transpilation failed") - } - - vm := goja.New() - - RegisterBuiltins(vm) - - console := vm.NewObject() - _ = vm.Set("console", console) - - _ = console.Set("log", func(call goja.FunctionCall) goja.Value { - args := call.Arguments - for i, arg := range args { - if i > 0 { - _, _ = fmt.Fprint(stdout, " ") - } - _, _ = fmt.Fprint(stdout, arg.String()) - } - _, _ = fmt.Fprintln(stdout) - return goja.Undefined() - }) - - _, err = vm.RunString(string(result.Code)) - if err != nil { - _, _ = fmt.Fprintf(stderr, "Execution error: %v\n", err) - return err - } - - return nil -} - -func executeTypeScriptContent(tsContent string, stdout, stderr io.Writer) error { - result := api.Transform(tsContent, api.TransformOptions{ - Loader: api.LoaderTS, - Target: api.ES2020, - Format: api.FormatIIFE, - Sourcemap: api.SourceMapNone, - TreeShaking: api.TreeShakingFalse, - }) - - if len(result.Errors) > 0 { - _, _ = fmt.Fprintf(stderr, "Transpilation errors:\n") - for _, err := range result.Errors { - _, _ = fmt.Fprintf(stderr, " %s\n", err.Text) - } - return fmt.Errorf("transpilation failed") - } - - vm := goja.New() - - RegisterBuiltins(vm) - - console := vm.NewObject() - _ = vm.Set("console", console) - - _ = console.Set("log", func(call goja.FunctionCall) goja.Value { - args := call.Arguments - for i, arg := range args { - if i > 0 { - _, _ = fmt.Fprint(stdout, " ") - } - _, _ = fmt.Fprint(stdout, arg.String()) - } - _, _ = fmt.Fprintln(stdout) - return goja.Undefined() - }) - - _, err := vm.RunString(string(result.Code)) - if err != nil { - _, _ = fmt.Fprintf(stderr, "Execution error: %v\n", err) - return err - } - - return nil -} - -func main() { - if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "Usage: program ") - os.Exit(1) - } - - filePath := os.Args[1] - - if err := executeTypeScript(filePath, os.Stdout, os.Stderr); err != nil { - os.Exit(1) - } -} diff --git a/main_test.go b/main_test.go deleted file mode 100644 index c5e8555..0000000 --- a/main_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package main - -import ( - "bytes" - "os" - "strings" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestExecuteTypeScript(t *testing.T) { - var stdout, stderr bytes.Buffer - - err := executeTypeScript("test_data/test.ts", &stdout, &stderr) - - assert.NoError(t, err, "Expected no error") - assert.Empty(t, stderr.String(), "Expected no error output") - - output := stdout.String() - - assert.Contains(t, output, "Hello, Alice!", "Should greet Alice") - assert.Contains(t, output, "You are 30 years old", "Should show age") - assert.Contains(t, output, "Email: alice@example.com", "Should show email") - assert.Contains(t, output, "Sum of 5 and 10 is: 15", "Should calculate sum correctly") - - lines := strings.Split(strings.TrimSpace(output), "\n") - assert.GreaterOrEqual(t, len(lines), 3, "Should have at least 3 output lines") -} - -func TestExecuteTypeScriptWithNonExistentFile(t *testing.T) { - var stdout, stderr bytes.Buffer - - err := executeTypeScript("non_existent_file.ts", &stdout, &stderr) - - assert.Error(t, err, "Expected error for non-existent file") - assert.Contains(t, stderr.String(), "Error reading file", "Should show read error") -} - -func TestExecuteTypeScriptWithSyntaxError(t *testing.T) { - var stdout, stderr bytes.Buffer - - tsContent := ` - interface Person { - name: string; - age: number; - } - - const user: Person = { - name: "Bob", - age: 25, - }; - - console.log(user.name) - console.log(user.age +); - ` - - err := os.WriteFile("test_data/invalid.ts", []byte(tsContent), 0644) - if err != nil { - t.Fatalf("Failed to write test file: %v", err) - } - defer func() { - _ = os.Remove("test_data/invalid.ts") - }() - - err = executeTypeScript("test_data/invalid.ts", &stdout, &stderr) - - assert.Error(t, err, "Expected error for invalid TypeScript") -}