@ttsc/banner 0.12.3 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,45 +20,56 @@ npm install -D ttsc @typescript/native-preview
20
20
  npm install -D @ttsc/banner
21
21
  ```
22
22
 
23
- Pass the banner text inline on the `compilerOptions.plugins[]` entry:
23
+ Register the plugin in your `tsconfig.json`:
24
24
 
25
25
  ```jsonc
26
26
  // tsconfig.json
27
27
  {
28
28
  "compilerOptions": {
29
29
  "plugins": [
30
- { "transform": "@ttsc/banner", "text": "License MIT (c) 2026 Acme" }
30
+ { "transform": "@ttsc/banner" }
31
31
  ]
32
32
  }
33
33
  }
34
34
  ```
35
35
 
36
- Or keep the text in a separate file `banner.config.ts` next to your tsconfig:
36
+ Drop a `banner.config.ts` next to your `tsconfig.json`:
37
37
 
38
38
  ```ts
39
39
  // banner.config.ts
40
- import type { TtscBannerConfig } from "@ttsc/banner";
40
+ import type { ITtscBannerConfig } from "@ttsc/banner";
41
41
 
42
42
  export default {
43
43
  text: "License MIT (c) 2026 Acme",
44
- } satisfies TtscBannerConfig;
44
+ } satisfies ITtscBannerConfig;
45
45
  ```
46
46
 
47
+ A `banner.config.*` file always exports an object with a `text` string.
48
+
47
49
  Run your normal `ttsc` command:
48
50
 
49
51
  ```bash
50
52
  npx ttsc
