From 91a950e72fb05447bdccfd484f5d0101aa7895c3 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 9 Jan 2026 00:24:04 +0200 Subject: [PATCH] init --- .dockerignore | 4 + .gitignore | 6 + .woodpecker.yml | 14 + Dockerfile | 106 ++++++++ entrypoint.sh | 2 + go.mod | 8 + go.sum | 4 + lsp.go | 217 ++++++++++++++++ main.go | 228 +++++++++++++++++ sdk.go | 267 ++++++++++++++++++++ sdk/extism.d.ts | 12 + sdk/writekit.d.ts | 245 ++++++++++++++++++ templates.go | 423 +++++++++++++++++++++++++++++++ templates/go.go | 90 +++++++ templates/typescript.ts | 53 ++++ transform/go.go | 497 ++++++++++++++++++++++++++++++++++++ transform/typescript.go | 548 ++++++++++++++++++++++++++++++++++++++++ 17 files changed, 2724 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 .woodpecker.yml create mode 100644 Dockerfile create mode 100644 entrypoint.sh create mode 100644 go.mod create mode 100644 go.sum create mode 100644 lsp.go create mode 100644 main.go create mode 100644 sdk.go create mode 100644 sdk/extism.d.ts create mode 100644 sdk/writekit.d.ts create mode 100644 templates.go create mode 100644 templates/go.go create mode 100644 templates/typescript.ts create mode 100644 transform/go.go create mode 100644 transform/typescript.go 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)
+	})
+}