@ttsc/banner — Source Preamble Insertion
@ttsc/banner is the simplest shipped transform plugin. It does one thing: insert a JSDoc @packageDocumentation block at the top of every emitted .js and .d.ts file. Go side-notes are heaviest on this page — subsequent pages assume the vocabulary it introduces.
What it does for the consumer
Given a project that opts into @ttsc/banner:
// tsconfig.json
{
"compilerOptions": {
"outDir": "dist",
"plugins": [{ "transform": "@ttsc/banner" }]
}
}// banner.config.ts
import type { TtscBannerConfig } from "@ttsc/banner";
export default {
text: "License MIT\nCopyright 2026 Example Corp",
} satisfies TtscBannerConfig;Running npx ttsc --emit produces:
// dist/index.js
/**
* ----------------------------------------------------------------
* License MIT
* Copyright 2026 Example Corp
*
* @packageDocumentation
*/
export const value = 1;The same @packageDocumentation block lands at the top of every emitted file: .js, .d.ts, .mjs, .cjs, etc.
The consumer can also pass text inline on the compilerOptions.plugins entry, or point at a specific config file with config: "./path/to/banner.config.ts". If neither is given, @ttsc/banner walks upward from the tsconfig directory looking for banner.config.{js,cjs,mjs,ts,mts,cts}.
If nothing is configured, the build fails — @ttsc/banner deliberately refuses to silently insert nothing.
Directory layout
packages/banner/
├── package.json ← npm manifest with the ttsc auto-discovery marker
├── src/
│ ├── index.ts ← JS descriptor — compiled to lib/index.js
│ └── structures/
│ ├── ITtscBannerPluginConfig.ts
│ ├── TtscBannerConfig.ts
│ └── index.ts
├── go.mod ← Go module declaration for the sidecar
├── driver/
│ └── banner.go ← Linked transform logic
└── plugin/
├── main.go ← Native sidecar entrypoint — 36 lines
└── banner.go ← Blank-imports the linked driver packageThe Go source ships in the npm tarball thanks to the files array in package.json:
"files": [
"README.md",
"lib",
"src",
"go.mod",
"driver/banner.go",
"plugin/banner.go",
"plugin/main.go"
]The descriptor points at driver/, a linked Go package. ttsc builds a generic native host plus this package on the consumer machine the first time the plugin is loaded, then caches the binary. See Architecture for the full cache key.
The JS descriptor (src/index.ts)
import path from "node:path";
import type { ITtscBannerPluginConfig } from "./structures";
export * from "./structures/index";
type TtscPluginDescriptor = {
name: string;
source: string;
stage?: "check" | "transform";
};
type TtscPluginFactoryContext<TConfig> = {
binary: string;
cwd: string;
plugin: TConfig;
projectRoot: string;
tsconfig: string;
};
export default function createTtscBanner(
_context: TtscPluginFactoryContext<ITtscBannerPluginConfig>,
): TtscPluginDescriptor {
return {
name: "@ttsc/banner",
source: path.resolve(__dirname, "..", "driver"),
stage: "transform",
};
}Three load-bearing lines:
name: "@ttsc/banner"— optional diagnostic label. Routing is not based on this value.source: path.resolve(__dirname, "..", "driver")— absolute path to the linked Go package.__dirnameis the directory holding the compiledlib/index.js;..steps up to the package root;driveris the Go source folder that callsdriver.RegisterPlugin.stage: "transform"— declares this plugin participates in the AST transform path. The host will spawntransform,build, andchecksubcommands. Omitstageand you get the same default.
The factory takes a TtscPluginFactoryContext parameter but ignores it — banner has no factory-time decisions to make. Other plugins use it to specialise at descriptor creation time: context.tsconfig is the absolute path of the active tsconfig, context.cwd is the resolved project directory, and context.plugin is the original compilerOptions.plugins[i] entry (typed by your plugin’s option interface). @ttsc/lint’s factory reads context.plugin.extends and context.plugin.plugins to discover contributor packages; banner has nothing analogous to do.
Defining TtscPluginDescriptor and TtscPluginFactoryContext inline (instead of importing them from ttsc) keeps banner’s package.json free of a ttsc dependency in the published tarball. The shapes are stable; copying them is the documented escape hatch (Publishing → Pin against ttsc).
go.mod
module github.com/samchon/ttsc/packages/banner
go 1.26
require github.com/samchon/ttsc/packages/ttsc v0.0.0Two things worth noting for Go newcomers:
moduledeclares this package’s import path. It must match what other Go files use when importing this module. For published plugins inside the ttsc monorepo, we use the GitHub path; standalone plugins can pick anything as long as it does not collide with a real fetchable module.v0.0.0is a placeholder.ttscsynthesizes a workspace overlay at build time that resolvesgithub.com/samchon/ttsc/packages/ttsc v0.0.0to the locally-installednode_modules/ttsc/directory. The Go toolchain never tries to download this version from a registry. The mechanism is documented in Architecture → Cold build path.
@ttsc/banner requires only one external module here because every shim package it eventually imports is reached through github.com/samchon/ttsc/packages/ttsc/utility. A plugin that imports shim packages directly must add a require line for each one — see End-to-end transform.
Sidebar: what is a shim/?
shim/ast, shim/checker, shim/compiler, shim/printer, shim/scanner (and a few more) are TypeScript-Go’s internal AST, type checker, compiler, printer, and scanner packages, re-exported as a narrow boundary under github.com/microsoft/typescript-go/shim/.... Plugins import these packages to read the same AST that tsgo reads. The full surface is documented at AST & Checker → Shim Model; for now, treat “shim” as ttsc’s word for “stable TypeScript-Go API.” Banner does not import any shim directly because source-preamble insertion is handled through the driver.SourcePreamblePlugin hook. The later walkthroughs use shims explicitly.
The sidecar (plugin/main.go)
This is the file ttsc actually builds and spawns. The entire content:
// Native sidecar entrypoint for `@ttsc/banner`.
package main
import (
"fmt"
"os"
"github.com/samchon/ttsc/packages/ttsc/utility"
)
const version = "0.0.1"
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "@ttsc/banner: command required (expected build|transform|check|version)")
return 2
}
switch args[0] {
case "-v", "--version", "version":
fmt.Fprintf(os.Stdout, "@ttsc/banner %s\n", version)
return 0
case "build":
return utility.RunBuild(args[1:])
case "transform":
return utility.RunTransform(args[1:])
case "check":
return utility.RunCheck(args[1:])
default:
fmt.Fprintf(os.Stderr, "@ttsc/banner: unknown command %q\n", args[0])
return 2
}
}Read it top to bottom:
Package declaration
package mainIn Go, every file belongs to a package. A package named main is special: it produces an executable binary instead of an importable library. The compiler looks for a main() function and wires that as the program entrypoint.
Imports
import (
"fmt"
"os"
"github.com/samchon/ttsc/packages/ttsc/utility"
)The import block lists every package this file uses. fmt and os are part of Go’s standard library; utility is the generic host used by the standalone sidecar entrypoint.
Go convention puts standard-library imports first, then a blank line, then third-party imports. gofmt enforces it.
main() and os.Args
func main() {
os.Exit(run(os.Args[1:]))
}main is the program entrypoint. os.Args is a slice of strings — every command-line argument including the binary path at os.Args[0]. os.Args[1:] drops the binary path; the same slice every Unix program uses for “the user’s args.”
The pattern os.Exit(run(...)) keeps main trivial and pushes the real logic into a testable function. run returns an int exit code, and os.Exit propagates it to the OS. Plugin authors should always exit non-zero on failure — ttsc interprets exit codes per Plugin protocol → Exit and Output.
Sidebar: why a separate run function?
os.Exit immediately terminates the process — defer statements do not run, and Go’s testing framework cannot recover. By keeping the side-effecting call in main and the logic in run, the testable function:
- can return an int instead of calling
os.Exit; - can be invoked from Go unit tests with different argv slices;
- still lets every plugin in this monorepo follow the same shape.
Every shipped plugin’s main.go uses this idiom. Copy it.
Dispatch on the first argument
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "@ttsc/banner: command required (expected build|transform|check|version)")
return 2
}
switch args[0] {
case "-v", "--version", "version":
...
case "build":
return utility.RunBuild(args[1:])
case "transform":
return utility.RunTransform(args[1:])
case "check":
return utility.RunCheck(args[1:])
default:
fmt.Fprintf(os.Stderr, "@ttsc/banner: unknown command %q\n", args[0])
return 2
}The host always spawns the plugin with a subcommand as the first argument: transform, build, check. (For check-stage plugins the host also spawns fix and format; for transform-stage plugins like banner those subcommands never arrive, which is why they hit the default and exit 2.)
The switch is Go’s case statement. Notable details:
- No fall-through by default. Unlike C/JavaScript, Go’s
switchdoes not fall through to the next case unless you writefallthrough. Eachcaseis implicitly broken. - Comma-separated case labels.
case "-v", "--version", "version":matches any of the three strings — a concise way to handle aliases. %qquotes the value.fmt.Fprintf(... %q ...)prints the string with surrounding quotes and escaped characters. Useful for error messages so an empty string or a tab character is visible.
Error printing
fmt.Fprintln(os.Stderr, "@ttsc/banner: command required ...")fmt.Fprintln(w io.Writer, ...) writes to any writer. Errors go to os.Stderr; the host prints stderr verbatim to the user. Successful protocol output goes to os.Stdout (mainly transform’s JSON envelope).
Convention. Prefix every plugin error with the plugin name (@ttsc/banner: ...) so users grepping ttsc’s output can find the source.
Where the actual banner logic is
The case branches all call into utility.Run*:
case "build": return utility.RunBuild(args[1:])
case "transform": return utility.RunTransform(args[1:])
case "check": return utility.RunCheck(args[1:])These three functions live in packages/ttsc/utility/host.go — the generic host used when a linked transform package needs an executable wrapper. The banner-specific code is not in this file; it is registered by packages/banner/driver/banner.go.
args[1:] drops the subcommand (args[0]) and passes the remaining flags. The flag set parsed by utility.Run* includes --cwd, --tsconfig, --plugins-json, --emit, --noEmit, --outDir, --quiet, --verbose. The same flags every plugin in this repo accepts.
plugin/banner.go
package main
import _ "github.com/samchon/ttsc/packages/banner/driver"The standalone plugin/ command blank-imports driver/ so the driver’s init() registration runs before main. When ttsc links driver/ into its generic host, this wrapper file is not part of the host build.
The actual transform logic — a brief tour
The full transform code lives in packages/banner/driver/banner.go. For banner, the relevant slice is:
initcallsdriver.RegisterPlugin(plugin{}), making this package visible to whichever native host links it.SourcePreamble(ctx driver.PluginContext)receives the paired project plugin entry and callsparseBanner(ctx.Entry.Config, ctx.Cwd, ctx.Tsconfig).parseBannerdelegates toresolveBannerTextfor the actual three-source resolution, then formats the resulting text into a JSDoc/** ... @packageDocumentation */block.resolveBannerTexttries, in order:config["text"]inline string;config["config"]path to abanner.config.*file;- upward filesystem walk from the tsconfig directory looking for any
banner.config.{js,cjs,mjs,ts,mts,cts}.
driver.LoadProgramin the native host asks every registeredSourcePreamblePluginfor text before parsing. From that point onward, every source file in the Program already contains the JSDoc block at offset 0 — the printer round-trips it naturally.makeSourcePreambleWriteFilein the generic host wrapstsgo’s emitWriteFilecallback. If the emitted file does not already contain the preamble text (e.g..d.tsfiles that tsgo generated from declarations rather than from the source text), the wrapper inserts it viadriver.ApplySourcePreamble.
The pattern is: prefer source-level transformations over post-emit edits. By overlaying the preamble at parse time, banner never has to deal with sourcemaps, declaration emit, or formatting variations — tsgo treats the preamble as ordinary source text.
The hairy bit is loading TypeScript config files (banner.config.ts). Because the config is TypeScript and may use ESM imports, banner spawns ttsx (injected as TTSC_TTSX_BINARY — see Architecture → Injected into the plugin process) to evaluate it, then captures the result through stdout JSON. This is loadBannerTypeScriptConfigFile — about 60 lines of “synthesize a tempdir, link node_modules, write a tiny loader, run it, parse the JSON it prints.” Worth reading once for the pattern; not something you typically reimplement.
Sidebar: defer prog.Close() is a hard rule
utility.RunBuild hides the Program lifecycle behind a helper, but the moment you copy this shape with driver.LoadProgram, you own the leased Checker. Every driver.LoadProgram call must be paired with defer prog.Close() on the very next line:
prog, _, err := driver.LoadProgram(cwd, tsconfig, driver.LoadProgramOptions{})
if err != nil { return 2 }
defer prog.Close()Forget it and the lease leaks back to the pool. After a handful of invocations subsequent loads stall silently — the symptom is ttsc --watch hanging without printing a diagnostic. Banner gets away without writing the line because the close lives inside utility.RunBuild; your third-party plugin will not.
Sidebar: why exit 2 on fix / format
The default branch in run rejects fix and format with exit 2 because the host only spawns those subcommands against stage: "check" plugins. If a transform-stage plugin’s dispatcher ever receives one, something is wrong upstream and the safe move is “fail loud.” Conversely, a check-stage plugin that does not implement fix should also exit non-zero with a human-readable stderr message — the host treats every non-zero exit as failure and prints stderr verbatim, with no special-cased phrase (Plugin protocol → Unsupported fix/format). Returning 0 from an unknown subcommand is a bug: ttsc fix would silently no-op and the user would never know.
What to copy, what to ignore for your own plugin
Copy:
- The
main→run→switchshape. Every shipped plugin uses it; so do the canonical fixtures. - The “exit 2 on unknown subcommand” convention. The host treats every non-zero exit as failure; 2 is the universally-understood “argument or config problem” code.
- The “prefix every stderr line with the plugin name” convention.
- The
--cwd/--tsconfig/--plugins-jsonflag set — these are the host-provided flags every plugin receives (see Plugin protocol → CLI Commands).
Do not copy:
- The package-specific banner config loader unless your plugin really needs TypeScript config-file execution.
- The
plugin/banner.gowrapper when your descriptor points directly at an executable command package. - Position-based linked registration unless your descriptor source is a non-
mainpackage. Executable plugins usually read their own--plugins-jsonpayload and calldriver.LoadProgramdirectly.
Beginner annotations — Go constructs introduced on this page
package main— declares an executable program; the linker looks formain().import— imports a package; the path uses forward slashes regardless of OS.func name(args) returnType— function declaration. Multiple return values are common:func foo() (string, error).os.Args—[]stringof command-line arguments, including the binary path at[0].switch x { case a: ... case b: ... default: ... }— like C/Java switch but without implicit fall-through.fmt.Fprintf(w, format, ...)— printf-style formatted write.%qquotes a string;%vprints any value’s default form.return Ninmain’s helper — propagates an exit code;os.Exit(N)then ends the process.
Where to read next
packages/banner/test— Go tests that drive the sidecar binary against fixture projects, one assertion per file.@ttsc/strip— the next step in this tour. Samemain.goshape; the real logic is your first AST mutation.