51
53
  ```
52
54
 
53
- If `@ttsc/banner` is installed and none of the three text sources (inline `text:`, `config:` path, or an auto-discovered `banner.config.*`) yields a banner, the compile fails.
55
+ If `@ttsc/banner` is installed and no `banner.config.*` file is found, the compile fails.
54
56
 
55
57
  ## Configuration
56
58
 
57
- `@ttsc/banner` resolves its text in this order:
59
+ `@ttsc/banner` discovers its config by walking upward from the tsconfig directory, looking for `banner.config.{ts,cts,mts,js,cjs,mjs,json}`.
60
+
61
+ To point at a specific file instead of using auto-discovery, set `configFile` on the tsconfig entry:
58
62
 
59
- 1. the entry's inline `text` string;
60
- 2. the entry's `config: "./path/to/banner.config.ts"` path;
61
- 3. an upward walk for any `banner.config.{js,cjs,mjs,ts,mts,cts}` starting from the tsconfig directory.
63
+ ```jsonc
64
+ // tsconfig.json
65
+ {
66
+ "compilerOptions": {
67
+ "plugins": [
68
+ { "transform": "@ttsc/banner", "configFile": "./config/banner.config.ts" }
69
+ ]
70
+ }
71
+ }
72
+ ```
62
73
 
63
74
  The plugin formats every line of the resolved text inside a JSDoc block and appends `@packageDocumentation`.
64
75
 
package/driver/banner.go CHANGED
@@ -1,215 +1,297 @@
1
1
  package banner
2
2
 
3
3
  import (
4
- "encoding/json"
5
- "fmt"
6
- "os"
7
- "os/exec"
8
- "path/filepath"
9
- "strings"
10
-
11
- "github.com/samchon/ttsc/packages/ttsc/driver"
4
+ "bytes"
5
+ "encoding/json"
6
+ "fmt"
7
+ "os"
8
+ "os/exec"
9
+ "path/filepath"
10
+ "strings"
11
+
12
+ "github.com/samchon/ttsc/packages/ttsc/driver"
12
13
  )
13
14
 
14
15
  func init() {
15
- driver.RegisterPlugin(plugin{})
16
+ driver.RegisterPlugin(plugin{})
16
17
  }
17
18
 
19
+ // plugin implements driver.SourcePreamblePlugin for @ttsc/banner.
18
20
  type plugin struct{}
19
21
 
20
22
  var (
21
- linkConfigNodeModules = linkNearestNodeModules
22
- writeConfigLoaderFile = os.WriteFile
23
+ // linkConfigNodeModules is overridable in tests to avoid real symlink creation.
24
+ linkConfigNodeModules = linkNearestNodeModules
25
+ // writeConfigLoaderFile is overridable in tests to avoid real file I/O.
26
+ writeConfigLoaderFile = os.WriteFile
23
27
  )
24
28
 
29
+ // frameworkKeys lists the tsconfig plugin-entry keys that the ttsc host
30
+ // framework owns. They are accepted without error; all other keys are rejected.
31
+ var frameworkKeys = map[string]struct{}{
32
+ "enabled": {},
33
+ "name": {},
34
+ "stage": {},
35
+ "transform": {},
36
+ }
37
+
38
+ // validateBannerConfig rejects any tsconfig plugin entry key that is not a
39
+ // known framework key and is not the single banner-specific "configFile" key.
40
+ func validateBannerConfig(config map[string]any) error {
41
+ for key := range config {
42
+ if _, ok := frameworkKeys[key]; ok {
43
+ continue
44
+ }
45
+ if key == "configFile" {
46
+ continue
47
+ }
48
+ return fmt.Errorf(
49
+ "@ttsc/banner: tsconfig plugin entry contains unsupported key %q. "+
50
+ "Banner options must be placed in a banner.config.{ts,cts,mts,js,cjs,mjs,json} file. "+
51
+ "The only accepted key in the tsconfig entry is \"configFile\" (optional path to the config file).",
52
+ key,
53
+ )
54
+ }
55
+ return nil
56
+ }
57
+
58
+ // SourcePreamble resolves the banner text from the plugin config and returns it
59
+ // formatted as a JSDoc block comment suitable for prepending to each emitted file.
25
60
  func (plugin) SourcePreamble(ctx driver.PluginContext) (string, error) {
26
- return parseBanner(ctx.Entry.Config, ctx.Cwd, ctx.Tsconfig)
61
+ return parseBanner(ctx.Entry.Config, ctx.Cwd, ctx.Tsconfig)
27
62
  }
28
63
 
64
+ // parseBanner resolves and formats banner text into a JSDoc block comment.
65
+ // Trailing blank lines are stripped from the resolved text before formatting.
29
66
  func parseBanner(config map[string]any, cwd, tsconfigPath string) (string, error) {
30
- text, err := resolveBannerText(config, cwd, tsconfigPath)
31
- if err != nil {
32
- return "", err
33
- }
34
- lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
35
- for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
36
- lines = lines[:len(lines)-1]
37
- }
38
- var b strings.Builder
39
- sep := strings.Repeat("-", 64)
40
- b.WriteString("/**\n")
41
- b.WriteString(" * ")
42
- b.WriteString(sep)
43
- b.WriteByte('\n')
44
- for _, line := range lines {
45
- b.WriteString(" * ")
46
- b.WriteString(sanitizeJSDocLine(line))
47
- b.WriteByte('\n')
48
- }
49
- b.WriteString(" *\n")
50
- b.WriteString(" * @packageDocumentation\n ")
51
- b.WriteString("*/\n")
52
- return b.String(), nil
67
+ text, err := resolveBannerText(config, cwd, tsconfigPath)
68
+ if err != nil {
69
+ return "", err
70
+ }
71
+ lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
72
+ for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
73
+ lines = lines[:len(lines)-1]
74
+ }
75
+ var b strings.Builder
76
+ sep := strings.Repeat("-", 64)
77
+ b.WriteString("/**\n")
78
+ b.WriteString(" * ")
79
+ b.WriteString(sep)
80
+ b.WriteByte('\n')
81
+ for _, line := range lines {
82
+ b.WriteString(" * ")
83
+ b.WriteString(sanitizeJSDocLine(line))
84
+ b.WriteByte('\n')
85
+ }
86
+ b.WriteString(" *\n")
87
+ b.WriteString(" * @packageDocumentation\n ")
88
+ b.WriteString("*/\n")
89
+ return b.String(), nil
53
90
  }
54
91
 
92
+ // resolveBannerText extracts the banner text from the plugin config.
93
+ // The config entry is validated first: only the "configFile" key (plus
94
+ // framework keys) is accepted. When "configFile" is present its value is
95
+ // resolved to an absolute path and loaded. When absent the upward-walk
96
+ // discovery is used. Returns an error when the config is invalid or when
97
+ // no banner text can be found.
55
98
  func resolveBannerText(config map[string]any, cwd, tsconfigPath string) (string, error) {
56
- if text, ok, err := bannerTextFromConfigValue(config["text"], `"text"`); ok || err != nil {
57
- return text, err
58
- }
59
- if rawConfigPath, ok := config["config"]; ok {
60
- configPath, ok := rawConfigPath.(string)
61
- if !ok || strings.TrimSpace(configPath) == "" {
62
- return "", fmt.Errorf("@ttsc/banner: \"config\" must be a non-empty string path")
63
- }
64
- location := resolveBannerConfigPath(configPath, cwd, tsconfigPath)
65
- raw, err := loadBannerConfigFile(location)
66
- if err != nil {
67
- return "", err
68
- }
69
- text, ok, err := bannerTextFromConfigValue(raw, filepath.Base(location))
70
- if err != nil {
71
- return "", err
72
- }
73
- if !ok {
74
- return "", fmt.Errorf("@ttsc/banner: %s must export a non-empty string or an object with a non-empty \"text\" string", location)
75
- }
76
- return text, nil
77
- }
78
- location, err := findBannerConfigFile(cwd, tsconfigPath)
79
- if err != nil {
80
- return "", err
81
- }
82
- if location == "" {
83
- return "", fmt.Errorf("@ttsc/banner: \"text\" must be a non-empty string or a banner.config.{js,cjs,mjs,ts,mts,cts} file must exist")
84
- }
85
- raw, err := loadBannerConfigFile(location)
86
- if err != nil {
87
- return "", err
88
- }
89
- text, ok, err := bannerTextFromConfigValue(raw, filepath.Base(location))
90
- if err != nil {
91
- return "", err
92
- }
93
- if !ok {
94
- return "", fmt.Errorf("@ttsc/banner: %s must export a non-empty string or an object with a non-empty \"text\" string", location)
95
- }
96
- return text, nil
99
+ if err := validateBannerConfig(config); err != nil {
100
+ return "", err
101
+ }
102
+
103
+ if rawConfigFile, ok := config["configFile"]; ok {
104
+ configFile, ok := rawConfigFile.(string)
105
+ if !ok || strings.TrimSpace(configFile) == "" {
106
+ return "", fmt.Errorf("@ttsc/banner: \"configFile\" must be a non-empty string path")
107
+ }
108
+ location := resolveBannerConfigPath(configFile, cwd, tsconfigPath)
109
+ raw, err := loadBannerConfigFile(location)
110
+ if err != nil {
111
+ return "", err
112
+ }
113
+ text, ok, err := bannerTextFromConfigValue(raw, filepath.Base(location))
114
+ if err != nil {
115
+ return "", err
116
+ }
117
+ if !ok {
118
+ return "", fmt.Errorf("@ttsc/banner: %s must export an object with a non-empty \"text\" string", location)
119
+ }
120
+ return text, nil
121
+ }
122
+
123
+ location, err := findBannerConfigFile(cwd, tsconfigPath)
124
+ if err != nil {
125
+ return "", err
126
+ }
127
+ if location == "" {
128
+ return "", fmt.Errorf("@ttsc/banner: no banner.config.{ts,cts,mts,js,cjs,mjs,json} file found; create one or set \"configFile\" in the tsconfig plugin entry")
129
+ }
130
+ raw, err := loadBannerConfigFile(location)
131
+ if err != nil {
132
+ return "", err
133
+ }
134
+ text, ok, err := bannerTextFromConfigValue(raw, filepath.Base(location))
135
+ if err != nil {
136
+ return "", err
137
+ }
138
+ if !ok {
139
+ return "", fmt.Errorf("@ttsc/banner: %s must export an object with a non-empty \"text\" string", location)
140
+ }
141
+ return text, nil
97
142
  }
98
143
 
144
+ // bannerTextFromConfigValue extracts a banner text string from a config value.
145
+ // A banner config value must be an object with a non-empty "text" string; raw
146
+ // may also be nil (not present). Returns (text, true, nil) on success,
147
+ // ("", false, nil) when absent, or ("", true, err) on a type mismatch. label is
148
+ // used in error messages and is the config file's base name (e.g.
149
+ // "banner.config.json").
99
150
  func bannerTextFromConfigValue(raw any, label string) (string, bool, error) {
100
- if raw == nil {
101
- return "", false, nil
102
- }
103
- text, ok := raw.(string)
104
- if ok {
105
- if strings.TrimSpace(text) == "" {
106
- return "", true, fmt.Errorf("@ttsc/banner: %s must be a non-empty string", label)
107
- }
108
- return text, true, nil
109
- }
110
- object, ok := raw.(map[string]any)
111
- if !ok {
112
- return "", true, fmt.Errorf("@ttsc/banner: %s must be a string or an object with a non-empty \"text\" string", label)
113
- }
114
- rawText, ok := object["text"]
115
- if !ok {
116
- return "", false, nil
117
- }
118
- text, ok = rawText.(string)
119
- if !ok || strings.TrimSpace(text) == "" {
120
- return "", true, fmt.Errorf("@ttsc/banner: %s.text must be a non-empty string", label)
121
- }
122
- return text, true, nil
151
+ if raw == nil {
152
+ return "", false, nil
153
+ }
154
+ object, ok := raw.(map[string]any)
155
+ if !ok {
156
+ return "", true, fmt.Errorf("@ttsc/banner: %s must be an object with a non-empty \"text\" string", label)
157
+ }
158
+ rawText, ok := object["text"]
159
+ if !ok {
160
+ return "", false, nil
161
+ }
162
+ text, ok := rawText.(string)
163
+ if !ok || strings.TrimSpace(text) == "" {
164
+ return "", true, fmt.Errorf("@ttsc/banner: %s.text must be a non-empty string", label)
165
+ }
166
+ return text, true, nil
123
167
  }
124
168
 
169
+ // findBannerConfigFile walks up from the tsconfig (or cwd) directory looking for
170
+ // a banner.config.{ts,cts,mts,js,cjs,mjs,json} file. Returns the path when exactly
171
+ // one match is found per directory, "" when none exists at any level, or an
172
+ // error when multiple candidates exist in the same directory.
125
173
  func findBannerConfigFile(cwd, tsconfigPath string) (string, error) {
126
- dir := discoveryConfigBaseDir(cwd, tsconfigPath)
127
- for {
128
- matches := make([]string, 0, 1)
129
- for _, name := range []string{
130
- "banner.config.js",
131
- "banner.config.cjs",
132
- "banner.config.mjs",
133
- "banner.config.ts",
134
- "banner.config.cts",
135
- "banner.config.mts",
136
- } {
137
- candidate := filepath.Join(dir, name)
138
- if stat, err := os.Stat(candidate); err == nil && !stat.IsDir() {
139
- matches = append(matches, candidate)
140
- }
141
- }
142
- if len(matches) > 1 {
143
- return "", fmt.Errorf("@ttsc/banner: multiple banner.config.* files found in %s", dir)
144
- }
145
- if len(matches) == 1 {
146
- return matches[0], nil
147
- }
148
- parent := filepath.Dir(dir)
149
- if parent == dir {
150
- return "", nil
151
- }
152
- dir = parent
153
- }
174
+ dir := tsconfigBaseDir(cwd, tsconfigPath)
175
+ for {
176
+ matches := make([]string, 0, 1)
177
+ for _, name := range []string{
178
+ "banner.config.json",
179
+ "banner.config.js",
180
+ "banner.config.cjs",
181
+ "banner.config.mjs",
182
+ "banner.config.ts",
183
+ "banner.config.cts",
184
+ "banner.config.mts",
185
+ } {
186
+ candidate := filepath.Join(dir, name)
187
+ if stat, err := os.Stat(candidate); err == nil && !stat.IsDir() {
188
+ matches = append(matches, candidate)
189
+ }
190
+ }
191
+ if len(matches) > 1 {
192
+ names := make([]string, len(matches))
193
+ for i, match := range matches {
194
+ names[i] = filepath.Base(match)
195
+ }
196
+ return "", fmt.Errorf(
197
+ "@ttsc/banner: multiple banner config files found in %s (%s); set \"configFile\" explicitly in the tsconfig plugin entry",
198
+ dir, strings.Join(names, ", "),
199
+ )
200
+ }
201
+ if len(matches) == 1 {
202
+ return matches[0], nil
203
+ }
204
+ parent := filepath.Dir(dir)
205
+ if parent == dir {
206
+ return "", nil
207
+ }
208
+ dir = parent
209
+ }
154
210
  }
155
211
 
212
+ // resolveBannerConfigPath resolves a config path from the plugin entry.
213
+ // Absolute paths are returned as-is; relative paths are resolved against the
214
+ // tsconfig directory (or cwd when no tsconfig is set).
156
215
  func resolveBannerConfigPath(configPath, cwd, tsconfigPath string) string {
157
- if filepath.IsAbs(configPath) {
158
- return configPath
159
- }
160
- return filepath.Join(tsconfigBaseDir(cwd, tsconfigPath), configPath)
216
+ if filepath.IsAbs(configPath) {
217
+ return configPath
218
+ }
219
+ return filepath.Join(tsconfigBaseDir(cwd, tsconfigPath), configPath)
161
220
  }
162
221
 
222
+ // tsconfigBaseDir returns the directory that contains the tsconfig file, or cwd
223
+ // when tsconfigPath is empty. It is the base both for resolving an explicit
224
+ // `configFile` path and for starting the upward banner-config-file search.
163
225
  func tsconfigBaseDir(cwd, tsconfigPath string) string {
164
- if tsconfigPath == "" {
165
- return cwd
166
- }
167
- resolvedTsconfig := tsconfigPath
168
- if !filepath.IsAbs(resolvedTsconfig) {
169
- resolvedTsconfig = filepath.Join(cwd, resolvedTsconfig)
170
- }
171
- return filepath.Dir(resolvedTsconfig)
172
- }
173
-
174
- func discoveryConfigBaseDir(cwd, tsconfigPath string) string {
175
- if tsconfigPath != "" {
176
- resolvedTsconfig := tsconfigPath
177
- if !filepath.IsAbs(resolvedTsconfig) {
178
- resolvedTsconfig = filepath.Join(cwd, resolvedTsconfig)
179
- }
180
- return filepath.Dir(resolvedTsconfig)
181
- }
182
- return cwd
226
+ if tsconfigPath == "" {
227
+ return cwd
228
+ }
229
+ resolvedTsconfig := tsconfigPath
230
+ if !filepath.IsAbs(resolvedTsconfig) {
231
+ resolvedTsconfig = filepath.Join(cwd, resolvedTsconfig)
232
+ }
233
+ return filepath.Dir(resolvedTsconfig)
183
234
  }
184
235
 
236
+ // loadBannerConfigFile loads and evaluates a banner config file, returning its
237
+ // exported value as a Go any. A valid banner config exports an object with a
238
+ // "text" string; the value is validated by bannerTextFromConfigValue. The file
239
+ // must be named banner.config.{ts,cts,mts,js,cjs,mjs,json}; JS/CJS/MJS variants
240
+ // run under Node, TypeScript variants compile and run via ttsx in a temp
241
+ // directory, and JSON files are parsed natively.
185
242
  func loadBannerConfigFile(location string) (any, error) {
186
- if !isBannerConfigFileName(filepath.Base(location)) {
187
- return nil, fmt.Errorf("@ttsc/banner: config file must be named banner.config.{js,cjs,mjs,ts,mts,cts}: %s", location)
188
- }
189
- ext := strings.ToLower(filepath.Ext(location))
190
- switch ext {
191
- case ".js", ".cjs", ".mjs":
192
- return loadBannerScriptConfigFile(location)
193
- }
194
- return loadBannerTypeScriptConfigFile(location)
243
+ if !isBannerConfigFileName(filepath.Base(location)) {
244
+ return nil, fmt.Errorf("@ttsc/banner: config file must be named banner.config.{ts,cts,mts,js,cjs,mjs,json}: %s", location)
245
+ }
246
+ ext := strings.ToLower(filepath.Ext(location))
247
+ switch ext {
248
+ case ".json":
249
+ return loadBannerJSONConfigFile(location)
250
+ case ".js", ".cjs", ".mjs":
251
+ return loadBannerScriptConfigFile(location)
252
+ }
253
+ return loadBannerTypeScriptConfigFile(location)
195
254
  }
196
255
 
256
+ // isBannerConfigFileName reports whether name is an allowed banner config file name.
197
257
  func isBannerConfigFileName(name string) bool {
198
- switch name {
199
- case "banner.config.js",
200
- "banner.config.cjs",
201
- "banner.config.mjs",
202
- "banner.config.ts",
203
- "banner.config.cts",
204
- "banner.config.mts":
205
- return true
206
- default:
207
- return false
208
- }
258
+ switch name {
259
+ case "banner.config.json",
260
+ "banner.config.js",
261
+ "banner.config.cjs",
262
+ "banner.config.mjs",
263
+ "banner.config.ts",
264
+ "banner.config.cts",
265
+ "banner.config.mts":
266
+ return true
267
+ default:
268
+ return false
269
+ }
270
+ }
271
+
272
+ // loadBannerJSONConfigFile reads and JSON-parses a banner config file. A leading
273
+ // UTF-8 BOM is stripped before parsing so files saved by Windows editors are
274
+ // accepted. The parsed value must be an object with a non-empty "text" string.
275
+ func loadBannerJSONConfigFile(location string) (any, error) {
276
+ body, err := os.ReadFile(location)
277
+ if err != nil {
278
+ return nil, fmt.Errorf("@ttsc/banner: read config file %s: %w", location, err)
279
+ }
280
+ // Strip a leading UTF-8 BOM so files saved by Windows editors round
281
+ // trip through json.Unmarshal without an opaque "invalid character" failure.
282
+ body = bytes.TrimPrefix(body, []byte{0xEF, 0xBB, 0xBF})
283
+ var out any
284
+ if err := json.Unmarshal(body, &out); err != nil {
285
+ return nil, fmt.Errorf("@ttsc/banner: parse config file %s: %w", location, err)
286
+ }
287
+ return out, nil
209
288
  }
210
289
 
290
+ // loadBannerScriptConfigFile evaluates a JS/CJS/MJS banner config file by
291
+ // running a small Node.js loader script that dynamic-imports the file and
292
+ // serializes its exported value to stdout as JSON.
211
293
  func loadBannerScriptConfigFile(location string) (any, error) {
212
- const script = `
294
+ const script = `
213
295
  const { pathToFileURL } = require("node:url");
214
296
 
215
297
  (async () => {
@@ -223,108 +305,116 @@ const { pathToFileURL } = require("node:url");
223
305
  });
224
306
 
225
307
  function toSerializableBanner(value) {
226
- if (typeof value === "string") {
227
- return value;
228
- }
229
308
  if (value !== null && typeof value === "object" && typeof value.text === "string") {
230
309
  return { text: value.text };
231
310
  }
232
- throw new Error("config file must export a string or an object with a text string");
311
+ throw new Error("config file must export an object with a non-empty \"text\" string");
233
312
  }
234
313
  `
