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

548
transform/typescript.go Normal file
View file

@ -0,0 +1,548 @@
package transform
import (
"fmt"
"regexp"
"strings"
)
// TypeScript type definitions to inject
const tsTypeDefinitions = `
// WriteKit SDK Types
interface Post {
slug: string;
title: string;
url: string;
excerpt: string;
publishedAt: string;
updatedAt?: string;
tags: string[];
readingTime: number;
}
interface Author {
name: string;
email: string;
avatar?: string;
}
interface Blog {
name: string;
url: string;
}
interface Comment {
id: string;
content: string;
authorName: string;
authorEmail: string;
postSlug: string;
parentId?: string;
createdAt: string;
}
interface Member {
email: string;
name?: string;
subscribedAt: string;
}
interface Tier {
name: string;
price: number;
}
interface TitleChange {
old: string;
new: string;
}
interface TagChanges {
added: string[];
removed: string[];
}
interface Changes {
title?: TitleChange;
content?: boolean;
tags?: TagChanges;
}
interface Period {
start: string;
end: string;
}
interface PageView {
path: string;
views: number;
}
// Event types
interface PostPublishedEvent {
post: Post;
author: Author;
blog: Blog;
}
interface PostUpdatedEvent {
post: Post;
author: Author;
changes: Changes;
}
interface CommentCreatedEvent {
comment: Comment;
post: Post;
}
interface CommentInput {
content: string;
authorName: string;
authorEmail: string;
postSlug: string;
}
interface ValidationResult {
allowed: boolean;
reason?: string;
}
interface MemberSubscribedEvent {
member: Member;
tier: Tier;
}
interface ContentRenderInput {
html: string;
post: Post;
}
interface ContentRenderOutput {
html: string;
}
interface AssetUploadedEvent {
id: string;
url: string;
contentType: string;
size: number;
width?: number;
height?: number;
}
interface AnalyticsSyncEvent {
period: Period;
pageviews: number;
visitors: number;
topPages: PageView[];
}
// Runner SDK - simple functions that wrap Host/Config
function runnerLog(msg: string): void {
Host.log(msg);
}
function runnerHttpRequest(options: { url: string; method?: string; headers?: Record<string, string>; body?: string }): any {
// For now, just log - full HTTP will be implemented later
Host.log("HTTP: " + options.method + " " + options.url);
return { status: 200, body: "" };
}
function runnerSecretGet(key: string): string {
return Config.get(key) || "";
}
// Runner namespace object
const Runner = {
log: runnerLog,
httpRequest: runnerHttpRequest,
secrets: {
get: runnerSecretGet,
},
};
`
// TypeScript transforms plugin source code from the clean API to the Extism-compatible format.
// extism-js requires CommonJS format (module.exports), not ES6 exports.
//
// Input (clean API):
//
// export function onPostPublished(event: PostPublishedEvent, secrets: Secrets): void {
// Runner.log(event.post.title);
// }
//
// Output (Extism-compatible CommonJS):
//
// // Runner SDK wrapper
// var Runner = { log: function(msg) { Host.log(msg); }, ... };
//
// function onPostPublished() {
// var event = JSON.parse(Host.inputString());
// var secrets = { KEY: Config.get("KEY") || "", ... };
// Runner.log(event.post.title);
// return 0;
// }
// module.exports = { onPostPublished };
func TypeScript(source string) (string, error) {
result := source
var exportedFuncs []string
// First, handle arrow functions: export const onPostPublished = (event: PostPublishedEvent): void => { ... }
arrowPattern := regexp.MustCompile(`(?s)export\s+const\s+(\w+)\s*=\s*\(([^)]*)\)\s*(?::\s*(\w+))?\s*=>\s*\{`)
arrowMatches := arrowPattern.FindAllStringSubmatchIndex(result, -1)
// Process arrow function matches in reverse order to preserve indices
for i := len(arrowMatches) - 1; i >= 0; i-- {
match := arrowMatches[i]
funcName := result[match[2]:match[3]]
params := result[match[4]:match[5]]
// Get return type if present
returnType := "void"
if match[6] != -1 && match[7] != -1 {
returnType = result[match[6]:match[7]]
}
// Only transform hook functions
if !isHookFunction(funcName) {
continue
}
// Track exported functions
exportedFuncs = append([]string{funcName}, exportedFuncs...)
// Parse parameters
eventParam, _, secretsParam, secretsType := parseParams(params)
// Find the opening brace after =>
funcStart := match[0]
braceIdx := strings.Index(result[match[0]:], "{")
if braceIdx == -1 {
continue
}
braceStart := match[0] + braceIdx + 1
braceEnd := findClosingBrace(result, braceStart)
if braceEnd == -1 {
continue
}
// Extract function body
body := result[braceStart:braceEnd]
// Build the transformed function
var transformed strings.Builder
snakeFuncName := camelToSnake(funcName)
transformed.WriteString(fmt.Sprintf("function %s() {\n", snakeFuncName))
if eventParam != "" {
transformed.WriteString(fmt.Sprintf(" var %s = JSON.parse(Host.inputString());\n", eventParam))
}
if secretsParam != "" && secretsType != "" {
secretsCode := generateSecretsInjectionJS(source, secretsParam, secretsType)
transformed.WriteString(secretsCode)
}
transformedBody := stripTypeAnnotations(body)
if returnType != "void" {
transformedBody = transformReturnStatements(transformedBody, returnType)
}
transformed.WriteString(transformedBody)
if !strings.Contains(transformedBody, "return 0;") && !strings.Contains(transformedBody, "return 0\n") {
transformed.WriteString("\n return 0;\n")
}
transformed.WriteString("}")
result = result[:funcStart] + transformed.String() + result[braceEnd+1:]
}
// Find all exported hook functions (function declaration syntax)
// Match both with and without return type annotation
funcPattern := regexp.MustCompile(`(?s)export\s+function\s+(\w+)\s*\(([^)]*)\)\s*(?::\s*(\w+))?\s*\{`)
matches := funcPattern.FindAllStringSubmatchIndex(result, -1)
// Process matches in reverse order to preserve indices
for i := len(matches) - 1; i >= 0; i-- {
match := matches[i]
funcName := result[match[2]:match[3]]
params := result[match[4]:match[5]]
// Get return type if present (match[6]:match[7] may be -1 if not matched)
returnType := "void"
if match[6] != -1 && match[7] != -1 {
returnType = result[match[6]:match[7]]
}
// Only transform hook functions
if !isHookFunction(funcName) {
continue
}
// Track exported functions
exportedFuncs = append([]string{funcName}, exportedFuncs...)
// Parse parameters
eventParam, _, secretsParam, secretsType := parseParams(params)
// Find the matching closing brace for this function
funcStart := match[0]
// Find the opening brace position
bracePos := strings.Index(result[match[5]:], "{")
if bracePos == -1 {
continue
}
braceStart := match[5] + bracePos + 1
braceEnd := findClosingBrace(result, braceStart)
if braceEnd == -1 {
continue // Could not find closing brace
}
// Extract function body
body := result[braceStart:braceEnd]
// Build the transformed function (CommonJS style, no type annotations)
var transformed strings.Builder
// Function declaration without 'export' and without type annotations
// Use snake_case name for WASM export (extism-js requires this)
snakeFuncName := camelToSnake(funcName)
transformed.WriteString(fmt.Sprintf("function %s() {\n", snakeFuncName))
// Inject event deserialization if there was an event parameter
if eventParam != "" {
transformed.WriteString(fmt.Sprintf(" var %s = JSON.parse(Host.inputString());\n", eventParam))
}
// Inject secrets if present
if secretsParam != "" && secretsType != "" {
secretsCode := generateSecretsInjectionJS(source, secretsParam, secretsType)
transformed.WriteString(secretsCode)
}
// Transform the body:
// 1. Remove type annotations
// 2. Transform return statements if needed
transformedBody := stripTypeAnnotations(body)
if returnType != "void" {
transformedBody = transformReturnStatements(transformedBody, returnType)
}
// Add the body
transformed.WriteString(transformedBody)
// Ensure return 0 at the end if not already present
if !strings.Contains(transformedBody, "return 0;") && !strings.Contains(transformedBody, "return 0\n") {
transformed.WriteString("\n return 0;\n")
}
transformed.WriteString("}")
// Replace the original function with the transformed one
result = result[:funcStart] + transformed.String() + result[braceEnd+1:]
}
// Remove interface declarations (TypeScript-only)
interfacePattern := regexp.MustCompile(`(?s)interface\s+\w+\s*\{[^}]*\}\s*`)
result = interfacePattern.ReplaceAllString(result, "")
// Remove any remaining 'export' keywords
result = regexp.MustCompile(`\bexport\s+`).ReplaceAllString(result, "")
// Prepend Runner SDK wrapper
runnerSDK := `// Runner SDK wrapper for WriteKit plugins
var Runner = {
log: function(msg) { Host.log(msg); },
httpRequest: function(options) {
var method = options.method || "GET";
var headers = options.headers || {};
var req = { url: options.url, method: method, headers: headers };
var bodyBytes = options.body ? (new TextEncoder()).encode(options.body) : new Uint8Array(0);
var resp = Http.request(req, bodyBytes);
var respBody = "";
try { respBody = (new TextDecoder()).decode(resp.body()); } catch(e) {}
return { status: resp.status, body: respBody, headers: {} };
},
secrets: {
get: function(key) { return Config.get(key) || ""; }
}
};
`
// Add module.exports at the end with snake_case names (extism requires snake_case exports)
var exports strings.Builder
if len(exportedFuncs) > 0 {
exports.WriteString("\nmodule.exports = { ")
for i, fn := range exportedFuncs {
snakeName := camelToSnake(fn)
exports.WriteString(snakeName)
if i < len(exportedFuncs)-1 {
exports.WriteString(", ")
}
}
exports.WriteString(" };\n")
}
result = runnerSDK + strings.TrimSpace(result) + exports.String()
return result, nil
}
// stripTypeAnnotations removes TypeScript type annotations from code
func stripTypeAnnotations(code string) string {
// Remove variable type annotations: const x: Type = ... -> var x = ...
code = regexp.MustCompile(`\b(const|let)\s+(\w+)\s*:\s*\w+\s*=`).ReplaceAllString(code, "var $2 =")
// Remove 'as Type' casts
code = regexp.MustCompile(`\s+as\s+\w+`).ReplaceAllString(code, "")
// Remove generic type parameters: func<Type>(...) -> func(...)
code = regexp.MustCompile(`<\w+>`).ReplaceAllString(code, "")
// Replace const/let with var for remaining cases
code = regexp.MustCompile(`\b(const|let)\b`).ReplaceAllString(code, "var")
return code
}
// generateSecretsInjectionJS creates JavaScript code to build the secrets object
func generateSecretsInjectionJS(source, secretsParam, secretsType string) string {
// Find the interface definition for the secrets type
interfacePattern := regexp.MustCompile(fmt.Sprintf(`(?s)interface\s+%s\s*\{([^}]*)\}`, regexp.QuoteMeta(secretsType)))
match := interfacePattern.FindStringSubmatch(source)
if match == nil {
// If no interface found, use empty object
return fmt.Sprintf(" var %s = {};\n", secretsParam)
}
// Parse interface body for property names
interfaceBody := match[1]
propPattern := regexp.MustCompile(`(\w+)\s*:\s*string`)
props := propPattern.FindAllStringSubmatch(interfaceBody, -1)
if len(props) == 0 {
return fmt.Sprintf(" var %s = {};\n", secretsParam)
}
// Build the secrets object (JavaScript, no types)
var builder strings.Builder
builder.WriteString(fmt.Sprintf(" var %s = {\n", secretsParam))
for i, prop := range props {
key := prop[1]
builder.WriteString(fmt.Sprintf(" %s: Config.get(\"%s\") || \"\"", key, key))
if i < len(props)-1 {
builder.WriteString(",")
}
builder.WriteString("\n")
}
builder.WriteString(" };\n")
return builder.String()
}
// camelToSnake converts camelCase to snake_case
// e.g., onPostPublished -> on_post_published
func camelToSnake(s string) string {
var result strings.Builder
for i, r := range s {
if i > 0 && r >= 'A' && r <= 'Z' {
result.WriteByte('_')
}
result.WriteRune(r)
}
return strings.ToLower(result.String())
}
// isHookFunction checks if a function name matches a known hook pattern
func isHookFunction(name string) bool {
hooks := []string{
"onPostPublished",
"onPostUpdated",
"onCommentCreated",
"onMemberSubscribed",
"onAssetUploaded",
"onAnalyticsSync",
"validateComment",
"renderContent",
}
for _, hook := range hooks {
if name == hook {
return true
}
}
return false
}
// parseParams extracts event and secrets parameters from the function signature
func parseParams(params string) (eventParam, eventType, secretsParam, secretsType string) {
params = strings.TrimSpace(params)
if params == "" {
return
}
// Split by comma
parts := strings.Split(params, ",")
// First parameter is the event
if len(parts) >= 1 {
eventPart := strings.TrimSpace(parts[0])
// Match: paramName: ParamType
re := regexp.MustCompile(`(\w+)\s*:\s*(\w+)`)
if m := re.FindStringSubmatch(eventPart); m != nil {
eventParam = m[1]
eventType = m[2]
}
}
// Second parameter (if present) is secrets
if len(parts) >= 2 {
secretsPart := strings.TrimSpace(parts[1])
re := regexp.MustCompile(`(\w+)\s*:\s*(\w+)`)
if m := re.FindStringSubmatch(secretsPart); m != nil {
secretsParam = m[1]
secretsType = m[2]
}
}
return
}
// findClosingBrace finds the matching closing brace for an opening brace
func findClosingBrace(source string, start int) int {
depth := 1
for i := start; i < len(source); i++ {
switch source[i] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return i
}
}
}
return -1
}
// transformReturnStatements wraps return statements with Host.outputString(JSON.stringify())
func transformReturnStatements(body, returnType string) string {
// Match return statements with a value
// This is a simplified version - a real parser would be more robust
returnPattern := regexp.MustCompile(`return\s+(\{[^;]+\}|\w+)\s*;`)
return returnPattern.ReplaceAllStringFunc(body, func(match string) string {
// Extract the return value
re := regexp.MustCompile(`return\s+(.+);`)
m := re.FindStringSubmatch(match)
if m == nil {
return match
}
value := strings.TrimSpace(m[1])
// Use Host.outputString with JSON.stringify since Host.output<T> doesn't exist in PDK
return fmt.Sprintf("Host.outputString(JSON.stringify(%s)); return 0;", value)
})
}