commit 91a950e72fb05447bdccfd484f5d0101aa7895c3 Author: Josh Date: Fri Jan 9 00:24:04 2026 +0200 init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f56fe10 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +*.wasm +*.md +.git +.gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e889032 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +jarvis +jarvis.exe +*.wasm +.env +.idea/ +.vscode/ diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..6c8d614 --- /dev/null +++ b/.woodpecker.yml @@ -0,0 +1,14 @@ +when: + branch: main + event: push + +steps: + - name: build + image: docker:27-cli + environment: + - DOCKER_HOST=unix:///var/run/docker.sock + commands: + - docker build -t 10.0.0.3:5000/jarvis:latest . + - docker push 10.0.0.3:5000/jarvis:latest + volumes: + - /var/run/docker.sock:/var/run/docker.sock diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1b406a1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,106 @@ +# Jarvis - WASM compilation + LSP server +# Languages: TypeScript, Go, C# + +FROM golang:1.23-bookworm AS builder + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /jarvis . + +# Runtime image with all toolchains +FROM ubuntu:24.04 +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y \ + curl \ + wget \ + git \ + ca-certificates \ + build-essential \ + libicu-dev \ + && rm -rf /var/lib/apt/lists/* + +# ============================================ +# GO + TinyGo + gopls +# ============================================ +ENV GOLANG_VERSION=1.23.4 +RUN wget -q https://go.dev/dl/go${GOLANG_VERSION}.linux-amd64.tar.gz \ + && tar -C /usr/local -xzf go${GOLANG_VERSION}.linux-amd64.tar.gz \ + && rm go${GOLANG_VERSION}.linux-amd64.tar.gz +ENV PATH="/usr/local/go/bin:/root/go/bin:${PATH}" + +RUN go install golang.org/x/tools/gopls@latest + +ENV TINYGO_VERSION=0.34.0 +RUN wget -q https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb \ + && dpkg -i tinygo_${TINYGO_VERSION}_amd64.deb \ + && rm tinygo_${TINYGO_VERSION}_amd64.deb + +# ============================================ +# TypeScript (extism-js + typescript-language-server) +# ============================================ +ENV BINARYEN_VERSION=120 +RUN wget -q https://github.com/WebAssembly/binaryen/releases/download/version_${BINARYEN_VERSION}/binaryen-version_${BINARYEN_VERSION}-x86_64-linux.tar.gz \ + && tar -xzf binaryen-version_${BINARYEN_VERSION}-x86_64-linux.tar.gz \ + && cp binaryen-version_${BINARYEN_VERSION}/bin/* /usr/local/bin/ \ + && rm -rf binaryen-* + +# extism-js (manual install - script requires sudo) +ENV EXTISM_JS_VERSION=1.5.1 +RUN wget -q https://github.com/extism/js-pdk/releases/download/v${EXTISM_JS_VERSION}/extism-js-x86_64-linux-v${EXTISM_JS_VERSION}.gz \ + && gunzip extism-js-x86_64-linux-v${EXTISM_JS_VERSION}.gz \ + && mv extism-js-x86_64-linux-v${EXTISM_JS_VERSION} /usr/local/bin/extism-js \ + && chmod +x /usr/local/bin/extism-js + +# Node.js for typescript-language-server +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y nodejs \ + && npm install -g typescript typescript-language-server + +# ============================================ +# C# (.NET SDK + OmniSharp) +# ============================================ +RUN wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh \ + && chmod +x dotnet-install.sh \ + && ./dotnet-install.sh --channel 9.0 --install-dir /usr/share/dotnet \ + && rm dotnet-install.sh \ + && ln -s /usr/share/dotnet/dotnet /usr/local/bin/dotnet + +ENV DOTNET_ROOT=/usr/share/dotnet +ENV PATH="${PATH}:/usr/share/dotnet" + +# Install WASI workload for C# WASM compilation +RUN dotnet workload install wasi-experimental + +# OmniSharp for C# LSP +RUN mkdir -p /opt/omnisharp \ + && wget -q https://github.com/OmniSharp/omnisharp-roslyn/releases/download/v1.39.12/omnisharp-linux-x64-net6.0.tar.gz \ + && tar -xzf omnisharp-linux-x64-net6.0.tar.gz -C /opt/omnisharp \ + && rm omnisharp-linux-x64-net6.0.tar.gz \ + && ln -s /opt/omnisharp/OmniSharp /usr/local/bin/omnisharp + +# ============================================ +# Pre-warm Go dependency cache +# ============================================ +RUN mkdir -p /tmp/go-warmup \ + && cd /tmp/go-warmup \ + && go mod init warmup \ + && go get github.com/extism/go-pdk@v1.0.6 \ + && rm -rf /tmp/go-warmup + +# ============================================ +# Jarvis server +# ============================================ +WORKDIR /app +COPY --from=builder /jarvis /app/jarvis +COPY sdk /app/sdk + +ENV PORT=8090 +EXPOSE 8090 + +COPY entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +CMD ["/app/entrypoint.sh"] diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..7f9091d --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,2 @@ +#!/bin/bash +exec /app/jarvis diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..868c8cd --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/nicepkg/jarvis + +go 1.23.0 + +require ( + github.com/extism/go-pdk v1.1.3 + github.com/gorilla/websocket v1.5.3 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b4ca977 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/lsp.go b/lsp.go new file mode 100644 index 0000000..df1be02 --- /dev/null +++ b/lsp.go @@ -0,0 +1,217 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + + "github.com/gorilla/websocket" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +type LSPSession struct { + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + conn *websocket.Conn + mu sync.Mutex +} + +func lspHandler(w http.ResponseWriter, r *http.Request) { + language := r.URL.Query().Get("language") + if language == "" { + language = "typescript" + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("WebSocket upgrade failed: %v", err) + return + } + defer conn.Close() + + session, err := startLanguageServer(language) + if err != nil { + log.Printf("Failed to start language server for %s: %v", language, err) + conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf(`{"error": "Failed to start %s language server"}`, language))) + return + } + session.conn = conn + + defer func() { + session.stdin.Close() + session.cmd.Process.Kill() + session.cmd.Wait() + }() + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + session.readFromServer() + }() + + go func() { + defer wg.Done() + session.writeToServer() + }() + + wg.Wait() +} + +func startLanguageServer(language string) (*LSPSession, error) { + var cmd *exec.Cmd + + switch language { + case "typescript": + workDir := createTypeScriptWorkspace() + cmd = exec.Command("typescript-language-server", "--stdio") + cmd.Dir = workDir + case "go": + workDir := createGoWorkspace() + cmd = exec.Command("gopls", "serve") + cmd.Dir = workDir + default: + return nil, fmt.Errorf("unsupported language: %s", language) + } + + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + return nil, err + } + + return &LSPSession{ + cmd: cmd, + stdin: stdin, + stdout: stdout, + }, nil +} + +func createTypeScriptWorkspace() string { + dir, _ := os.MkdirTemp("", "lsp-ts-*") + + sdkPath := "/app/sdk/writekit.d.ts" + if _, err := os.Stat(sdkPath); err != nil { + sdkPath = "sdk/writekit.d.ts" + } + sdkContent, _ := os.ReadFile(sdkPath) + + os.WriteFile(filepath.Join(dir, "writekit.d.ts"), sdkContent, 0644) + os.WriteFile(filepath.Join(dir, "tsconfig.json"), []byte(`{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "node", + "strict": true, + "noEmit": true + }, + "include": ["*.ts"] +}`), 0644) + + return dir +} + +func createGoWorkspace() string { + dir, _ := os.MkdirTemp("", "lsp-go-*") + + os.WriteFile(filepath.Join(dir, "go.mod"), []byte(`module plugin + +go 1.22 + +require github.com/extism/go-pdk v1.0.6 +`), 0644) + + os.WriteFile(filepath.Join(dir, "main.go"), []byte(`package main + +import "github.com/extism/go-pdk" + +func main() {} + +var _ = pdk.Log +`), 0644) + + return dir +} + +func (s *LSPSession) readFromServer() { + reader := bufio.NewReader(s.stdout) + + for { + header, err := reader.ReadString('\n') + if err != nil { + return + } + + if !strings.HasPrefix(header, "Content-Length:") { + continue + } + + lengthStr := strings.TrimSpace(strings.TrimPrefix(header, "Content-Length:")) + length, err := strconv.Atoi(lengthStr) + if err != nil { + continue + } + + reader.ReadString('\n') + + body := make([]byte, length) + _, err = io.ReadFull(reader, body) + if err != nil { + return + } + + s.mu.Lock() + err = s.conn.WriteMessage(websocket.TextMessage, body) + s.mu.Unlock() + + if err != nil { + return + } + } +} + +func (s *LSPSession) writeToServer() { + for { + _, message, err := s.conn.ReadMessage() + if err != nil { + return + } + + var msg json.RawMessage + if err := json.Unmarshal(message, &msg); err != nil { + continue + } + + lspMessage := fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(message), message) + _, err = s.stdin.Write([]byte(lspMessage)) + if err != nil { + return + } + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..514513b --- /dev/null +++ b/main.go @@ -0,0 +1,228 @@ +package main + +import ( + "bytes" + "encoding/json" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/nicepkg/jarvis/transform" +) + +type CompileRequest struct { + Language string `json:"language"` + Source string `json:"source"` +} + +type CompileResponse struct { + Success bool `json:"success"` + Wasm []byte `json:"wasm,omitempty"` + Size int `json:"size,omitempty"` + TimeMS int64 `json:"time_ms,omitempty"` + Errors []string `json:"errors,omitempty"` +} + +type Language struct { + Value string `json:"value"` + Label string `json:"label"` +} + +var languages = []Language{ + {Value: "typescript", Label: "TypeScript"}, + {Value: "go", Label: "Go"}, +} + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "8090" + } + + http.HandleFunc("/health", healthHandler) + http.HandleFunc("/compile", compileHandler) + http.HandleFunc("/languages", languagesHandler) + http.HandleFunc("/hooks", hooksHandler) + http.HandleFunc("/template", templateHandler) + http.HandleFunc("/sdk", sdkHandler) + http.HandleFunc("/lsp", lspHandler) + + log.Printf("Jarvis listening on :%s", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} + +func healthHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) +} + +func languagesHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(languages) +} + +func hooksHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(hooks) +} + +func templateHandler(w http.ResponseWriter, r *http.Request) { + hook := r.URL.Query().Get("hook") + language := r.URL.Query().Get("language") + + if hook == "" { + hook = "post.published" + } + if language == "" { + language = "typescript" + } + + template := GetTemplate(hook, language) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "template": template, + "hook": hook, + "language": language, + }) +} + +func compileHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req CompileRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, "Invalid request body") + return + } + + start := time.Now() + var resp CompileResponse + + switch req.Language { + case "typescript": + resp = compileTypeScript(req.Source) + case "go": + resp = compileGo(req.Source) + default: + respondError(w, "Unsupported language: "+req.Language) + return + } + + resp.TimeMS = time.Since(start).Milliseconds() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func respondError(w http.ResponseWriter, msg string) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(CompileResponse{Success: false, Errors: []string{msg}}) +} + +func compileTypeScript(source string) CompileResponse { + // Transform source from clean API to Extism-compatible format + transformed, err := transform.TypeScript(source) + if err != nil { + return CompileResponse{Success: false, Errors: []string{"Transform error: " + err.Error()}} + } + + tmpDir, err := os.MkdirTemp("", "jarvis-ts-*") + if err != nil { + return CompileResponse{Success: false, Errors: []string{err.Error()}} + } + defer os.RemoveAll(tmpDir) + + srcFile := filepath.Join(tmpDir, "plugin.ts") + outFile := filepath.Join(tmpDir, "plugin.wasm") + dtsFile := filepath.Join(tmpDir, "index.d.ts") + + if err := os.WriteFile(srcFile, []byte(transformed), 0644); err != nil { + return CompileResponse{Success: false, Errors: []string{err.Error()}} + } + + // Copy the Extism d.ts file - extism-js requires interface declarations + sdkDts, err := os.ReadFile("/app/sdk/extism.d.ts") + if err != nil { + return CompileResponse{Success: false, Errors: []string{"Failed to read SDK d.ts: " + err.Error()}} + } + if err := os.WriteFile(dtsFile, sdkDts, 0644); err != nil { + return CompileResponse{Success: false, Errors: []string{err.Error()}} + } + + cmd := exec.Command("extism-js", srcFile, "-i", dtsFile, "-o", outFile) + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return CompileResponse{Success: false, Errors: []string{stderr.String()}} + } + + wasm, err := os.ReadFile(outFile) + if err != nil { + return CompileResponse{Success: false, Errors: []string{err.Error()}} + } + + return CompileResponse{Success: true, Wasm: wasm, Size: len(wasm)} +} + +func compileGo(source string) CompileResponse { + // Transform source from clean API to Extism-compatible format + transformed, err := transform.Go(source) + if err != nil { + return CompileResponse{Success: false, Errors: []string{"Transform error: " + err.Error()}} + } + + tmpDir, err := os.MkdirTemp("", "jarvis-go-*") + if err != nil { + return CompileResponse{Success: false, Errors: []string{err.Error()}} + } + defer os.RemoveAll(tmpDir) + + srcFile := filepath.Join(tmpDir, "main.go") + outFile := filepath.Join(tmpDir, "plugin.wasm") + + if err := os.WriteFile(srcFile, []byte(transformed), 0644); err != nil { + return CompileResponse{Success: false, Errors: []string{err.Error()}} + } + + if err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte(` +module writekit-plugin + +go 1.22 + +require github.com/extism/go-pdk v1.0.6 +`), 0644); err != nil { + return CompileResponse{Success: false, Errors: []string{err.Error()}} + } + + // Run go mod tidy to fetch dependencies and create go.sum + tidyCmd := exec.Command("go", "mod", "tidy") + tidyCmd.Dir = tmpDir + var tidyStderr bytes.Buffer + tidyCmd.Stderr = &tidyStderr + if err := tidyCmd.Run(); err != nil { + return CompileResponse{Success: false, Errors: []string{"Failed to tidy dependencies: " + tidyStderr.String()}} + } + + cmd := exec.Command("tinygo", "build", "-o", outFile, "-target", "wasi", srcFile) + cmd.Dir = tmpDir + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return CompileResponse{Success: false, Errors: []string{stderr.String()}} + } + + wasm, err := os.ReadFile(outFile) + if err != nil { + return CompileResponse{Success: false, Errors: []string{err.Error()}} + } + + return CompileResponse{Success: true, Wasm: wasm, Size: len(wasm)} +} diff --git a/sdk.go b/sdk.go new file mode 100644 index 0000000..e84e195 --- /dev/null +++ b/sdk.go @@ -0,0 +1,267 @@ +package main + +import ( + "encoding/json" + "net/http" +) + +// SDKFunction describes a function in the SDK +type SDKFunction struct { + Name string `json:"name"` + Signature string `json:"signature"` + InsertText string `json:"insertText"` + Documentation string `json:"documentation"` +} + +// SDKNamespace describes a namespace with functions +type SDKNamespace struct { + Functions []SDKFunction `json:"functions,omitempty"` +} + +// SDKField describes a field in an event type +type SDKField struct { + Name string `json:"name"` + Type string `json:"type"` + Doc string `json:"doc,omitempty"` +} + +// SDKEventType describes an event type with nested fields +type SDKEventType struct { + Fields []SDKField `json:"fields"` +} + +// SDKHookType maps hook names to their event types +type SDKHookTypes map[string]SDKEventType + +// SDKSchema is the complete SDK schema for a language +type SDKSchema struct { + Runner SDKNamespace `json:"Runner"` + Events SDKHookTypes `json:"events"` +} + +// Event type definitions per hook (language-agnostic field names, language-specific in schema) +var hookEventFields = map[string][]SDKField{ + "post.published": { + {Name: "post", Type: "Post", Doc: "The published post"}, + {Name: "author", Type: "Author", Doc: "Post author"}, + {Name: "blog", Type: "Blog", Doc: "Blog info"}, + }, + "post.updated": { + {Name: "post", Type: "Post", Doc: "The updated post"}, + {Name: "author", Type: "Author", Doc: "Post author"}, + {Name: "changes", Type: "Changes", Doc: "What changed"}, + }, + "comment.validate": { + {Name: "content", Type: "string", Doc: "Comment content"}, + {Name: "authorName", Type: "string", Doc: "Author name"}, + {Name: "authorEmail", Type: "string", Doc: "Author email"}, + {Name: "postSlug", Type: "string", Doc: "Post slug"}, + }, + "comment.created": { + {Name: "comment", Type: "Comment", Doc: "The created comment"}, + {Name: "post", Type: "Post", Doc: "The post"}, + }, + "member.subscribed": { + {Name: "member", Type: "Member", Doc: "The subscriber"}, + {Name: "tier", Type: "Tier", Doc: "Subscription tier"}, + }, + "content.render": { + {Name: "html", Type: "string", Doc: "HTML content to transform"}, + {Name: "post", Type: "Post", Doc: "Post metadata"}, + }, + "asset.uploaded": { + {Name: "id", Type: "string", Doc: "Asset ID"}, + {Name: "url", Type: "string", Doc: "Asset URL"}, + {Name: "contentType", Type: "string", Doc: "MIME type"}, + {Name: "size", Type: "int", Doc: "Size in bytes"}, + {Name: "width", Type: "int", Doc: "Image width (if image)"}, + {Name: "height", Type: "int", Doc: "Image height (if image)"}, + }, + "analytics.sync": { + {Name: "period", Type: "Period", Doc: "Time period"}, + {Name: "pageviews", Type: "int", Doc: "Total pageviews"}, + {Name: "visitors", Type: "int", Doc: "Unique visitors"}, + {Name: "topPages", Type: "[]PageView", Doc: "Top pages"}, + }, +} + +// Nested type definitions +var nestedTypes = map[string][]SDKField{ + "Post": { + {Name: "slug", Type: "string"}, + {Name: "title", Type: "string"}, + {Name: "url", Type: "string"}, + {Name: "excerpt", Type: "string"}, + {Name: "publishedAt", Type: "string"}, + {Name: "updatedAt", Type: "string"}, + {Name: "tags", Type: "[]string"}, + {Name: "readingTime", Type: "int"}, + }, + "Author": { + {Name: "name", Type: "string"}, + {Name: "email", Type: "string"}, + {Name: "avatar", Type: "string"}, + }, + "Blog": { + {Name: "name", Type: "string"}, + {Name: "url", Type: "string"}, + }, + "Comment": { + {Name: "id", Type: "string"}, + {Name: "content", Type: "string"}, + {Name: "authorName", Type: "string"}, + {Name: "authorEmail", Type: "string"}, + {Name: "postSlug", Type: "string"}, + {Name: "parentId", Type: "string"}, + {Name: "createdAt", Type: "string"}, + }, + "Member": { + {Name: "email", Type: "string"}, + {Name: "name", Type: "string"}, + {Name: "subscribedAt", Type: "string"}, + }, + "Tier": { + {Name: "name", Type: "string"}, + {Name: "price", Type: "int"}, + }, + "Changes": { + {Name: "title", Type: "TitleChange"}, + {Name: "content", Type: "bool"}, + {Name: "tags", Type: "TagChanges"}, + }, + "TitleChange": { + {Name: "old", Type: "string"}, + {Name: "new", Type: "string"}, + }, + "TagChanges": { + {Name: "added", Type: "[]string"}, + {Name: "removed", Type: "[]string"}, + }, + "Period": { + {Name: "start", Type: "string"}, + {Name: "end", Type: "string"}, + }, + "PageView": { + {Name: "path", Type: "string"}, + {Name: "views", Type: "int"}, + }, +} + +func buildEventTypes(lang string) SDKHookTypes { + events := make(SDKHookTypes) + for hook, fields := range hookEventFields { + // Convert field names for Go (PascalCase) + if lang == "go" { + converted := make([]SDKField, len(fields)) + for i, f := range fields { + converted[i] = SDKField{ + Name: toPascalCase(f.Name), + Type: f.Type, + Doc: f.Doc, + } + } + events[hook] = SDKEventType{Fields: converted} + } else { + events[hook] = SDKEventType{Fields: fields} + } + } + return events +} + +func toPascalCase(s string) string { + if len(s) == 0 { + return s + } + // Simple: just capitalize first letter + return string(s[0]-32) + s[1:] +} + +// SDK schemas for each language +var sdkSchemas = map[string]SDKSchema{ + "typescript": { + Runner: SDKNamespace{ + Functions: []SDKFunction{ + { + Name: "log", + Signature: "log(message: string): void", + InsertText: "log(${1:message})", + Documentation: "Log a message to the plugin console", + }, + { + Name: "httpRequest", + Signature: "httpRequest(options: HttpRequestOptions): HttpResponse", + InsertText: "httpRequest({\n url: ${1:url},\n method: \"${2:POST}\",\n headers: { \"Content-Type\": \"application/json\" },\n body: ${3:body}\n})", + Documentation: "Make an HTTP request to an external service", + }, + }, + }, + Events: buildEventTypes("typescript"), + }, + "go": { + Runner: SDKNamespace{ + Functions: []SDKFunction{ + { + Name: "Log", + Signature: "Log(message string)", + InsertText: "Log(${1:message})", + Documentation: "Log a message to the plugin console", + }, + { + Name: "HttpRequest", + Signature: "HttpRequest(url string, method string, body []byte) (*Response, error)", + InsertText: "HttpRequest(${1:url}, \"${2:POST}\", []byte(${3:body}))", + Documentation: "Make an HTTP request to an external service", + }, + }, + }, + Events: buildEventTypes("go"), + }, +} + +// Also expose nested types +type FullSDKResponse struct { + Runner SDKNamespace `json:"Runner"` + Events SDKHookTypes `json:"events"` + NestedTypes map[string][]SDKField `json:"nestedTypes"` +} + +func sdkHandler(w http.ResponseWriter, r *http.Request) { + language := r.URL.Query().Get("language") + + w.Header().Set("Content-Type", "application/json") + + if language != "" { + if schema, ok := sdkSchemas[language]; ok { + // Convert nested types for the language + convertedNested := make(map[string][]SDKField) + for typeName, fields := range nestedTypes { + if language == "go" { + converted := make([]SDKField, len(fields)) + for i, f := range fields { + converted[i] = SDKField{ + Name: toPascalCase(f.Name), + Type: f.Type, + Doc: f.Doc, + } + } + convertedNested[typeName] = converted + } else { + convertedNested[typeName] = fields + } + } + + response := FullSDKResponse{ + Runner: schema.Runner, + Events: schema.Events, + NestedTypes: convertedNested, + } + json.NewEncoder(w).Encode(response) + return + } + http.Error(w, "Unknown language", http.StatusBadRequest) + return + } + + // Return all schemas (without nested types for brevity) + json.NewEncoder(w).Encode(sdkSchemas) +} diff --git a/sdk/extism.d.ts b/sdk/extism.d.ts new file mode 100644 index 0000000..f6a43d3 --- /dev/null +++ b/sdk/extism.d.ts @@ -0,0 +1,12 @@ +// Minimal Extism PDK declarations for extism-js compiler +declare module "main" { + // Hook functions (snake_case to match transformed exports) + export function on_post_published(): I32; + export function on_post_updated(): I32; + export function on_comment_created(): I32; + export function on_member_subscribed(): I32; + export function on_asset_uploaded(): I32; + export function on_analytics_sync(): I32; + export function validate_comment(): I32; + export function render_content(): I32; +} diff --git a/sdk/writekit.d.ts b/sdk/writekit.d.ts new file mode 100644 index 0000000..3ffb9e9 --- /dev/null +++ b/sdk/writekit.d.ts @@ -0,0 +1,245 @@ +// WriteKit Plugin SDK Type Definitions +// These types are injected into Monaco for autocomplete + +// ============================================================================ +// EXTISM PDK TYPES (required for compilation) +// ============================================================================ + +/** Host functions provided by Extism */ +declare namespace Host { + function inputString(): string; + function inputBytes(): Uint8Array; + function inputJSON(): T; + function outputString(s: string): void; + function outputBytes(bytes: Uint8Array): void; + function outputJSON(obj: T): void; + function log(msg: string): void; +} + +/** Configuration access */ +declare namespace Config { + function get(key: string): string | null; +} + +/** Variable storage */ +declare namespace Var { + function get(key: string): { text(): string } | null; + function set(key: string, value: string): void; +} + +/** HTTP request types */ +interface HttpRequest { + url: string; + method: string; + headers: Record; +} + +declare namespace Http { + function request(req: HttpRequest, body?: Uint8Array): { status: number; body(): Uint8Array }; +} + +// ============================================================================ +// RUNNER SDK (user-facing API) +// ============================================================================ + +/** + * Runner provides all plugin capabilities + */ +declare namespace Runner { + /** Log a message (visible in plugin logs) */ + function log(message: string): void; + + /** Make an HTTP request */ + function httpRequest(options: HttpRequestOptions): HttpResponse; + + /** + * Access your configured secrets + * Secret keys are dynamically typed based on your dashboard configuration + */ + namespace secrets { + // Dynamic: actual secret keys are injected at runtime based on user's secrets + } +} + +interface HttpRequestOptions { + url: string; + method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; + headers?: Record; + body?: string; +} + +interface HttpResponse { + status: number; + headers: Record; + body: string; +} + +// ============================================================================ +// VALIDATION TYPES +// ============================================================================ + +/** Result for validation hooks (comment.validate, etc.) */ +interface ValidationResult { + allowed: boolean; + reason?: string; +} + +// ============================================================================ +// EVENT TYPES +// ============================================================================ + +/** Event fired when a post is published */ +interface PostPublishedEvent { + post: { + slug: string; + title: string; + url: string; + excerpt: string; + publishedAt: string; + tags: string[]; + readingTime: number; + }; + author: { + name: string; + email: string; + avatar?: string; + }; + blog: { + name: string; + url: string; + }; +} + +/** Event fired when a post is updated */ +interface PostUpdatedEvent { + post: { + slug: string; + title: string; + url: string; + excerpt: string; + publishedAt: string; + updatedAt: string; + tags: string[]; + }; + author: { + name: string; + email: string; + }; + changes: { + title?: { old: string; new: string }; + content?: boolean; + tags?: { added: string[]; removed: string[] }; + }; +} + +/** Event fired when a comment is created */ +interface CommentCreatedEvent { + comment: { + id: string; + content: string; + authorName: string; + authorEmail: string; + postSlug: string; + parentId?: string; + createdAt: string; + }; + post: { + slug: string; + title: string; + url: string; + }; +} + +/** Event fired when a member subscribes */ +interface MemberSubscribedEvent { + member: { + email: string; + name?: string; + subscribedAt: string; + }; + tier: { + name: string; + price: number; + }; +} + +/** Event fired when an asset is uploaded */ +interface AssetUploadedEvent { + id: string; + url: string; + contentType: string; + size: number; + width?: number; + height?: number; +} + +/** Event fired when analytics are synced */ +interface AnalyticsSyncEvent { + period: { + start: string; + end: string; + }; + pageviews: number; + visitors: number; + topPages: Array<{ path: string; views: number }>; +} + +// ============================================================================ +// VALIDATION INPUT TYPES +// ============================================================================ + +/** Input for comment validation hook */ +interface CommentInput { + content: string; + authorName: string; + authorEmail: string; + postSlug: string; + parentId?: string; +} + +// ============================================================================ +// TRANSFORM TYPES +// ============================================================================ + +/** Input for content.render transform hook */ +interface ContentRenderInput { + html: string; + post: { + slug: string; + title: string; + tags: string[]; + }; +} + +/** Output for content.render transform hook */ +interface ContentRenderOutput { + html: string; +} + +// ============================================================================ +// HOOK HANDLER TYPES (for documentation) +// ============================================================================ + +/** Handler for post.published event */ +type PostPublishedHandler = (event: PostPublishedEvent, secrets?: any) => void; + +/** Handler for post.updated event */ +type PostUpdatedHandler = (event: PostUpdatedEvent, secrets?: any) => void; + +/** Handler for comment.validate validation */ +type CommentValidateHandler = (input: CommentInput, secrets?: any) => ValidationResult; + +/** Handler for comment.created event */ +type CommentCreatedHandler = (event: CommentCreatedEvent, secrets?: any) => void; + +/** Handler for member.subscribed event */ +type MemberSubscribedHandler = (event: MemberSubscribedEvent, secrets?: any) => void; + +/** Handler for content.render transform */ +type ContentRenderHandler = (input: ContentRenderInput, secrets?: any) => ContentRenderOutput; + +/** Handler for asset.uploaded event */ +type AssetUploadedHandler = (event: AssetUploadedEvent, secrets?: any) => void; + +/** Handler for analytics.sync event */ +type AnalyticsSyncHandler = (event: AnalyticsSyncEvent, secrets?: any) => void; diff --git a/templates.go b/templates.go new file mode 100644 index 0000000..7592c18 --- /dev/null +++ b/templates.go @@ -0,0 +1,423 @@ +package main + +// HookInfo describes a plugin hook +type HookInfo struct { + Name string `json:"name"` + Label string `json:"label"` + Description string `json:"description"` + Pattern string `json:"pattern"` // event, validation, transform + TestData map[string]any `json:"test_data"` +} + +// Available hooks +var hooks = []HookInfo{ + { + Name: "post.published", + Label: "Post Published", + Description: "Triggered when a post is published", + Pattern: "event", + TestData: map[string]any{ + "post": map[string]any{ + "slug": "hello-world", + "title": "Hello World", + "url": "/hello-world", + "excerpt": "This is a test post for plugin development.", + "publishedAt": "2024-01-15T10:30:00Z", + "tags": []string{"test", "development"}, + "readingTime": 3, + }, + "author": map[string]any{ + "name": "Test Author", + "email": "author@example.com", + }, + "blog": map[string]any{ + "name": "Test Blog", + "url": "https://test.writekit.dev", + }, + }, + }, + { + Name: "post.updated", + Label: "Post Updated", + Description: "Triggered when a post is updated", + Pattern: "event", + TestData: map[string]any{ + "post": map[string]any{ + "slug": "hello-world", + "title": "Hello World (Updated)", + "url": "/hello-world", + "excerpt": "This is a test post that was updated.", + "publishedAt": "2024-01-15T10:30:00Z", + "updatedAt": "2024-01-16T14:00:00Z", + "tags": []string{"test", "development", "updated"}, + }, + "author": map[string]any{ + "name": "Test Author", + "email": "author@example.com", + }, + "changes": map[string]any{ + "title": map[string]any{"old": "Hello World", "new": "Hello World (Updated)"}, + "content": true, + "tags": map[string]any{"added": []string{"updated"}, "removed": []string{}}, + }, + }, + }, + { + Name: "comment.validate", + Label: "Comment Validate", + Description: "Validate comments before creation", + Pattern: "validation", + TestData: map[string]any{ + "content": "This is a test comment for moderation.", + "authorName": "Test User", + "authorEmail": "user@example.com", + "postSlug": "hello-world", + }, + }, + { + Name: "comment.created", + Label: "Comment Created", + Description: "Triggered when a comment is created", + Pattern: "event", + TestData: map[string]any{ + "comment": map[string]any{ + "id": "test-comment-123", + "content": "Great post! Thanks for sharing.", + "authorName": "Test User", + "authorEmail": "user@example.com", + "postSlug": "hello-world", + "createdAt": "2024-01-15T12:00:00Z", + }, + "post": map[string]any{ + "slug": "hello-world", + "title": "Hello World", + "url": "/hello-world", + }, + }, + }, + { + Name: "member.subscribed", + Label: "Member Subscribed", + Description: "Triggered when a member subscribes", + Pattern: "event", + TestData: map[string]any{ + "member": map[string]any{ + "email": "subscriber@example.com", + "name": "New Subscriber", + "subscribedAt": "2024-01-15T09:00:00Z", + }, + "tier": map[string]any{ + "name": "Free", + "price": 0, + }, + }, + }, + { + Name: "content.render", + Label: "Content Render", + Description: "Transform rendered HTML", + Pattern: "transform", + TestData: map[string]any{ + "html": "