235
- node := os.Getenv("TTSC_NODE_BINARY")
236
- if node == "" {
237
- node = "node"
238
- }
239
- cmd := exec.Command(node, "-e", script, location)
240
- cmd.Env = nodeConfigLoaderEnv(location)
241
- output, err := cmd.Output()
242
- if err != nil {
243
- stderr := ""
244
- if exit, ok := err.(*exec.ExitError); ok {
245
- stderr = strings.TrimSpace(string(exit.Stderr))
246
- }
247
- if stderr != "" {
248
- return nil, fmt.Errorf("@ttsc/banner: load config file %s: %s", location, stderr)
249
- }
250
- return nil, fmt.Errorf("@ttsc/banner: load config file %s: %w", location, err)
251
- }
252
- var out any
253
- if err := json.Unmarshal(output, &out); err != nil {
254
- return nil, fmt.Errorf("@ttsc/banner: parse config file %s output: %w", location, err)
255
- }
256
- return out, nil
314
+ node := os.Getenv("TTSC_NODE_BINARY")
315
+ if node == "" {
316
+ node = "node"
317
+ }
318
+ cmd := exec.Command(node, "-e", script, location)
319
+ cmd.Env = nodeConfigLoaderEnv(location)
320
+ output, err := cmd.Output()
321
+ if err != nil {
322
+ stderr := ""
323
+ if exit, ok := err.(*exec.ExitError); ok {
324
+ stderr = strings.TrimSpace(string(exit.Stderr))
325
+ }
326
+ if stderr != "" {
327
+ return nil, fmt.Errorf("@ttsc/banner: load config file %s: %s", location, stderr)
328
+ }
329
+ return nil, fmt.Errorf("@ttsc/banner: load config file %s: %w", location, err)
330
+ }
331
+ var out any
332
+ if err := json.Unmarshal(output, &out); err != nil {
333
+ return nil, fmt.Errorf("@ttsc/banner: parse config file %s output: %w", location, err)
334
+ }
335
+ return out, nil
257
336
  }
258
337
 
338
+ // loadBannerTypeScriptConfigFile compiles and runs a TypeScript banner config
339
+ // file using ttsx in a temp directory. A symlink to the nearest node_modules
340
+ // is created so the config file can import its own dependencies. The ttsx
341
+ // build runs with `--no-plugins` so evaluating the config never triggers the
342
+ // host project's transform/check plugins against the loader tsconfig.
259
343
  func loadBannerTypeScriptConfigFile(location string) (any, error) {
260
- tempDir, err := os.MkdirTemp("", "ttsc-banner-config-")
261
- if err != nil {
262
- return nil, fmt.Errorf("@ttsc/banner: create config loader tempdir: %w", err)
263
- }
264
- defer os.RemoveAll(tempDir)
265
-
266
- if err := linkConfigNodeModules(tempDir, filepath.Dir(location)); err != nil {
267
- return nil, err
268
- }
269
-
270
- loader := filepath.Join(tempDir, "loader.mts")
271
- tsconfig := filepath.Join(tempDir, "tsconfig.json")
272
- importSpecifier, err := relativeImportSpecifier(tempDir, location)
273
- if err != nil {
274
- return nil, err
275
- }
276
- importLiteral, _ := json.Marshal(importSpecifier)
277
- if err := writeConfigLoaderFile(loader, []byte(bannerTypeScriptConfigLoaderSource(string(importLiteral))), 0o644); err != nil {
278
- return nil, fmt.Errorf("@ttsc/banner: write config loader: %w", err)
279
- }
280
- if err := writeConfigLoaderFile(tsconfig, []byte(typeScriptConfigLoaderTsconfig(loader, location, tempDir)), 0o644); err != nil {
281
- return nil, fmt.Errorf("@ttsc/banner: write config loader tsconfig: %w", err)
282
- }
283
-
284
- args := []string{
285
- "--project", tsconfig,
286
- "--cwd", tempDir,
287
- "--cache-dir", filepath.Join(tempDir, "cache"),
288
- }
289
- if tsgo := os.Getenv("TTSC_TSGO_BINARY"); tsgo != "" {
290
- args = append(args, "--binary", tsgo)
291
- }
292
- args = append(args, loader)
293
-
294
- cmd := ttsxCommand(args...)
295
- cmd.Env = nodeConfigLoaderEnv(location)
296
- output, err := cmd.Output()
297
- if err != nil {
298
- stderr := ""
299
- if exit, ok := err.(*exec.ExitError); ok {
300
- stderr = strings.TrimSpace(string(exit.Stderr))
301
- }
302
- if stderr != "" {
303
- return nil, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %s", location, stderr)
304
- }
305
- return nil, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %w", location, err)
306
- }
307
- var out any
308
- if err := json.Unmarshal(output, &out); err != nil {
309
- return nil, fmt.Errorf("@ttsc/banner: parse TypeScript config file %s output: %w", location, err)
310
- }
311
- return out, nil
344
+ tempDir, err := os.MkdirTemp("", "ttsc-banner-config-")
345
+ if err != nil {
346
+ return nil, fmt.Errorf("@ttsc/banner: create config loader tempdir: %w", err)
347
+ }
348
+ defer os.RemoveAll(tempDir)
349
+
350
+ if err := linkConfigNodeModules(tempDir, filepath.Dir(location)); err != nil {
351
+ return nil, err
352
+ }
353
+
354
+ loader := filepath.Join(tempDir, "loader.mts")
355
+ tsconfig := filepath.Join(tempDir, "tsconfig.json")
356
+ importSpecifier, err := relativeImportSpecifier(tempDir, location)
357
+ if err != nil {
358
+ return nil, err
359
+ }
360
+ importLiteral, _ := json.Marshal(importSpecifier)
361
+ if err := writeConfigLoaderFile(loader, []byte(bannerTypeScriptConfigLoaderSource(string(importLiteral))), 0o644); err != nil {
362
+ return nil, fmt.Errorf("@ttsc/banner: write config loader: %w", err)
363
+ }
364
+ if err := writeConfigLoaderFile(tsconfig, []byte(typeScriptConfigLoaderTsconfig(loader, location, tempDir)), 0o644); err != nil {
365
+ return nil, fmt.Errorf("@ttsc/banner: write config loader tsconfig: %w", err)
366
+ }
367
+
368
+ args := []string{
369
+ "--project", tsconfig,
370
+ "--cwd", tempDir,
371
+ "--cache-dir", filepath.Join(tempDir, "cache"),
372
+ "--no-plugins",
373
+ }
374
+ if tsgo := os.Getenv("TTSC_TSGO_BINARY"); tsgo != "" {
375
+ args = append(args, "--binary", tsgo)
376
+ }
377
+ args = append(args, loader)
378
+
379
+ cmd := ttsxCommand(args...)
380
+ cmd.Env = nodeConfigLoaderEnv(location)
381
+ output, err := cmd.Output()
382
+ if err != nil {
383
+ stderr := ""
384
+ if exit, ok := err.(*exec.ExitError); ok {
385
+ stderr = strings.TrimSpace(string(exit.Stderr))
386
+ }
387
+ if stderr != "" {
388
+ return nil, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %s", location, stderr)
389
+ }
390
+ return nil, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %w", location, err)
391
+ }
392
+ var out any
393
+ if err := json.Unmarshal(output, &out); err != nil {
394
+ return nil, fmt.Errorf("@ttsc/banner: parse TypeScript config file %s output: %w", location, err)
395
+ }
396
+ return out, nil
312
397
  }
