init
This commit is contained in:
commit
91a950e72f
17 changed files with 2724 additions and 0 deletions
4
.dockerignore
Normal file
4
.dockerignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
*.wasm
|
||||
*.md
|
||||
.git
|
||||
.gitignore
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
jarvis
|
||||
jarvis.exe
|
||||
*.wasm
|
||||
.env
|
||||
.idea/
|
||||
.vscode/
|
||||
14
.woodpecker.yml
Normal file
14
.woodpecker.yml
Normal 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
106
Dockerfile
Normal 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
2
entrypoint.sh
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
#!/bin/bash
|
||||
exec /app/jarvis
|
||||
8
go.mod
Normal file
8
go.mod
Normal 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
4
go.sum
Normal 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
217
lsp.go
Normal 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
228
main.go
Normal 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
267
sdk.go
Normal 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
12
sdk/extism.d.ts
vendored
Normal 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
245
sdk/writekit.d.ts
vendored
Normal 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
423
templates.go
Normal 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
90
templates/go.go
Normal 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
53
templates/typescript.ts
Normal 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
497
transform/go.go
Normal 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
548
transform/typescript.go
Normal 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)
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue