This commit is contained in:
Josh 2026-01-09 00:24:04 +02:00
commit 91a950e72f
17 changed files with 2724 additions and 0 deletions

4
.dockerignore Normal file
View file

@ -0,0 +1,4 @@
*.wasm
*.md
.git
.gitignore

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
jarvis
jarvis.exe
*.wasm
.env
.idea/
.vscode/

14
.woodpecker.yml Normal file
View file

@ -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

106
Dockerfile Normal file
View file

@ -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"]

2
entrypoint.sh Normal file
View file

@ -0,0 +1,2 @@
#!/bin/bash
exec /app/jarvis

8
go.mod Normal file
View file

@ -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
)

4
go.sum Normal file
View file

@ -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=

217
lsp.go Normal file
View file

@ -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
}
}
}

228
main.go Normal file
View file

@ -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)}
}

267
sdk.go Normal file
View file

@ -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)
}

12
sdk/extism.d.ts vendored Normal file
View file

@ -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;
}

245
sdk/writekit.d.ts vendored Normal file
View file

@ -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>(): T;
function outputString(s: string): void;
function outputBytes(bytes: Uint8Array): void;
function outputJSON<T>(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<string, string>;
}
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<string, string>;
body?: string;
}
interface HttpResponse {
status: number;
headers: Record<string, string>;
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;

423
templates.go Normal file
View file

@ -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": "<h1>Hello World</h1><p>This is test content.</p><pre><code>const x = 1;</code></pre>",
"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(
/<pre><code/g,
'<pre class="relative group"><button class="copy-btn absolute top-2 right-2 opacity-0 group-hover:opacity-100">Copy</button><code'
);
// Example: Make external links open in new tab
html = html.replace(
/<a href="(https?:\/\/[^"]+)"/g,
'<a href="$1" target="_blank" rel="noopener"'
);
return { html };
};
`,
"go": `package main
import "strings"
func RenderContent(input ContentRenderInput) (ContentRenderOutput, error) {
html := input.Html
// Example: Add copy button to code blocks
html = strings.ReplaceAll(html, "<pre><code", "<pre class=\"relative\"><button class=\"copy-btn\">Copy</button><code")
return ContentRenderOutput{Html: html}, nil
}
func main() {}
`,
},
"asset.uploaded": {
"typescript": `export const onAssetUploaded = (event: AssetUploadedEvent): void => {
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"]
}

90
templates/go.go Normal file
View file

@ -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() {}

53
templates/typescript.ts Normal file
View file

@ -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(
/<pre><code/g,
'<pre class="relative group"><button class="copy-btn">Copy</button><code'
);
return { html };
}

497
transform/go.go Normal file
View file

@ -0,0 +1,497 @@
package transform
import (
"fmt"
"regexp"
"strings"
)
// Go type definitions to inject
const goTypeDefinitions = `
// WriteKit SDK Types
type Post struct {
Slug string ` + "`json:\"slug\"`" + `
Title string ` + "`json:\"title\"`" + `
Url string ` + "`json:\"url\"`" + `
Excerpt string ` + "`json:\"excerpt\"`" + `
PublishedAt string ` + "`json:\"publishedAt\"`" + `
UpdatedAt string ` + "`json:\"updatedAt\"`" + `
Tags []string ` + "`json:\"tags\"`" + `
ReadingTime int ` + "`json:\"readingTime\"`" + `
}
type Author struct {
Name string ` + "`json:\"name\"`" + `
Email string ` + "`json:\"email\"`" + `
Avatar string ` + "`json:\"avatar\"`" + `
}
type Blog struct {
Name string ` + "`json:\"name\"`" + `
Url string ` + "`json:\"url\"`" + `
}
type Comment struct {
Id string ` + "`json:\"id\"`" + `
Content string ` + "`json:\"content\"`" + `
AuthorName string ` + "`json:\"authorName\"`" + `
AuthorEmail string ` + "`json:\"authorEmail\"`" + `
PostSlug string ` + "`json:\"postSlug\"`" + `
ParentId string ` + "`json:\"parentId\"`" + `
CreatedAt string ` + "`json:\"createdAt\"`" + `
}
type Member struct {
Email string ` + "`json:\"email\"`" + `
Name string ` + "`json:\"name\"`" + `
SubscribedAt string ` + "`json:\"subscribedAt\"`" + `
}
type Tier struct {
Name string ` + "`json:\"name\"`" + `
Price int ` + "`json:\"price\"`" + `
}
type TitleChange struct {
Old string ` + "`json:\"old\"`" + `
New string ` + "`json:\"new\"`" + `
}
type TagChanges struct {
Added []string ` + "`json:\"added\"`" + `
Removed []string ` + "`json:\"removed\"`" + `
}
type Changes struct {
Title TitleChange ` + "`json:\"title\"`" + `
Content bool ` + "`json:\"content\"`" + `
Tags TagChanges ` + "`json:\"tags\"`" + `
}
type Period struct {
Start string ` + "`json:\"start\"`" + `
End string ` + "`json:\"end\"`" + `
}
type PageView struct {
Path string ` + "`json:\"path\"`" + `
Views int ` + "`json:\"views\"`" + `
}
// Event types
type PostPublishedEvent struct {
Post Post ` + "`json:\"post\"`" + `
Author Author ` + "`json:\"author\"`" + `
Blog Blog ` + "`json:\"blog\"`" + `
}
type PostUpdatedEvent struct {
Post Post ` + "`json:\"post\"`" + `
Author Author ` + "`json:\"author\"`" + `
Changes Changes ` + "`json:\"changes\"`" + `
}
type CommentCreatedEvent struct {
Comment Comment ` + "`json:\"comment\"`" + `
Post Post ` + "`json:\"post\"`" + `
}
type CommentInput struct {
Content string ` + "`json:\"content\"`" + `
AuthorName string ` + "`json:\"authorName\"`" + `
AuthorEmail string ` + "`json:\"authorEmail\"`" + `
PostSlug string ` + "`json:\"postSlug\"`" + `
}
type ValidationResult struct {
Allowed bool ` + "`json:\"allowed\"`" + `
Reason string ` + "`json:\"reason,omitempty\"`" + `
}
type MemberSubscribedEvent struct {
Member Member ` + "`json:\"member\"`" + `
Tier Tier ` + "`json:\"tier\"`" + `
}
type ContentRenderInput struct {
Html string ` + "`json:\"html\"`" + `
Post Post ` + "`json:\"post\"`" + `
}
type ContentRenderOutput struct {
Html string ` + "`json:\"html\"`" + `
}
type AssetUploadedEvent struct {
Id string ` + "`json:\"id\"`" + `
Url string ` + "`json:\"url\"`" + `
ContentType string ` + "`json:\"contentType\"`" + `
Size int ` + "`json:\"size\"`" + `
Width int ` + "`json:\"width\"`" + `
Height int ` + "`json:\"height\"`" + `
}
type AnalyticsSyncEvent struct {
Period Period ` + "`json:\"period\"`" + `
Pageviews int ` + "`json:\"pageviews\"`" + `
Visitors int ` + "`json:\"visitors\"`" + `
TopPages []PageView ` + "`json:\"topPages\"`" + `
}
// Runner SDK wrapper
type runnerSDK struct{}
var Runner = runnerSDK{}
func (r runnerSDK) Log(msg string) {
pdk.Log(pdk.LogInfo, msg)
}
type httpResponse struct {
Status int
Headers map[string]string
Body string
}
func (r runnerSDK) HttpRequest(url, method string, body []byte) httpResponse {
req := pdk.NewHTTPRequest(pdk.MethodPost, url)
if method == "GET" {
req = pdk.NewHTTPRequest(pdk.MethodGet, url)
}
if body != nil {
req.SetBody(body)
}
resp := req.Send()
return httpResponse{
Status: int(resp.Status()),
Body: string(resp.Body()),
}
}
// Secrets accessor (populated at runtime via pdk.GetConfig)
type secretsAccessor struct{}
var Secrets = secretsAccessor{}
`
// Go transforms plugin source code from the clean API to the Extism-compatible format.
//
// Input (clean API):
//
// func OnPostPublished(event PostPublishedEvent) error {
// Runner.Log("Post published: " + event.Post.Title)
// return nil
// }
//
// Output (Extism-compatible):
//
// //export on_post_published
// func onPostPublished() int32 {
// var event PostPublishedEvent
// if err := pdk.InputJSON(&event); err != nil {
// pdk.Log(pdk.LogError, err.Error())
// return 1
// }
// Runner.Log("Post published: " + event.Post.Title)
// return 0
// }
func Go(source string) (string, error) {
result := source
// Transform Runner.Secrets.X to pdk.GetConfig("X") calls
result = transformRunnerSecrets(result)
// Map of hook functions: CleanName -> 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
})
}

548
transform/typescript.go Normal file
View file

@ -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<string, string>; 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<Type>(...) -> 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<T> doesn't exist in PDK
return fmt.Sprintf("Host.outputString(JSON.stringify(%s)); return 0;", value)
})
}