End-to-End: Source-Transform Plugin
This page builds on Getting started. The Hello plugin proved the bootstrap; this one removes debugger statements via real AST mutation, runs both the build and transform subcommands correctly, and shows the table-driven flag parser, the explicit consumer fixture, and the printer round-trip a real transform plugin needs.
After this works, compare with the friendly walkthroughs of @ttsc/banner and @ttsc/strip. They show the linked-package shape for transforms that can ride inside another native host; this page shows the executable shape for plugins that own Program creation. See Walkthroughs โ Where the real logic lives.
1. Create the Package
ttsc-plugin-debugger-strip/
โโโ package.json
โโโ plugin.cjs
โโโ plugin/
โโโ go.mod
โโโ main.gopackage.json:
{
"name": "ttsc-plugin-debugger-strip",
"version": "0.1.0",
"main": "plugin.cjs",
"ttsc": {
"plugin": {
"transform": "ttsc-plugin-debugger-strip"
}
},
"files": ["plugin.cjs", "plugin"],
"engines": {
"node": ">=18"
}
}The files field includes the Go source because ttsc builds it on the consumer machine. Published manifests keep ttsc and @typescript/native-preview out of package dependencies; the consumer project supplies them โ see Publishing โ Pin against ttsc for the peerDependencies pattern. The name field must equal the consumerโs dependency string for auto-discovery โ see Concepts โ Auto-discovery.
2. Write the Descriptor
plugin.cjs:
const path = require("node:path");
module.exports = function createDebuggerStripPlugin() {
return {
name: "ttsc-plugin-debugger-strip",
source: path.resolve(__dirname, "plugin"),
stage: "transform",
};
};There is no output stage. Generated JavaScript text is not the plugin API.
3. Write the Go Module
plugin/go.mod:
module ttsc-plugin-debugger-strip
go 1.26
require (
github.com/samchon/ttsc/packages/ttsc v0.0.0
github.com/microsoft/typescript-go/shim/ast v0.0.0
github.com/microsoft/typescript-go/shim/printer v0.0.0
)ttsc supplies these v0.0.0 modules through its generated go.work overlay while it builds the plugin. Add a require line for every shim package your plugin imports.
4. Implement the Plugin Commands
plugin/main.go:
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
shimast "github.com/microsoft/typescript-go/shim/ast"
shimprinter "github.com/microsoft/typescript-go/shim/printer"
"github.com/samchon/ttsc/packages/ttsc/driver"
)
type options struct {
cwd string
emit bool
noEmit bool
outDir string
tsconfig string
}
type transformResult struct {
Diagnostics []any `json:"diagnostics,omitempty"`
TypeScript map[string]string `json:"typescript"`
}
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "ttsc-plugin-debugger-strip: command required")
return 2
}
switch args[0] {
case "version", "-v", "--version":
fmt.Fprintln(os.Stdout, "ttsc-plugin-debugger-strip 0.1.0")
return 0
case "check":
return 0
case "transform":
return runTransform(args[1:])
case "build":
return runBuild(args[1:])
default:
fmt.Fprintf(os.Stderr, "ttsc-plugin-debugger-strip: unknown command %q\n", args[0])
return 2
}
}
func runBuild(args []string) int {
opts, ok := parseOptions("build", args)
if !ok {
return 2
}
prog, ok := loadProgram(opts)
if !ok {
return 2
}
defer prog.Close()
stripProgram(prog)
if opts.noEmit {
return 0
}
_, emitDiags, err := prog.EmitAllRaw(nil)
if err != nil {
fmt.Fprintf(os.Stderr, "ttsc-plugin-debugger-strip: emit failed: %v\n", err)
return 3
}
for _, diag := range emitDiags {
fmt.Fprintln(os.Stderr, diag.String())
}
if len(emitDiags) > 0 {
return 2
}
return 0
}
func runTransform(args []string) int {
opts, ok := parseOptions("transform", args)
if !ok {
return 2
}
prog, ok := loadProgram(opts)
if !ok {
return 2
}
defer prog.Close()
stripProgram(prog)
// The host treats each typescript[path] value as opaque text. To surface
// the AST mutation, render the file through tsgo's printer here.
// Returning file.Text() would round-trip the ORIGINAL source.
printer := shimprinter.NewPrinter(
shimprinter.PrinterOptions{},
shimprinter.PrintHandlers{},
nil,
)
out := transformResult{TypeScript: map[string]string{}}
for _, file := range prog.SourceFiles() {
if file == nil || file.IsDeclarationFile {
continue
}
out.TypeScript[outputKey(opts.cwd, file.FileName())] = shimprinter.EmitSourceFile(printer, file)
}
data, err := json.Marshal(out)
if err != nil {
fmt.Fprintf(os.Stderr, "ttsc-plugin-debugger-strip: transform marshal failed: %v\n", err)
return 3
}
fmt.Fprintln(os.Stdout, string(data))
return 0
}
func parseOptions(command string, args []string) (options, bool) {
fs := flag.NewFlagSet(command, flag.ContinueOnError)
fs.SetOutput(os.Stderr)
cwd := fs.String("cwd", "", "project directory")
emit := fs.Bool("emit", false, "force emit")
noEmit := fs.Bool("noEmit", false, "force no emit")
outDir := fs.String("outDir", "", "emit directory override")
tsconfig := fs.String("tsconfig", "tsconfig.json", "project tsconfig")
_ = fs.String("plugins-json", "", "ttsc plugin metadata")
_ = fs.Bool("quiet", true, "suppress summary")
_ = fs.Bool("verbose", false, "print summary")
if err := fs.Parse(filterHostArgs(args, fs)); err != nil {
return options{}, false
}
root := *cwd
if root == "" {
var err error
root, err = os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, err)
return options{}, false
}
}
if !filepath.IsAbs(root) {
abs, err := filepath.Abs(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return options{}, false
}
root = abs
}
return options{
cwd: filepath.Clean(root),
emit: *emit,
noEmit: *noEmit,
outDir: *outDir,
tsconfig: *tsconfig,
}, true
}
// valueFlags is the closed set of host flags passed with separate values
// (space-form). New ttsc minors may add flags; the table is the safe seam.
var valueFlags = map[string]bool{
"--cwd": true,
"--tsconfig": true,
"--outDir": true,
"--plugins-json": true,
}
// filterHostArgs drops flags the FlagSet does not know, keeping known flags
// (and their separately-passed values) intact for fs.Parse. This is the same
// shape ttsc's own utility host uses; the alternative โ a HasPrefix(args[i+1],
// "-") heuristic โ silently swallows positional args whenever an unknown
// flag's value starts with "-".
func filterHostArgs(args []string, fs *flag.FlagSet) []string {
out := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
a := args[i]
if !strings.HasPrefix(a, "-") {
out = append(out, a)
continue
}
name, _, hasEq := strings.Cut(a, "=")
if fs.Lookup(strings.TrimLeft(name, "-")) != nil {
out = append(out, a)
if !hasEq && valueFlags[name] && i+1 < len(args) {
out = append(out, args[i+1])
i++
}
continue
}
// Unknown flag: skip it and (if it's a known value-flag) its value.
if !hasEq && valueFlags[name] && i+1 < len(args) {
i++
}
}
return out
}
func loadProgram(opts options) (*driver.Program, bool) {
prog, parseDiags, err := driver.LoadProgram(opts.cwd, opts.tsconfig, driver.LoadProgramOptions{
ForceEmit: opts.emit,
ForceNoEmit: opts.noEmit,
OutDir: opts.outDir,
})
if err != nil {
fmt.Fprintf(os.Stderr, "ttsc-plugin-debugger-strip: %v\n", err)
return nil, false
}
if len(parseDiags) > 0 {
driver.WritePrettyDiagnostics(os.Stderr, parseDiags, opts.cwd)
prog.Close()
return nil, false
}
if diags := prog.Diagnostics(); len(diags) > 0 {
driver.WritePrettyDiagnostics(os.Stderr, diags, opts.cwd)
prog.Close()
return nil, false
}
return prog, true
}
func stripProgram(prog *driver.Program) {
for _, file := range prog.SourceFiles() {
removeDebuggers(file.Statements)
}
}
func outputKey(cwd, fileName string) string {
rel, err := filepath.Rel(cwd, fileName)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return filepath.ToSlash(fileName)
}
return filepath.ToSlash(rel)
}
func removeDebuggers(list *shimast.NodeList) {
if list == nil || len(list.Nodes) == 0 {
return
}
out := list.Nodes[:0]
for _, stmt := range list.Nodes {
if stmt.Kind == shimast.KindDebuggerStatement {
continue
}
removeDebuggersFromChildren(stmt)
out = append(out, stmt)
}
list.Nodes = out
}
func removeDebuggersFromChildren(node *shimast.Node) {
if node == nil {
return
}
if node.CanHaveStatements() {
removeDebuggers(node.StatementList())
}
node.ForEachChild(func(child *shimast.Node) bool {
removeDebuggersFromChildren(child)
return false
})
}What matters:
- The plugin changes TypeScript AST, not emitted text.
driver.LoadProgramlets TypeScript-Go parse and typecheck the real project.EmitAllRawlets TypeScript-Go own JavaScript, declaration, and source-map printing after the AST mutation (thebuildpath).shimprinter.EmitSourceFilerenders the mutated AST per file for thetransformpath. The host treats thetypescript[path]value as opaque text โ see Plugin protocol โ Transform.- This sample mutates statement lists (structural mutation). Leaf-text mutations (identifiers, string literals, numeric literals) need a synthesize-flag invariant or the printer silently re-reads the original source โ see Recipes โ Mutating leaf-text nodes.
- Optional flags are accepted even when unused. Future
ttscminors may add more optional flags;filterHostArgsdrops unknown ones safely.
5. Use It
Consumer install:
npm i -D ttsc @typescript/native-preview /path/to/ttsc-plugin-debugger-stripConsumer package.json:
{
"name": "debugger-strip-consumer",
"private": true,
"devDependencies": {
"ttsc": "^0.11.0",
"@typescript/native-preview": "^0.4.0",
"ttsc-plugin-debugger-strip": "0.1.0"
}
}Consumer tsconfig.json โ use the explicit form so the wiring is unambiguous:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"plugins": [{ "transform": "ttsc-plugin-debugger-strip" }]
},
"include": ["src"]
}Consumer src/main.ts:
debugger;
export const value = 1;Run:
npx ttsc --emitThe first run builds and caches the Go binary. Later runs reuse it until the plugin source, ttsc version, TypeScript-Go version, platform, or source entry changes. The emitted dist/main.js should contain value but not the debugger statement.
Run the transform path:
npx ttsc transformThe JSON written to stdout under typescript["src/main.ts"] should contain export const value = 1; without debugger;.
Next Step
For production-quality versions of this shape, read the friendly walkthroughs of @ttsc/strip and @ttsc/banner. They use linked driver/ packages when the transform can ride inside another native host; the executable shape in this guide remains the right starting point when your plugin owns Program creation. The @ttsc/paths walkthrough is the next step up in difficulty, and the @ttsc/lint deep dive is the right reference once your plugin grows past one file.
โ Recipes โ auto-discovery, driver.NewLintDiagnostic, leaf-text mutation invariant, re-spawning ttsx / tsgo.
โ AST & Checker โ deeper AST traversal, Checker queries, and the synthesize-flag rule for leaf-text mutations.
โ Pitfalls โ first-hour build failures.