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