Hello World

This is test content.

const x = 1;
", + "post": map[string]any{ + "slug": "hello-world", + "title": "Hello World", + "tags": []string{"test"}, + }, + }, + }, + { + Name: "asset.uploaded", + Label: "Asset Uploaded", + Description: "Triggered when an asset is uploaded", + Pattern: "event", + TestData: map[string]any{ + "id": "asset-123", + "url": "https://cdn.example.com/image.webp", + "contentType": "image/webp", + "size": 102400, + "width": 1920, + "height": 1080, + }, + }, + { + Name: "analytics.sync", + Label: "Analytics Sync", + Description: "Sync analytics data periodically", + Pattern: "event", + TestData: map[string]any{ + "period": map[string]any{ + "start": "2024-01-08T00:00:00Z", + "end": "2024-01-15T00:00:00Z", + }, + "pageviews": 1250, + "visitors": 890, + "topPages": []map[string]any{ + {"path": "/hello-world", "views": 450}, + {"path": "/about", "views": 230}, + }, + }, + }, +} + +// Templates organized by hook and language +var templates = map[string]map[string]string{ + "post.published": { + "typescript": `export const onPostPublished = (event: PostPublishedEvent): void => { + Runner.log("Post published: " + event.post.title); + + // Example: Send Slack notification + Runner.httpRequest({ + url: Runner.secrets.SLACK_WEBHOOK, + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: "New post: " + event.post.title + "\n" + event.post.url, + }), + }); +}; +`, + "go": `package main + +func OnPostPublished(event PostPublishedEvent) error { + Runner.Log("Post published: " + event.Post.Title) + + // Example: Send Slack notification + Runner.HttpRequest(Runner.Secrets.SlackWebhook, "POST", []byte("{\"text\":\"New post published\"}")) + + return nil +} + +func main() {} +`, + }, + "post.updated": { + "typescript": `export const onPostUpdated = (event: PostUpdatedEvent): void => { + Runner.log("Post updated: " + event.post.title); + + // Example: Sync to external CMS + if (event.changes.content) { + Runner.httpRequest({ + url: "https://api.example.com/sync", + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + slug: event.post.slug, + title: event.post.title, + url: event.post.url, + }), + }); + } +}; +`, + "go": `package main + +func OnPostUpdated(event PostUpdatedEvent) error { + Runner.Log("Post updated: " + event.Post.Title) + return nil +} + +func main() {} +`, + }, + "comment.validate": { + "typescript": `export const validateComment = (input: CommentInput): ValidationResult => { + // Example: Simple spam check + const spamWords = ["buy now", "click here", "free money"]; + const content = input.content.toLowerCase(); + + for (const word of spamWords) { + if (content.includes(word)) { + return { allowed: false, reason: "Comment flagged as spam" }; + } + } + + // Example: Check minimum length + if (input.content.length < 10) { + return { allowed: false, reason: "Comment too short" }; + } + + return { allowed: true }; +}; +`, + "go": `package main + +import "strings" + +func ValidateComment(input CommentInput) (ValidationResult, error) { + // Example: Simple spam check + spamWords := []string{"buy now", "click here", "free money"} + content := strings.ToLower(input.Content) + + for _, word := range spamWords { + if strings.Contains(content, word) { + return ValidationResult{Allowed: false, Reason: "Spam detected"}, nil + } + } + + return ValidationResult{Allowed: true}, nil +} + +func main() {} +`, + }, + "comment.created": { + "typescript": `export const onCommentCreated = (event: CommentCreatedEvent): void => { + Runner.log("New comment on: " + event.post.title); + + // Example: Send notification + Runner.httpRequest({ + url: Runner.secrets.SLACK_WEBHOOK, + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: "New comment by " + event.comment.authorName + " on \"" + event.post.title + "\"", + }), + }); +}; +`, + "go": `package main + +func OnCommentCreated(event CommentCreatedEvent) error { + Runner.Log("New comment on: " + event.Post.Title) + return nil +} + +func main() {} +`, + }, + "member.subscribed": { + "typescript": `export const onMemberSubscribed = (event: MemberSubscribedEvent): void => { + Runner.log("New subscriber: " + event.member.email); + + // Example: Add to email list + Runner.httpRequest({ + url: "https://api.buttondown.email/v1/subscribers", + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Token " + Runner.secrets.BUTTONDOWN_API_KEY, + }, + body: JSON.stringify({ + email: event.member.email, + notes: "Subscribed from blog", + }), + }); +}; +`, + "go": `package main + +func OnMemberSubscribed(event MemberSubscribedEvent) error { + Runner.Log("New subscriber: " + event.Member.Email) + return nil +} + +func main() {} +`, + }, + "content.render": { + "typescript": `export const renderContent = (input: ContentRenderInput): ContentRenderOutput => { + let html = input.html; + + // Example: Add copy button to code blocks + html = html.replace( + /
 {
+  Runner.log("Asset uploaded: " + event.url);
+
+  // Example: Backup to external storage
+  if (event.contentType.startsWith("image/")) {
+    Runner.httpRequest({
+      url: "https://api.cloudinary.com/v1_1/demo/image/upload",
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        file: event.url,
+        folder: "blog-backups",
+      }),
+    });
+  }
+};
+`,
+		"go": `package main
+
+func OnAssetUploaded(event AssetUploadedEvent) error {
+	Runner.Log("Asset uploaded: " + event.Url)
+	return nil
+}
+
+func main() {}
+`,
+	},
+	"analytics.sync": {
+		"typescript": `export const onAnalyticsSync = (event: AnalyticsSyncEvent): void => {
+  Runner.log("Analytics sync: " + event.pageviews + " pageviews");
+
+  // Example: Push to external analytics
+  Runner.httpRequest({
+    url: "https://api.example.com/analytics",
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+      "Authorization": "Bearer " + Runner.secrets.ANALYTICS_KEY,
+    },
+    body: JSON.stringify({
+      period: event.period,
+      pageviews: event.pageviews,
+      visitors: event.visitors,
+      topPages: event.topPages,
+    }),
+  });
+};
+`,
+		"go": `package main
+
+func OnAnalyticsSync(event AnalyticsSyncEvent) error {
+	Runner.Log("Analytics sync completed")
+	return nil
+}
+
+func main() {}
+`,
+	},
+}
+
+// GetTemplate returns the template for a specific hook and language
+func GetTemplate(hook, language string) string {
+	if hookTemplates, ok := templates[hook]; ok {
+		if template, ok := hookTemplates[language]; ok {
+			return template
+		}
+	}
+	// Default fallback
+	return templates["post.published"]["typescript"]
+}
diff --git a/templates/go.go b/templates/go.go
new file mode 100644
index 0000000..9be9f4b
--- /dev/null
+++ b/templates/go.go
@@ -0,0 +1,90 @@
+// WriteKit Plugin Template - Go
+// Hooks: post.published, post.updated, comment.validate, comment.created,
+//        member.subscribed, content.render, asset.uploaded, analytics.sync
+
+package main
+
+import (
+	"strings"
+
+	"github.com/extism/go-pdk"
+)
+
+// Secrets holds your configured secret values
+// Field names will be converted to UPPER_SNAKE_CASE for config lookup
+type Secrets struct {
+	SlackWebhook string
+	// Add more secrets as needed
+}
+
+// Event types
+type PostPublishedEvent struct {
+	Post struct {
+		Slug        string   `json:"slug"`
+		Title       string   `json:"title"`
+		URL         string   `json:"url"`
+		Excerpt     string   `json:"excerpt"`
+		PublishedAt string   `json:"publishedAt"`
+		Tags        []string `json:"tags"`
+	} `json:"post"`
+	Author struct {
+		Name  string `json:"name"`
+		Email string `json:"email"`
+	} `json:"author"`
+	Blog struct {
+		Name string `json:"name"`
+		URL  string `json:"url"`
+	} `json:"blog"`
+}
+
+type CommentInput struct {
+	Content     string `json:"content"`
+	AuthorName  string `json:"authorName"`
+	AuthorEmail string `json:"authorEmail"`
+	PostSlug    string `json:"postSlug"`
+	ParentID    string `json:"parentId,omitempty"`
+}
+
+type ValidationResult struct {
+	Allowed bool   `json:"allowed"`
+	Reason  string `json:"reason,omitempty"`
+}
+
+// Called when a post is published
+func OnPostPublished(event PostPublishedEvent, secrets Secrets) error {
+	pdk.Log(pdk.LogInfo, "Post published: "+event.Post.Title)
+
+	// Example: Send Slack notification
+	body := []byte(`{"text":"New post: ` + event.Post.Title + `\n` + event.Post.URL + `"}`)
+	req := pdk.NewHTTPRequest(pdk.MethodPost, secrets.SlackWebhook)
+	req.SetHeader("Content-Type", "application/json")
+	req.SetBody(body)
+
+	resp := req.Send()
+	if resp.Status() >= 400 {
+		pdk.Log(pdk.LogError, "HTTP request failed")
+	}
+
+	return nil
+}
+
+// Called to validate a comment before creation
+// Return ValidationResult{Allowed: false, Reason: "..."} to reject
+func ValidateComment(input CommentInput, secrets Secrets) (ValidationResult, error) {
+	// Example: Simple spam check
+	spamWords := []string{"buy now", "click here", "free money"}
+	content := strings.ToLower(input.Content)
+
+	for _, word := range spamWords {
+		if strings.Contains(content, word) {
+			return ValidationResult{
+				Allowed: false,
+				Reason:  "Comment flagged as spam",
+			}, nil
+		}
+	}
+
+	return ValidationResult{Allowed: true}, nil
+}
+
+func main() {}
diff --git a/templates/typescript.ts b/templates/typescript.ts
new file mode 100644
index 0000000..ed5f112
--- /dev/null
+++ b/templates/typescript.ts
@@ -0,0 +1,53 @@
+// WriteKit Plugin Template - TypeScript
+// Hooks: post.published, post.updated, comment.validate, comment.created,
+//        member.subscribed, content.render, asset.uploaded, analytics.sync
+
+import { Host, Config } from "@writekit/sdk";
+
+// Declare your secrets (you'll get autocomplete in the editor!)
+interface Secrets {
+  SLACK_WEBHOOK: string;
+  // Add more secrets as needed
+}
+
+// Called when a post is published
+export function onPostPublished(event: PostPublishedEvent, secrets: Secrets): void {
+  Host.log(`Post published: ${event.post.title}`);
+
+  // Example: Send Slack notification
+  Host.httpRequest({
+    url: secrets.SLACK_WEBHOOK,
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({
+      text: `New post: ${event.post.title}\n${event.post.url}`,
+    }),
+  });
+}
+
+// Called to validate a comment before it's created
+// Return { allowed: false, reason: "..." } to reject
+export function validateComment(input: CommentInput, secrets: Secrets): ValidationResult {
+  // Example: Simple spam check
+  const spamWords = ["buy now", "click here", "free money"];
+  const isSpam = spamWords.some(word =>
+    input.content.toLowerCase().includes(word)
+  );
+
+  if (isSpam) {
+    return { allowed: false, reason: "Comment flagged as spam" };
+  }
+
+  return { allowed: true };
+}
+
+// Called to transform rendered HTML before display
+export function renderContent(input: ContentRenderInput): ContentRenderOutput {
+  // Example: Add copy button to code blocks
+  const html = input.html.replace(
+    /
 export_name
+	hookMap := map[string]string{
+		"OnPostPublished":   "on_post_published",
+		"OnPostUpdated":     "on_post_updated",
+		"OnCommentCreated":  "on_comment_created",
+		"OnMemberSubscribed": "on_member_subscribed",
+		"OnAssetUploaded":   "on_asset_uploaded",
+		"OnAnalyticsSync":   "on_analytics_sync",
+		"ValidateComment":   "validate_comment",
+		"RenderContent":     "render_content",
+	}
+
+	// Find all hook functions
+	// Pattern: func FuncName(params) returnType {
+	funcPattern := regexp.MustCompile(`(?m)^func\s+(\w+)\s*\(([^)]*)\)\s*(\([^)]*\)|[\w\*]+)?\s*\{`)
+	matches := funcPattern.FindAllStringSubmatchIndex(result, -1)
+
+	// Process matches in reverse order to preserve indices
+	for i := len(matches) - 1; i >= 0; i-- {
+		match := matches[i]
+		funcName := result[match[2]:match[3]]
+
+		// Check if this is a hook function
+		exportName, isHook := hookMap[funcName]
+		if !isHook {
+			continue
+		}
+
+		params := result[match[4]:match[5]]
+
+		// Get return type if present
+		returnType := ""
+		if match[6] != -1 && match[7] != -1 {
+			returnType = strings.TrimSpace(result[match[6]:match[7]])
+		}
+
+		// Find the closing brace
+		braceStart := match[7]
+		if braceStart == -1 {
+			braceStart = match[5] + 1
+		}
+		// Find the actual opening brace position
+		for braceStart < len(result) && result[braceStart] != '{' {
+			braceStart++
+		}
+		braceEnd := findClosingBrace(result, braceStart+1)
+		if braceEnd == -1 {
+			continue
+		}
+
+		// Extract function body
+		body := result[braceStart+1 : braceEnd]
+
+		// Parse parameters
+		eventParam, eventType, secretsParam, secretsType := parseGoParams(params)
+
+		// Build transformed function
+		var transformed strings.Builder
+
+		// Add export directive
+		transformed.WriteString(fmt.Sprintf("//export %s\n", exportName))
+
+		// New function signature (lowercase name, no params, returns int32)
+		lowercaseName := strings.ToLower(funcName[:1]) + funcName[1:]
+		transformed.WriteString(fmt.Sprintf("func %s() int32 {\n", lowercaseName))
+
+		// Inject event deserialization if we have an event parameter
+		if eventParam != "" && eventType != "" {
+			transformed.WriteString(fmt.Sprintf("  var %s %s\n", eventParam, eventType))
+			transformed.WriteString(fmt.Sprintf("  if err := pdk.InputJSON(&%s); err != nil {\n", eventParam))
+			transformed.WriteString("    pdk.Log(pdk.LogError, err.Error())\n")
+			transformed.WriteString("    return 1\n")
+			transformed.WriteString("  }\n")
+		}
+
+		// Inject secrets if present
+		if secretsParam != "" && secretsType != "" {
+			secretsCode := generateGoSecretsInjection(source, secretsParam, secretsType)
+			transformed.WriteString(secretsCode)
+		}
+
+		// Transform the body
+		transformedBody := body
+
+		// Check if return type includes a result type (tuple return)
+		hasResultReturn := strings.Contains(returnType, ",") || (returnType != "" && returnType != "error" && !strings.HasPrefix(returnType, "("))
+
+		if hasResultReturn {
+			// Transform returns that have result values
+			transformedBody = transformGoReturnsWithResult(body)
+		} else {
+			// Transform simple error returns to int32 returns
+			transformedBody = transformGoReturns(body)
+		}
+
+		transformed.WriteString(transformedBody)
+
+		// Ensure we have a return 0 at the end
+		if !strings.HasSuffix(strings.TrimSpace(transformedBody), "return 0") {
+			transformed.WriteString("\n  return 0\n")
+		}
+
+		transformed.WriteString("}")
+
+		// Replace the original function
+		result = result[:match[0]] + transformed.String() + result[braceEnd+1:]
+	}
+
+	// Inject imports and type definitions
+	result = injectGoImportsAndTypes(result)
+
+	return result, nil
+}
+
+// transformRunnerSecrets transforms Runner.Secrets.X to pdk.GetConfig("X") calls
+func transformRunnerSecrets(source string) string {
+	// Transform Runner.Secrets.SomeName to func() string { v, _ := pdk.GetConfig("SOME_NAME"); return v }()
+	pattern := regexp.MustCompile(`Runner\.Secrets\.(\w+)`)
+	return pattern.ReplaceAllStringFunc(source, func(match string) string {
+		m := pattern.FindStringSubmatch(match)
+		if m != nil {
+			fieldName := m[1]
+			configKey := toUpperSnakeCase(fieldName)
+			return fmt.Sprintf(`func() string { v, _ := pdk.GetConfig("%s"); return v }()`, configKey)
+		}
+		return match
+	})
+}
+
+// injectGoImportsAndTypes injects the necessary imports and type definitions
+func injectGoImportsAndTypes(source string) string {
+	// Check if source already has package main
+	if !strings.Contains(source, "package main") {
+		source = "package main\n\n" + source
+	}
+
+	// Replace "package main" with package + imports + types
+	importBlock := `package main
+
+import (
+	"github.com/extism/go-pdk"
+)
+` + goTypeDefinitions
+
+	// Remove the original package line and add our block
+	result := regexp.MustCompile(`package\s+main\s*\n*`).ReplaceAllString(source, "")
+	return importBlock + "\n" + result
+}
+
+// parseGoParams extracts parameters from Go function signature
+func parseGoParams(params string) (eventParam, eventType, secretsParam, secretsType string) {
+	params = strings.TrimSpace(params)
+	if params == "" {
+		return
+	}
+
+	// Split by comma (handling potential spaces)
+	parts := splitGoParams(params)
+
+	// First parameter is the event
+	if len(parts) >= 1 {
+		eventPart := strings.TrimSpace(parts[0])
+		// Match: paramName ParamType
+		re := regexp.MustCompile(`(\w+)\s+(\w+)`)
+		if m := re.FindStringSubmatch(eventPart); m != nil {
+			eventParam = m[1]
+			eventType = m[2]
+		}
+	}
+
+	// Second parameter (if present) is secrets
+	if len(parts) >= 2 {
+		secretsPart := strings.TrimSpace(parts[1])
+		re := regexp.MustCompile(`(\w+)\s+(\w+)`)
+		if m := re.FindStringSubmatch(secretsPart); m != nil {
+			secretsParam = m[1]
+			secretsType = m[2]
+		}
+	}
+
+	return
+}
+
+// splitGoParams splits Go parameters, handling edge cases
+func splitGoParams(params string) []string {
+	var parts []string
+	var current strings.Builder
+	depth := 0
+
+	for _, ch := range params {
+		switch ch {
+		case '(':
+			depth++
+			current.WriteRune(ch)
+		case ')':
+			depth--
+			current.WriteRune(ch)
+		case ',':
+			if depth == 0 {
+				parts = append(parts, current.String())
+				current.Reset()
+			} else {
+				current.WriteRune(ch)
+			}
+		default:
+			current.WriteRune(ch)
+		}
+	}
+	if current.Len() > 0 {
+		parts = append(parts, current.String())
+	}
+
+	return parts
+}
+
+// generateGoSecretsInjection creates code to build the secrets struct from pdk.GetConfig() calls
+func generateGoSecretsInjection(source, secretsParam, secretsType string) string {
+	// Find the struct definition for the secrets type
+	structPattern := regexp.MustCompile(fmt.Sprintf(`(?s)type\s+%s\s+struct\s*\{([^}]*)\}`, regexp.QuoteMeta(secretsType)))
+	match := structPattern.FindStringSubmatch(source)
+	if match == nil {
+		return fmt.Sprintf("  var %s %s\n", secretsParam, secretsType)
+	}
+
+	// Parse struct body for field names
+	structBody := match[1]
+	fieldPattern := regexp.MustCompile(`(\w+)\s+string`)
+	fields := fieldPattern.FindAllStringSubmatch(structBody, -1)
+
+	if len(fields) == 0 {
+		return fmt.Sprintf("  var %s %s\n", secretsParam, secretsType)
+	}
+
+	// Build the secrets struct initialization
+	var builder strings.Builder
+	builder.WriteString(fmt.Sprintf("  %s := %s{\n", secretsParam, secretsType))
+	for _, field := range fields {
+		fieldName := field[1]
+		// Convert Go field name to config key (e.g., SlackWebhook -> SLACK_WEBHOOK)
+		configKey := toUpperSnakeCase(fieldName)
+		builder.WriteString(fmt.Sprintf("    %s: func() string { v, _ := pdk.GetConfig(\"%s\"); return v }(),\n", fieldName, configKey))
+	}
+	builder.WriteString("  }\n")
+
+	return builder.String()
+}
+
+// toUpperSnakeCase converts CamelCase to UPPER_SNAKE_CASE
+func toUpperSnakeCase(s string) string {
+	var result strings.Builder
+	for i, ch := range s {
+		if i > 0 && ch >= 'A' && ch <= 'Z' {
+			result.WriteRune('_')
+		}
+		result.WriteRune(ch)
+	}
+	return strings.ToUpper(result.String())
+}
+
+// transformGoReturns transforms return statements from error returns to int32 returns
+func transformGoReturns(body string) string {
+	// Replace "return nil" with "return 0"
+	result := regexp.MustCompile(`return\s+nil\b`).ReplaceAllString(body, "return 0")
+
+	// Replace "return err" or "return someErr" with error handling
+	result = regexp.MustCompile(`return\s+(\w+)\s*$`).ReplaceAllStringFunc(result, func(match string) string {
+		re := regexp.MustCompile(`return\s+(\w+)`)
+		m := re.FindStringSubmatch(match)
+		if m != nil && m[1] != "nil" && m[1] != "0" && m[1] != "1" {
+			// It's an error variable
+			return fmt.Sprintf("if %s != nil { pdk.Log(pdk.LogError, %s.Error()); return 1 }; return 0", m[1], m[1])
+		}
+		return match
+	})
+
+	return result
+}
+
+// transformGoReturnsWithResult transforms returns that have a result value
+func transformGoReturnsWithResult(body string) string {
+	// Match: return result, nil or return result, err
+	returnPattern := regexp.MustCompile(`return\s+(\w+),\s*(\w+)\s*$`)
+
+	return returnPattern.ReplaceAllStringFunc(body, func(match string) string {
+		m := returnPattern.FindStringSubmatch(match)
+		if m != nil {
+			result := m[1]
+			errVar := m[2]
+			if errVar == "nil" {
+				return fmt.Sprintf("pdk.OutputJSON(%s); return 0", result)
+			}
+			return fmt.Sprintf("if %s != nil { pdk.Log(pdk.LogError, %s.Error()); return 1 }; pdk.OutputJSON(%s); return 0", errVar, errVar, result)
+		}
+		return match
+	})
+}
diff --git a/transform/typescript.go b/transform/typescript.go
new file mode 100644
index 0000000..b997e6b
--- /dev/null
+++ b/transform/typescript.go
@@ -0,0 +1,548 @@
+package transform
+
+import (
+	"fmt"
+	"regexp"
+	"strings"
+)
+
+// TypeScript type definitions to inject
+const tsTypeDefinitions = `
+// WriteKit SDK Types
+interface Post {
+  slug: string;
+  title: string;
+  url: string;
+  excerpt: string;
+  publishedAt: string;
+  updatedAt?: string;
+  tags: string[];
+  readingTime: number;
+}
+
+interface Author {
+  name: string;
+  email: string;
+  avatar?: string;
+}
+
+interface Blog {
+  name: string;
+  url: string;
+}
+
+interface Comment {
+  id: string;
+  content: string;
+  authorName: string;
+  authorEmail: string;
+  postSlug: string;
+  parentId?: string;
+  createdAt: string;
+}
+
+interface Member {
+  email: string;
+  name?: string;
+  subscribedAt: string;
+}
+
+interface Tier {
+  name: string;
+  price: number;
+}
+
+interface TitleChange {
+  old: string;
+  new: string;
+}
+
+interface TagChanges {
+  added: string[];
+  removed: string[];
+}
+
+interface Changes {
+  title?: TitleChange;
+  content?: boolean;
+  tags?: TagChanges;
+}
+
+interface Period {
+  start: string;
+  end: string;
+}
+
+interface PageView {
+  path: string;
+  views: number;
+}
+
+// Event types
+interface PostPublishedEvent {
+  post: Post;
+  author: Author;
+  blog: Blog;
+}
+
+interface PostUpdatedEvent {
+  post: Post;
+  author: Author;
+  changes: Changes;
+}
+
+interface CommentCreatedEvent {
+  comment: Comment;
+  post: Post;
+}
+
+interface CommentInput {
+  content: string;
+  authorName: string;
+  authorEmail: string;
+  postSlug: string;
+}
+
+interface ValidationResult {
+  allowed: boolean;
+  reason?: string;
+}
+
+interface MemberSubscribedEvent {
+  member: Member;
+  tier: Tier;
+}
+
+interface ContentRenderInput {
+  html: string;
+  post: Post;
+}
+
+interface ContentRenderOutput {
+  html: string;
+}
+
+interface AssetUploadedEvent {
+  id: string;
+  url: string;
+  contentType: string;
+  size: number;
+  width?: number;
+  height?: number;
+}
+
+interface AnalyticsSyncEvent {
+  period: Period;
+  pageviews: number;
+  visitors: number;
+  topPages: PageView[];
+}
+
+// Runner SDK - simple functions that wrap Host/Config
+function runnerLog(msg: string): void {
+  Host.log(msg);
+}
+
+function runnerHttpRequest(options: { url: string; method?: string; headers?: Record; body?: string }): any {
+  // For now, just log - full HTTP will be implemented later
+  Host.log("HTTP: " + options.method + " " + options.url);
+  return { status: 200, body: "" };
+}
+
+function runnerSecretGet(key: string): string {
+  return Config.get(key) || "";
+}
+
+// Runner namespace object
+const Runner = {
+  log: runnerLog,
+  httpRequest: runnerHttpRequest,
+  secrets: {
+    get: runnerSecretGet,
+  },
+};
+`
+
+// TypeScript transforms plugin source code from the clean API to the Extism-compatible format.
+// extism-js requires CommonJS format (module.exports), not ES6 exports.
+//
+// Input (clean API):
+//
+//	export function onPostPublished(event: PostPublishedEvent, secrets: Secrets): void {
+//	  Runner.log(event.post.title);
+//	}
+//
+// Output (Extism-compatible CommonJS):
+//
+//	// Runner SDK wrapper
+//	var Runner = { log: function(msg) { Host.log(msg); }, ... };
+//
+//	function onPostPublished() {
+//	  var event = JSON.parse(Host.inputString());
+//	  var secrets = { KEY: Config.get("KEY") || "", ... };
+//	  Runner.log(event.post.title);
+//	  return 0;
+//	}
+//	module.exports = { onPostPublished };
+func TypeScript(source string) (string, error) {
+	result := source
+	var exportedFuncs []string
+
+	// First, handle arrow functions: export const onPostPublished = (event: PostPublishedEvent): void => { ... }
+	arrowPattern := regexp.MustCompile(`(?s)export\s+const\s+(\w+)\s*=\s*\(([^)]*)\)\s*(?::\s*(\w+))?\s*=>\s*\{`)
+	arrowMatches := arrowPattern.FindAllStringSubmatchIndex(result, -1)
+
+	// Process arrow function matches in reverse order to preserve indices
+	for i := len(arrowMatches) - 1; i >= 0; i-- {
+		match := arrowMatches[i]
+		funcName := result[match[2]:match[3]]
+		params := result[match[4]:match[5]]
+
+		// Get return type if present
+		returnType := "void"
+		if match[6] != -1 && match[7] != -1 {
+			returnType = result[match[6]:match[7]]
+		}
+
+		// Only transform hook functions
+		if !isHookFunction(funcName) {
+			continue
+		}
+
+		// Track exported functions
+		exportedFuncs = append([]string{funcName}, exportedFuncs...)
+
+		// Parse parameters
+		eventParam, _, secretsParam, secretsType := parseParams(params)
+
+		// Find the opening brace after =>
+		funcStart := match[0]
+		braceIdx := strings.Index(result[match[0]:], "{")
+		if braceIdx == -1 {
+			continue
+		}
+		braceStart := match[0] + braceIdx + 1
+		braceEnd := findClosingBrace(result, braceStart)
+		if braceEnd == -1 {
+			continue
+		}
+
+		// Extract function body
+		body := result[braceStart:braceEnd]
+
+		// Build the transformed function
+		var transformed strings.Builder
+		snakeFuncName := camelToSnake(funcName)
+		transformed.WriteString(fmt.Sprintf("function %s() {\n", snakeFuncName))
+
+		if eventParam != "" {
+			transformed.WriteString(fmt.Sprintf("  var %s = JSON.parse(Host.inputString());\n", eventParam))
+		}
+
+		if secretsParam != "" && secretsType != "" {
+			secretsCode := generateSecretsInjectionJS(source, secretsParam, secretsType)
+			transformed.WriteString(secretsCode)
+		}
+
+		transformedBody := stripTypeAnnotations(body)
+		if returnType != "void" {
+			transformedBody = transformReturnStatements(transformedBody, returnType)
+		}
+
+		transformed.WriteString(transformedBody)
+
+		if !strings.Contains(transformedBody, "return 0;") && !strings.Contains(transformedBody, "return 0\n") {
+			transformed.WriteString("\n  return 0;\n")
+		}
+
+		transformed.WriteString("}")
+
+		result = result[:funcStart] + transformed.String() + result[braceEnd+1:]
+	}
+
+	// Find all exported hook functions (function declaration syntax)
+	// Match both with and without return type annotation
+	funcPattern := regexp.MustCompile(`(?s)export\s+function\s+(\w+)\s*\(([^)]*)\)\s*(?::\s*(\w+))?\s*\{`)
+	matches := funcPattern.FindAllStringSubmatchIndex(result, -1)
+
+	// Process matches in reverse order to preserve indices
+	for i := len(matches) - 1; i >= 0; i-- {
+		match := matches[i]
+		funcName := result[match[2]:match[3]]
+		params := result[match[4]:match[5]]
+
+		// Get return type if present (match[6]:match[7] may be -1 if not matched)
+		returnType := "void"
+		if match[6] != -1 && match[7] != -1 {
+			returnType = result[match[6]:match[7]]
+		}
+
+		// Only transform hook functions
+		if !isHookFunction(funcName) {
+			continue
+		}
+
+		// Track exported functions
+		exportedFuncs = append([]string{funcName}, exportedFuncs...)
+
+		// Parse parameters
+		eventParam, _, secretsParam, secretsType := parseParams(params)
+
+		// Find the matching closing brace for this function
+		funcStart := match[0]
+		// Find the opening brace position
+		bracePos := strings.Index(result[match[5]:], "{")
+		if bracePos == -1 {
+			continue
+		}
+		braceStart := match[5] + bracePos + 1
+		braceEnd := findClosingBrace(result, braceStart)
+		if braceEnd == -1 {
+			continue // Could not find closing brace
+		}
+
+		// Extract function body
+		body := result[braceStart:braceEnd]
+
+		// Build the transformed function (CommonJS style, no type annotations)
+		var transformed strings.Builder
+
+		// Function declaration without 'export' and without type annotations
+		// Use snake_case name for WASM export (extism-js requires this)
+		snakeFuncName := camelToSnake(funcName)
+		transformed.WriteString(fmt.Sprintf("function %s() {\n", snakeFuncName))
+
+		// Inject event deserialization if there was an event parameter
+		if eventParam != "" {
+			transformed.WriteString(fmt.Sprintf("  var %s = JSON.parse(Host.inputString());\n", eventParam))
+		}
+
+		// Inject secrets if present
+		if secretsParam != "" && secretsType != "" {
+			secretsCode := generateSecretsInjectionJS(source, secretsParam, secretsType)
+			transformed.WriteString(secretsCode)
+		}
+
+		// Transform the body:
+		// 1. Remove type annotations
+		// 2. Transform return statements if needed
+		transformedBody := stripTypeAnnotations(body)
+		if returnType != "void" {
+			transformedBody = transformReturnStatements(transformedBody, returnType)
+		}
+
+		// Add the body
+		transformed.WriteString(transformedBody)
+
+		// Ensure return 0 at the end if not already present
+		if !strings.Contains(transformedBody, "return 0;") && !strings.Contains(transformedBody, "return 0\n") {
+			transformed.WriteString("\n  return 0;\n")
+		}
+
+		transformed.WriteString("}")
+
+		// Replace the original function with the transformed one
+		result = result[:funcStart] + transformed.String() + result[braceEnd+1:]
+	}
+
+	// Remove interface declarations (TypeScript-only)
+	interfacePattern := regexp.MustCompile(`(?s)interface\s+\w+\s*\{[^}]*\}\s*`)
+	result = interfacePattern.ReplaceAllString(result, "")
+
+	// Remove any remaining 'export' keywords
+	result = regexp.MustCompile(`\bexport\s+`).ReplaceAllString(result, "")
+
+	// Prepend Runner SDK wrapper
+	runnerSDK := `// Runner SDK wrapper for WriteKit plugins
+var Runner = {
+  log: function(msg) { Host.log(msg); },
+  httpRequest: function(options) {
+    var method = options.method || "GET";
+    var headers = options.headers || {};
+    var req = { url: options.url, method: method, headers: headers };
+    var bodyBytes = options.body ? (new TextEncoder()).encode(options.body) : new Uint8Array(0);
+    var resp = Http.request(req, bodyBytes);
+    var respBody = "";
+    try { respBody = (new TextDecoder()).decode(resp.body()); } catch(e) {}
+    return { status: resp.status, body: respBody, headers: {} };
+  },
+  secrets: {
+    get: function(key) { return Config.get(key) || ""; }
+  }
+};
+
+`
+
+	// Add module.exports at the end with snake_case names (extism requires snake_case exports)
+	var exports strings.Builder
+	if len(exportedFuncs) > 0 {
+		exports.WriteString("\nmodule.exports = { ")
+		for i, fn := range exportedFuncs {
+			snakeName := camelToSnake(fn)
+			exports.WriteString(snakeName)
+			if i < len(exportedFuncs)-1 {
+				exports.WriteString(", ")
+			}
+		}
+		exports.WriteString(" };\n")
+	}
+
+	result = runnerSDK + strings.TrimSpace(result) + exports.String()
+
+	return result, nil
+}
+
+// stripTypeAnnotations removes TypeScript type annotations from code
+func stripTypeAnnotations(code string) string {
+	// Remove variable type annotations: const x: Type = ...  ->  var x = ...
+	code = regexp.MustCompile(`\b(const|let)\s+(\w+)\s*:\s*\w+\s*=`).ReplaceAllString(code, "var $2 =")
+
+	// Remove 'as Type' casts
+	code = regexp.MustCompile(`\s+as\s+\w+`).ReplaceAllString(code, "")
+
+	// Remove generic type parameters: func(...)  ->  func(...)
+	code = regexp.MustCompile(`<\w+>`).ReplaceAllString(code, "")
+
+	// Replace const/let with var for remaining cases
+	code = regexp.MustCompile(`\b(const|let)\b`).ReplaceAllString(code, "var")
+
+	return code
+}
+
+// generateSecretsInjectionJS creates JavaScript code to build the secrets object
+func generateSecretsInjectionJS(source, secretsParam, secretsType string) string {
+	// Find the interface definition for the secrets type
+	interfacePattern := regexp.MustCompile(fmt.Sprintf(`(?s)interface\s+%s\s*\{([^}]*)\}`, regexp.QuoteMeta(secretsType)))
+	match := interfacePattern.FindStringSubmatch(source)
+	if match == nil {
+		// If no interface found, use empty object
+		return fmt.Sprintf("  var %s = {};\n", secretsParam)
+	}
+
+	// Parse interface body for property names
+	interfaceBody := match[1]
+	propPattern := regexp.MustCompile(`(\w+)\s*:\s*string`)
+	props := propPattern.FindAllStringSubmatch(interfaceBody, -1)
+
+	if len(props) == 0 {
+		return fmt.Sprintf("  var %s = {};\n", secretsParam)
+	}
+
+	// Build the secrets object (JavaScript, no types)
+	var builder strings.Builder
+	builder.WriteString(fmt.Sprintf("  var %s = {\n", secretsParam))
+	for i, prop := range props {
+		key := prop[1]
+		builder.WriteString(fmt.Sprintf("    %s: Config.get(\"%s\") || \"\"", key, key))
+		if i < len(props)-1 {
+			builder.WriteString(",")
+		}
+		builder.WriteString("\n")
+	}
+	builder.WriteString("  };\n")
+
+	return builder.String()
+}
+
+// camelToSnake converts camelCase to snake_case
+// e.g., onPostPublished -> on_post_published
+func camelToSnake(s string) string {
+	var result strings.Builder
+	for i, r := range s {
+		if i > 0 && r >= 'A' && r <= 'Z' {
+			result.WriteByte('_')
+		}
+		result.WriteRune(r)
+	}
+	return strings.ToLower(result.String())
+}
+
+// isHookFunction checks if a function name matches a known hook pattern
+func isHookFunction(name string) bool {
+	hooks := []string{
+		"onPostPublished",
+		"onPostUpdated",
+		"onCommentCreated",
+		"onMemberSubscribed",
+		"onAssetUploaded",
+		"onAnalyticsSync",
+		"validateComment",
+		"renderContent",
+	}
+	for _, hook := range hooks {
+		if name == hook {
+			return true
+		}
+	}
+	return false
+}
+
+// parseParams extracts event and secrets parameters from the function signature
+func parseParams(params string) (eventParam, eventType, secretsParam, secretsType string) {
+	params = strings.TrimSpace(params)
+	if params == "" {
+		return
+	}
+
+	// Split by comma
+	parts := strings.Split(params, ",")
+
+	// First parameter is the event
+	if len(parts) >= 1 {
+		eventPart := strings.TrimSpace(parts[0])
+		// Match: paramName: ParamType
+		re := regexp.MustCompile(`(\w+)\s*:\s*(\w+)`)
+		if m := re.FindStringSubmatch(eventPart); m != nil {
+			eventParam = m[1]
+			eventType = m[2]
+		}
+	}
+
+	// Second parameter (if present) is secrets
+	if len(parts) >= 2 {
+		secretsPart := strings.TrimSpace(parts[1])
+		re := regexp.MustCompile(`(\w+)\s*:\s*(\w+)`)
+		if m := re.FindStringSubmatch(secretsPart); m != nil {
+			secretsParam = m[1]
+			secretsType = m[2]
+		}
+	}
+
+	return
+}
+
+// findClosingBrace finds the matching closing brace for an opening brace
+func findClosingBrace(source string, start int) int {
+	depth := 1
+	for i := start; i < len(source); i++ {
+		switch source[i] {
+		case '{':
+			depth++
+		case '}':
+			depth--
+			if depth == 0 {
+				return i
+			}
+		}
+	}
+	return -1
+}
+
+// transformReturnStatements wraps return statements with Host.outputString(JSON.stringify())
+func transformReturnStatements(body, returnType string) string {
+	// Match return statements with a value
+	// This is a simplified version - a real parser would be more robust
+	returnPattern := regexp.MustCompile(`return\s+(\{[^;]+\}|\w+)\s*;`)
+
+	return returnPattern.ReplaceAllStringFunc(body, func(match string) string {
+		// Extract the return value
+		re := regexp.MustCompile(`return\s+(.+);`)
+		m := re.FindStringSubmatch(match)
+		if m == nil {
+			return match
+		}
+		value := strings.TrimSpace(m[1])
+		// Use Host.outputString with JSON.stringify since Host.output doesn't exist in PDK
+		return fmt.Sprintf("Host.outputString(JSON.stringify(%s)); return 0;", value)
+	})
+}