Skip to Content

@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 package

The 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. __dirname is the directory holding the compiled lib/index.js; .. steps up to the package root; driver is the Go source folder that calls driver.RegisterPlugin.
  • stage: "transform" — declares this plugin participates in the AST transform path. The host will spawn transform, build, and check subcommands. Omit stage and 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.0

Two things worth noting for Go newcomers:

  • module declares 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.0 is a placeholder. ttsc synthesizes a workspace overlay at build time that resolves github.com/samchon/ttsc/packages/ttsc v0.0.0 to the locally-installed node_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.

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 main

In 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 failurettsc interprets exit codes per Plugin protocol → Exit and Output.

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 switch does not fall through to the next case unless you write fallthrough. Each case is implicitly broken.
  • Comma-separated case labels. case "-v", "--version", "version": matches any of the three strings — a concise way to handle aliases.
  • %q quotes 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:

  1. init calls driver.RegisterPlugin(plugin{}), making this package visible to whichever native host links it.
  2. SourcePreamble(ctx driver.PluginContext) receives the paired project plugin entry and calls parseBanner(ctx.Entry.Config, ctx.Cwd, ctx.Tsconfig).
  3. parseBanner delegates to resolveBannerText for the actual three-source resolution, then formats the resulting text into a JSDoc /** ... @packageDocumentation */ block. resolveBannerText tries, in order:
    • config["text"] inline string;
    • config["config"] path to a banner.config.* file;
    • upward filesystem walk from the tsconfig directory looking for any banner.config.{js,cjs,mjs,ts,mts,cts}.
  4. driver.LoadProgram in the native host asks every registered SourcePreamblePlugin for 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.
  5. makeSourcePreambleWriteFile in the generic host wraps tsgo’s emit WriteFile callback. If the emitted file does not already contain the preamble text (e.g. .d.ts files that tsgo generated from declarations rather than from the source text), the wrapper inserts it via driver.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.

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.

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 mainrunswitch shape. 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-json flag 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.go wrapper when your descriptor points directly at an executable command package.
  • Position-based linked registration unless your descriptor source is a non-main package. Executable plugins usually read their own --plugins-json payload and call driver.LoadProgram directly.

Beginner annotations — Go constructs introduced on this page

  • package main — declares an executable program; the linker looks for main().
  • 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[]string of 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. %q quotes a string; %v prints any value’s default form.
  • return N in main’s helper — propagates an exit code; os.Exit(N) then ends the process.
  • 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. Same main.go shape; the real logic is your first AST mutation.
Last updated on