Recipes
Short patterns for common plugin work. There is no high-level Go helper package yet; these snippets are the current reusable surface.
Read Options from --plugins-json
type PluginEntry struct {
Config map[string]any `json:"config"`
Name string `json:"name"`
Stage string `json:"stage"`
}
func parsePlugins(text string) ([]PluginEntry, error) {
var entries []PluginEntry
if err := json.Unmarshal([]byte(text), &entries); err != nil {
return nil, err
}
return entries, nil
}The Config map is the original tsconfig plugin entry. Plugin-owned options such as mode, calls, or text live inside Config, not beside name and stage.
Typed Config
For structured options, marshal the config map back into a typed struct:
type Config struct {
Text string `json:"text"`
Calls []string `json:"calls"`
}
func decodeConfig(raw map[string]any) (Config, error) {
var cfg Config
bytes, _ := json.Marshal(raw)
err := json.Unmarshal(bytes, &cfg)
return cfg, err
}Validate after decoding. Error loudly on wrong types.
Multiple Modes
One binary can support several modes:
func runModes(value string, plugins []PluginEntry) (string, error) {
var (
prefix *PluginEntry
uppercase bool
suffix *PluginEntry
)
for _, plugin := range plugins {
mode := stringOption(plugin.Config, "mode")
switch mode {
case "prefix":
entry := plugin
prefix = &entry
case "uppercase":
uppercase = true
case "suffix":
entry := plugin
suffix = &entry
default:
return "", fmt.Errorf("unsupported mode %q", mode)
}
}
if prefix != nil {
value = stringOption(prefix.Config, "prefix") + value
}
if uppercase {
value = strings.ToUpper(value)
}
if suffix != nil {
value += stringOption(suffix.Config, "suffix")
}
return value, nil
}
func stringOption(config map[string]any, key string) string {
value, _ := config[key].(string)
return value
}When modes need to cooperate inside one transform emit pass, keep those modes in one native binary and dispatch by explicit mode values. Check plugins are independent diagnostics passes.
Transform Plugin
Declare a transform plugin descriptor:
module.exports = {
name: "my-transform-plugin",
source: path.resolve(__dirname, "go-plugin"),
stage: "transform",
};Accept the transform-stage command set:
my-plugin check --cwd=/project --tsconfig=/project/tsconfig.json --plugins-json='[...]'
my-plugin transform --cwd=/project --tsconfig=/project/tsconfig.json --plugins-json='[...]'
my-plugin build --cwd=/project --tsconfig=/project/tsconfig.json --plugins-json='[...]'check may be a no-op for pure transforms. transform writes the transformed TypeScript JSON used by in-memory callers and bundler adapters. build loads the project, mutates TypeScript source AST, then lets TypeScript-Go print JavaScript, declarations, and source maps.
Check Plugin
Use stage: "check" for diagnostics before emit:
module.exports = {
name: "my-check-plugin",
source: path.resolve(__dirname, "go-plugin"),
stage: "check",
};Implement:
my-plugin check --cwd=/project --tsconfig=/project/tsconfig.json --plugins-json='[...]'Exit non-zero when error-severity diagnostics exist.
Text Edits
Collect edits as offsets, then apply in reverse order:
sort.SliceStable(edits, func(i, j int) bool {
return edits[i].start > edits[j].start
})Use shimscanner.SkipTrivia when the edit should start at the token, not leading comments or whitespace.
Warnings
Write warnings to stderr and exit 0:
fmt.Fprintf(os.Stderr, "my-plugin: warning: ignored unknown option %q\n", key)
return 0Write errors to stderr and exit non-zero:
fmt.Fprintf(os.Stderr, "my-plugin: %s: invalid config\n", tsconfig)
return 2Source Maps
Prefer AST transforms and TypeScript-Go printing so source maps stay owned by the compiler. The public plugin contract does not provide generated JavaScript text as a source-map-bearing edit target.
Watch Mode
ttsc --watch starts a fresh plugin process for each invocation. The source build cache avoids rebuilding the Go binary after the first run, but every rebuild still pays process startup and backend initialization. Keep state in files under outDir if needed; do not rely on process globals.
Next
β Testing