313
398
 
399
+ // relativeImportSpecifier returns a "./" or "../"-prefixed slash-separated
400
+ // import specifier for location relative to fromDir.
314
401
  func relativeImportSpecifier(fromDir, location string) (string, error) {
315
- relative, err := filepath.Rel(fromDir, location)
316
- if err != nil {
317
- return "", fmt.Errorf("@ttsc/banner: resolve relative config import %s: %w", location, err)
318
- }
319
- relative = filepath.ToSlash(relative)
320
- if strings.HasPrefix(relative, "../") || strings.HasPrefix(relative, "./") {
321
- return relative, nil
322
- }
323
- return "./" + relative, nil
402
+ relative, err := filepath.Rel(fromDir, location)
403
+ if err != nil {
404
+ return "", fmt.Errorf("@ttsc/banner: resolve relative config import %s: %w", location, err)
405
+ }
406
+ relative = filepath.ToSlash(relative)
407
+ if strings.HasPrefix(relative, "../") || strings.HasPrefix(relative, "./") {
408
+ return relative, nil
409
+ }
410
+ return "./" + relative, nil
324
411
  }
325
412
 
413
+ // bannerTypeScriptConfigLoaderSource returns the source of a TypeScript loader
414
+ // module that imports the banner config file specified by importLiteral (a
415
+ // JSON-encoded import specifier) and writes the serialized banner value to stdout.
326
416
  func bannerTypeScriptConfigLoaderSource(importLiteral string) string {
327
- return fmt.Sprintf(`import * as importedConfig from %s;
417
+ return fmt.Sprintf(`import * as importedConfig from %s;
328
418
 
329
419
  declare const process: {
330
420
  stdout: { write(value: string): void };
@@ -364,116 +454,131 @@ function hasOwn(value: Record<string, unknown>, key: string): boolean {
364
454
  }
365
455
 
366
456
  function toSerializableBanner(value: unknown): unknown {
367
- if (typeof value === "string") {
368
- return value;
369
- }
370
457
  if (isObject(value) && typeof value.text === "string") {
371
458
  return { text: value.text };
372
459
  }
373
- throw new Error("config file must export a string or an object with a text string");
460
+ throw new Error("config file must export an object with a non-empty \"text\" string");
374
461
  }
375
462
  `, importLiteral)
376
463
  }
377
464
 
465
+ // typeScriptConfigLoaderTsconfig returns the JSON content of a tsconfig that
466
+ // compiles loader and location together so ttsx can execute the loader.
378
467
  func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
379
- content := map[string]any{
380
- "compilerOptions": map[string]any{
381
- "allowImportingTsExtensions": true,
382
- "module": "ESNext",
383
- "moduleResolution": "bundler",
384
- "outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
385
- "rewriteRelativeImportExtensions": true,
386
- "rootDir": "/",
387
- "skipLibCheck": true,
388
- "strict": true,
389
- "target": "ES2022",
390
- },
391
- "files": []string{
392
- filepath.ToSlash(loader),
393
- filepath.ToSlash(location),
394
- },
395
- }
396
- body, _ := json.MarshalIndent(content, "", " ")
397
- return string(body)
468
+ content := map[string]any{
469
+ "compilerOptions": map[string]any{
470
+ "allowImportingTsExtensions": true,
471
+ "module": "ESNext",
472
+ "moduleResolution": "bundler",
473
+ "outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
474
+ "rewriteRelativeImportExtensions": true,
475
+ "rootDir": "/",
476
+ "skipLibCheck": true,
477
+ "strict": true,
478
+ "target": "ES2022",
479
+ },
480
+ "files": []string{
481
+ filepath.ToSlash(loader),
482
+ filepath.ToSlash(location),
483
+ },
484
+ }
485
+ body, _ := json.MarshalIndent(content, "", " ")
486
+ return string(body)
398
487
  }
399
488
 
489
+ // ttsxCommand builds an exec.Cmd that runs ttsx with the given args.
490
+ // When TTSC_TTSX_BINARY has a script extension (.js, .ts, …) the binary is
491
+ // invoked via the Node runtime so it is executed correctly on all platforms.
400
492
  func ttsxCommand(args ...string) *exec.Cmd {
401
- ttsx := os.Getenv("TTSC_TTSX_BINARY")
402
- if ttsx == "" {
403
- ttsx = "ttsx"
404
- }
405
- if shouldRunTtsxThroughNode(ttsx) {
406
- node := os.Getenv("TTSC_NODE_BINARY")
407
- if node == "" {
408
- node = "node"
409
- }
410
- return exec.Command(node, append([]string{ttsx}, args...)...)
411
- }
412
- return exec.Command(ttsx, args...)
493
+ ttsx := os.Getenv("TTSC_TTSX_BINARY")
494
+ if ttsx == "" {
495
+ ttsx = "ttsx"
496
+ }
497
+ if shouldRunTtsxThroughNode(ttsx) {
498
+ node := os.Getenv("TTSC_NODE_BINARY")
499
+ if node == "" {
500
+ node = "node"
501
+ }
502
+ return exec.Command(node, append([]string{ttsx}, args...)...)
503
+ }
504
+ return exec.Command(ttsx, args...)
413
505
  }
414
506
 
507
+ // shouldRunTtsxThroughNode reports whether binary has a script file extension
508
+ // and therefore must be launched via node rather than executed directly.
415
509
  func shouldRunTtsxThroughNode(binary string) bool {
416
- switch strings.ToLower(filepath.Ext(binary)) {
417
- case ".js", ".cjs", ".mjs", ".ts", ".cts", ".mts":
418
- return true
419
- default:
420
- return false
421
- }
510
+ switch strings.ToLower(filepath.Ext(binary)) {
511
+ case ".js", ".cjs", ".mjs", ".ts", ".cts", ".mts":
512
+ return true
513
+ default:
514
+ return false
515
+ }
422
516
  }
423
517
 
518
+ // nodeConfigLoaderEnv builds an environment slice for the config-loader Node
519
+ // process. It prepends the nearest node_modules directory to NODE_PATH so
520
+ // the config file can resolve its own package dependencies.
424
521
  func nodeConfigLoaderEnv(location string) []string {
425
- env := os.Environ()
426
- parts := make([]string, 0, 2)
427
- if nodeModules := findNearestNodeModules(filepath.Dir(location)); nodeModules != "" {
428
- parts = append(parts, nodeModules)
429
- }
430
- if existing := os.Getenv("NODE_PATH"); existing != "" {
431
- parts = append(parts, existing)
432
- }
433
- if len(parts) == 0 {
434
- return env
435
- }
436
- return setEnv(env, "NODE_PATH", strings.Join(parts, string(os.PathListSeparator)))
522
+ env := os.Environ()
523
+ parts := make([]string, 0, 2)
524
+ if nodeModules := findNearestNodeModules(filepath.Dir(location)); nodeModules != "" {
525
+ parts = append(parts, nodeModules)
526
+ }
527
+ if existing := os.Getenv("NODE_PATH"); existing != "" {
528
+ parts = append(parts, existing)
529
+ }
530
+ if len(parts) == 0 {
531
+ return env
532
+ }
533
+ return setEnv(env, "NODE_PATH", strings.Join(parts, string(os.PathListSeparator)))
437
534
  }
438
535
 
536
+ // linkNearestNodeModules creates a node_modules symlink inside tempDir pointing
537
+ // to the nearest node_modules ancestor of sourceDir. Does nothing when none is found.
439
538
  func linkNearestNodeModules(tempDir, sourceDir string) error {
440
- nodeModules := findNearestNodeModules(sourceDir)
441
- if nodeModules == "" {
442
- return nil
443
- }
444
- link := filepath.Join(tempDir, "node_modules")
445
- if err := os.Symlink(nodeModules, link); err != nil {
446
- return fmt.Errorf("@ttsc/banner: link config node_modules %s: %w", nodeModules, err)
447
- }
448
- return nil
539
+ nodeModules := findNearestNodeModules(sourceDir)
540
+ if nodeModules == "" {
541
+ return nil
542
+ }
543
+ link := filepath.Join(tempDir, "node_modules")
544
+ if err := os.Symlink(nodeModules, link); err != nil {
545
+ return fmt.Errorf("@ttsc/banner: link config node_modules %s: %w", nodeModules, err)
546
+ }
547
+ return nil
449
548
  }
450
549
 
550
+ // findNearestNodeModules walks up from start looking for a node_modules directory.
551
+ // Returns the absolute path of the first match, or "" when none is found.
451
552
  func findNearestNodeModules(start string) string {
452
- dir := filepath.Clean(start)
453
- for {
454
- candidate := filepath.Join(dir, "node_modules")
455
- if stat, err := os.Stat(candidate); err == nil && stat.IsDir() {
456
- return candidate
457
- }
458
- parent := filepath.Dir(dir)
459
- if parent == dir {
460
- return ""
461
- }
462
- dir = parent
463
- }
553
+ dir := filepath.Clean(start)
554
+ for {
555
+ candidate := filepath.Join(dir, "node_modules")
556
+ if stat, err := os.Stat(candidate); err == nil && stat.IsDir() {
557
+ return candidate
558
+ }
559
+ parent := filepath.Dir(dir)
560
+ if parent == dir {
561
+ return ""
562
+ }
563
+ dir = parent
564
+ }
464
565
  }
465
566
 
567
+ // setEnv returns a copy of env with key=value. If key already exists in env,
568
+ // its value is updated in-place; otherwise the entry is appended.
466
569
  func setEnv(env []string, key, value string) []string {
467
- prefix := key + "="
468
- for i, entry := range env {
469
- if strings.HasPrefix(entry, prefix) {
470
- env[i] = prefix + value
471
- return env
472
- }
473
- }
474
- return append(env, prefix+value)
570
+ prefix := key + "="
571
+ for i, entry := range env {
572
+ if strings.HasPrefix(entry, prefix) {
573
+ env[i] = prefix + value
574
+ return env
575
+ }
576
+ }
577
+ return append(env, prefix+value)
475
578
  }
476
579
 
580
+ // sanitizeJSDocLine escapes any JSDoc-closing sequence in a banner text line
581
+ // by replacing "*/" with "* /" so the generated block comment stays valid.
477
582
  func sanitizeJSDocLine(line string) string {
478
- return strings.ReplaceAll(line, "*/", "* /")
583
+ return strings.ReplaceAll(line, "*/", "* /")
479
584
  }
package/lib/index.js CHANGED
@@ -21,11 +21,40 @@ exports.default = createTtscBanner;
21
21
  const node_path_1 = __importDefault(require("node:path"));
22
22
  __exportStar(require("./structures/index"), exports);
23
23
  /**
24
+ * Keys that the ttsc plugin host injects into every plugin entry and are not
25
+ * owned by `@ttsc/banner`. These pass through the factory without validation so
26
+ * the host can freely add new framework keys in the future.
27
+ */
28
+ const FRAMEWORK_KEYS = new Set([
29
+ "enabled",
30
+ "name",
31
+ "stage",
32
+ "transform",
33
+ ]);
34
+ /**
35
+ * Plugin factory for `@ttsc/banner` — called by the ttsc host to obtain the
36
+ * plugin descriptor.
37
+ *
38
+ * The only banner-specific key accepted in the tsconfig plugin entry is
39
+ * `configFile`. Any other key that is not a known framework key is rejected
40
+ * with a specific error so users discover the correct configuration surface
41
+ * (the dedicated config file) rather than silently receiving no banner.
42
+ *
24
43
  * @internal
25
44
  */
26
- function createTtscBanner(_context) {
45
+ function createTtscBanner(context) {
46
+ const entry = context.plugin;
47
+ for (const key of Object.keys(entry)) {
48
+ if (!FRAMEWORK_KEYS.has(key) && key !== "configFile") {
49
+ throw new Error(`@ttsc/banner: tsconfig plugin entry contains unsupported key ${JSON.stringify(key)}. ` +
50
+ `Banner options must be placed in a banner.config.{ts,cts,mts,js,cjs,mjs,json} file. ` +
51
+ `The only accepted key in the tsconfig entry is "configFile" (optional path to the config file).`);
52
+ }
53
+ }
27
54
  return {
28
55
  name: "@ttsc/banner",
56
+ // Point at the `driver/` directory one level above `lib/` in the
57
+ // installed package tree (where the Go sources live).
29
58
  source: node_path_1.default.resolve(__dirname, "..", "driver"),
30
59
  stage: "transform",
31
60
  };
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA,0DAA6B;AAI7B,qDAAmC;AAgBnC;;GAEG;AACH,0BACE,QAA2D;IAE3D,OAAO;QACL,IAAI,EAAE,cAAc;QACpB,MAAM,EAAE,mBAAI,CAAC,OAAO,CAAC,SAAS,EAAE,IAAI,EAAE,QAAQ,CAAC;QAC/C,KAAK,EAAE,WAAW;KACnB,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA,0DAA6B;AAI7B,qDAAmC;AA4CnC;;;;GAIG;AACH,MAAM,cAAc,GAAG,IAAI,GAAG,CAAS;IACrC,SAAS;IACT,MAAM;IACN,OAAO;IACP,WAAW;CACZ,CAAC,CAAC;AAEH;;;;;;;;;;GAUG;AACH,0BACE,OAA0D;IAE1D,MAAM,KAAK,GAAG,OAAO,CAAC,MAAiC,CAAC;IACxD,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QACrC,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,GAAG,KAAK,YAAY,EAAE,CAAC;YACrD,MAAM,IAAI,KAAK,CACb,gEAAgE,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI;gBACrF,sFAAsF;gBACtF,iGAAiG,CACpG,CAAC;QACJ,CAAC;IACH,CAAC;IAED,OAAO;QACL,IAAI,EAAE,cAAc;QACpB,iEAAiE;QACjE,sDAAsD;QACtD,MAAM,EAAE,mBAAI,CAAC,OAAO,CAAC,SAAS,EAAE,IAAI,EAAE,QAAQ,CAAC;QAC/C,KAAK,EAAE,WAAW;KACnB,CAAC;AACJ,CAAC"}
@@ -1,5 +1,5 @@
1
1
  /** Standalone `banner.config.*` file shape consumed by `@ttsc/banner`. */
2
- export type TtscBannerConfig = string | {
2
+ export interface ITtscBannerConfig {
3
3
  /** Text inserted into the generated `@packageDocumentation` block. */
4
4
  text: string;
5
- };
5
+ }
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- //# sourceMappingURL=TtscBannerConfig.js.map
3
+ //# sourceMappingURL=ITtscBannerConfig.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ITtscBannerConfig.js","sourceRoot":"","sources":["../../src/structures/ITtscBannerConfig.ts"],"names":[],"mappings":""}
@@ -4,10 +4,11 @@ export interface ITtscBannerPluginConfig {
4
4
  enabled?: boolean;
5
5
  /** Plugin module specifier. */
6
6
  transform?: string;
7
- /** Inline banner text. */
8
- text?: string;
9
- /** Path to `banner.config.*`, resolved from the selected tsconfig directory. */
10
- config?: string;
11
- /** Extra plugin-owned fields are passed through unchanged. */
12
- [key: string]: unknown;
7
+ /**
8
+ * Path to a `banner.config.*` file, resolved from the directory that contains
9
+ * the tsconfig. When omitted, `@ttsc/banner` walks up from that directory and
10
+ * uses the first `banner.config.{ts,cts,mts,js,cjs,mjs,json}` file it finds.
11
+ * Any other key in this entry is rejected with an error.
12
+ */
13
+ configFile?: string;
13
14
  }
@@ -1,2 +1,2 @@
1
1
  export * from "./ITtscBannerPluginConfig";
2
- export * from "./TtscBannerConfig";
2
+ export * from "./ITtscBannerConfig";
@@ -15,5 +15,5 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./ITtscBannerPluginConfig"), exports);
18
- __exportStar(require("./TtscBannerConfig"), exports);
18
+ __exportStar(require("./ITtscBannerConfig"), exports);
19
19
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/structures/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,4DAA0C;AAC1C,qDAAmC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/structures/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,4DAA0C;AAC1C,sDAAoC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttsc/banner",
3
- "version": "0.12.3",
3
+ "version": "0.13.0",
4
4
  "description": "First-party ttsc plugin that adds package-documentation JSDoc banners during emit.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -33,9 +33,9 @@
33
33
  ],
34
34
  "devDependencies": {
35
35
  "@types/node": "^25.3.0",
36
- "@typescript/native-preview": "7.0.0-dev.20260518.1",
36
+ "@typescript/native-preview": "7.0.0-dev.20260522.1",
37
37
  "rimraf": "^6.1.2",
38
- "ttsc": "0.12.3"
38
+ "ttsc": "0.13.0"
39
39
  },
40
40
  "repository": {
41
41
  "type": "git",
package/src/index.ts CHANGED
@@ -4,28 +4,89 @@ import type { ITtscBannerPluginConfig } from "./structures";
4
4
 
5
5
  export * from "./structures/index";
6
6
 
7
+ /**
8
+ * The shape returned by a ttsc plugin factory function.
9
+ *
10
+ * Mirrors the `driver.PluginDescriptor` contract in the Go host. The `source`
11
+ * field points to the Go source directory that the host will compile into a
12
+ * plugin binary (cached under `.ttsc/`).
13
+ */
7
14
  type TtscPluginDescriptor = {
15
+ /** Human-readable plugin name used in logs and error messages. */
8
16
  name: string;
17
+ /** Absolute path to the Go source directory for this plugin. */
9
18
  source: string;
19
+ /**
20
+ * Pipeline stage. `"transform"` plugins may rewrite source files; `"check"`
21
+ * plugins only produce diagnostics. Defaults to `"check"`.
22
+ */
10
23
  stage?: "check" | "transform";
11
24
  };
12
25
 
26
+ /**
27
+ * Context object passed by the ttsc host to every plugin factory function.
28
+ *
29
+ * The factory may inspect the context to customise the descriptor — for example
30
+ * selecting a different Go source directory based on `plugin` config — but most
31
+ * factories ignore it.
32
+ */
13
33
  type TtscPluginFactoryContext<TConfig> = {
34
+ /**
35
+ * Absolute path to the compiled plugin binary (not yet built at factory
36
+ * time).
37
+ */
14
38
  binary: string;
39
+ /** Working directory of the ttsc invocation. */
15
40
  cwd: string;
41
+ /** The raw plugin entry from `compilerOptions.plugins[]`. */
16
42
  plugin: TConfig;
43
+ /** Absolute path to the project root (directory containing tsconfig). */
17
44
  projectRoot: string;
45
+ /** Absolute path to the resolved tsconfig. */
18
46
  tsconfig: string;
19
47
  };
20
48
 
21
49
  /**
50
+ * Keys that the ttsc plugin host injects into every plugin entry and are not
51
+ * owned by `@ttsc/banner`. These pass through the factory without validation so
52
+ * the host can freely add new framework keys in the future.
53
+ */
54
+ const FRAMEWORK_KEYS = new Set<string>([
55
+ "enabled",
56
+ "name",
57
+ "stage",
58
+ "transform",
59
+ ]);
60
+
61
+ /**
62
+ * Plugin factory for `@ttsc/banner` — called by the ttsc host to obtain the
63
+ * plugin descriptor.
64
+ *
65
+ * The only banner-specific key accepted in the tsconfig plugin entry is
66
+ * `configFile`. Any other key that is not a known framework key is rejected
67
+ * with a specific error so users discover the correct configuration surface
68
+ * (the dedicated config file) rather than silently receiving no banner.
69
+ *
22
70
  * @internal
23
71
  */
24
72
  export default function createTtscBanner(
25
- _context: TtscPluginFactoryContext<ITtscBannerPluginConfig>,
73
+ context: TtscPluginFactoryContext<ITtscBannerPluginConfig>,
26
74
  ): TtscPluginDescriptor {
75
+ const entry = context.plugin as Record<string, unknown>;
76
+ for (const key of Object.keys(entry)) {
77
+ if (!FRAMEWORK_KEYS.has(key) && key !== "configFile") {
78
+ throw new Error(
79
+ `@ttsc/banner: tsconfig plugin entry contains unsupported key ${JSON.stringify(key)}. ` +
80
+ `Banner options must be placed in a banner.config.{ts,cts,mts,js,cjs,mjs,json} file. ` +
81
+ `The only accepted key in the tsconfig entry is "configFile" (optional path to the config file).`,
82
+ );
83
+ }
84
+ }
85
+
27
86
  return {
28
87
  name: "@ttsc/banner",
88
+ // Point at the `driver/` directory one level above `lib/` in the
89
+ // installed package tree (where the Go sources live).
29
90
  source: path.resolve(__dirname, "..", "driver"),
30
91
  stage: "transform",
31
92
  };
@@ -0,0 +1,5 @@
1
+ /** Standalone `banner.config.*` file shape consumed by `@ttsc/banner`. */
2
+ export interface ITtscBannerConfig {
3
+ /** Text inserted into the generated `@packageDocumentation` block. */
4
+ text: string;
5
+ }
@@ -6,12 +6,11 @@ export interface ITtscBannerPluginConfig {
6
6
  /** Plugin module specifier. */
7
7
  transform?: string;
8
8
 
9
- /** Inline banner text. */
10
- text?: string;
11
-
12
- /** Path to `banner.config.*`, resolved from the selected tsconfig directory. */
13
- config?: string;
14
-
15
- /** Extra plugin-owned fields are passed through unchanged. */
16
- [key: string]: unknown;
9
+ /**
10
+ * Path to a `banner.config.*` file, resolved from the directory that contains
11
+ * the tsconfig. When omitted, `@ttsc/banner` walks up from that directory and
12
+ * uses the first `banner.config.{ts,cts,mts,js,cjs,mjs,json}` file it finds.
13
+ * Any other key in this entry is rejected with an error.
14
+ */
15
+ configFile?: string;
17
16
  }
@@ -1,2 +1,2 @@
1
1
  export * from "./ITtscBannerPluginConfig";
2
- export * from "./TtscBannerConfig";
2
+ export * from "./ITtscBannerConfig";
@@ -1 +0,0 @@
1
- {"version":3,"file":"TtscBannerConfig.js","sourceRoot":"","sources":["../../src/structures/TtscBannerConfig.ts"],"names":[],"mappings":""}
@@ -1,7 +0,0 @@
1
- /** Standalone `banner.config.*` file shape consumed by `@ttsc/banner`. */
2
- export type TtscBannerConfig =
3
- | string
4
- | {
5
- /** Text inserted into the generated `@packageDocumentation` block. */
6
- text: string;
7
- };