init
This commit is contained in:
commit
91a950e72f
17 changed files with 2724 additions and 0 deletions
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