@ttsc/banner 0.12.3 → 0.12.4

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/driver/banner.go CHANGED
@@ -1,215 +1,250 @@
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
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "os/exec"
8
+ "path/filepath"
9
+ "strings"
10
+
11
+ "github.com/samchon/ttsc/packages/ttsc/driver"
12
12
  )
13
13
 
14
14
  func init() {
15
- driver.RegisterPlugin(plugin{})
15
+ driver.RegisterPlugin(plugin{})
16
16
  }
17
17
 
18
+ // plugin implements driver.SourcePreamblePlugin for @ttsc/banner.
18
19
  type plugin struct{}
19
20
 
20
21
  var (
21
- linkConfigNodeModules = linkNearestNodeModules
22
- writeConfigLoaderFile = os.WriteFile
22
+ // linkConfigNodeModules is overridable in tests to avoid real symlink creation.
23
+ linkConfigNodeModules = linkNearestNodeModules
24
+ // writeConfigLoaderFile is overridable in tests to avoid real file I/O.
25
+ writeConfigLoaderFile = os.WriteFile
23
26
  )
24
27
 
28
+ // SourcePreamble resolves the banner text from the plugin config and returns it
29
+ // formatted as a JSDoc block comment suitable for prepending to each emitted file.
25
30
  func (plugin) SourcePreamble(ctx driver.PluginContext) (string, error) {
26
- return parseBanner(ctx.Entry.Config, ctx.Cwd, ctx.Tsconfig)
31
+ return parseBanner(ctx.Entry.Config, ctx.Cwd, ctx.Tsconfig)
27
32
  }
28
33
 
34
+ // parseBanner resolves and formats banner text into a JSDoc block comment.
35
+ // Trailing blank lines are stripped from the resolved text before formatting.
29
36
  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
37
+ text, err := resolveBannerText(config, cwd, tsconfigPath)
38
+ if err != nil {
39
+ return "", err
40
+ }
41
+ lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
42
+ for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
43
+ lines = lines[:len(lines)-1]
44
+ }
45
+ var b strings.Builder
46
+ sep := strings.Repeat("-", 64)
47
+ b.WriteString("/**\n")
48
+ b.WriteString(" * ")
49
+ b.WriteString(sep)
50
+ b.WriteByte('\n')
51
+ for _, line := range lines {
52
+ b.WriteString(" * ")
53
+ b.WriteString(sanitizeJSDocLine(line))
54
+ b.WriteByte('\n')
55
+ }
56
+ b.WriteString(" *\n")
57
+ b.WriteString(" * @packageDocumentation\n ")
58
+ b.WriteString("*/\n")
59
+ return b.String(), nil
53
60
  }
54
61
 
62
+ // resolveBannerText extracts the banner text from the plugin config.
63
+ // It tries, in order: inline "text" key, explicit "config" path, auto-discovered
64
+ // banner.config.* file. Returns an error when none of these sources provides text.
55
65
  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
66
+ if text, ok, err := bannerTextFromConfigValue(config["text"], `"text"`); ok || err != nil {
67
+ return text, err
68
+ }
69
+ if rawConfigPath, ok := config["config"]; ok {
70
+ configPath, ok := rawConfigPath.(string)
71
+ if !ok || strings.TrimSpace(configPath) == "" {
72
+ return "", fmt.Errorf("@ttsc/banner: \"config\" must be a non-empty string path")
73
+ }
74
+ location := resolveBannerConfigPath(configPath, cwd, tsconfigPath)
75
+ raw, err := loadBannerConfigFile(location)
76
+ if err != nil {
77
+ return "", err
78
+ }
79
+ text, ok, err := bannerTextFromConfigValue(raw, filepath.Base(location))
80
+ if err != nil {
81
+ return "", err
82
+ }
83
+ if !ok {
84
+ return "", fmt.Errorf("@ttsc/banner: %s must export a non-empty string or an object with a non-empty \"text\" string", location)
85
+ }
86
+ return text, nil
87
+ }
88
+ location, err := findBannerConfigFile(cwd, tsconfigPath)
89
+ if err != nil {
90
+ return "", err
91
+ }
92
+ if location == "" {
93
+ 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")
94
+ }
95
+ raw, err := loadBannerConfigFile(location)
96
+ if err != nil {
97
+ return "", err
98
+ }
99
+ text, ok, err := bannerTextFromConfigValue(raw, filepath.Base(location))
100
+ if err != nil {
101
+ return "", err
102
+ }
103
+ if !ok {
104
+ return "", fmt.Errorf("@ttsc/banner: %s must export a non-empty string or an object with a non-empty \"text\" string", location)
105
+ }
106
+ return text, nil
97
107
  }
98
108
 
109
+ // bannerTextFromConfigValue extracts a banner text string from a config value.
110
+ // raw may be a string, a map with a "text" key, or nil (not present).
111
+ // Returns (text, true, nil) on success, ("", false, nil) when absent, or
112
+ // ("", true, err) / ("", false, err) on a type mismatch. label is used in
113
+ // error messages and should describe the config source (e.g. "\"text\"").
99
114
  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
115
+ if raw == nil {
116
+ return "", false, nil
117
+ }
118
+ text, ok := raw.(string)
119
+ if ok {
120
+ if strings.TrimSpace(text) == "" {
121
+ return "", true, fmt.Errorf("@ttsc/banner: %s must be a non-empty string", label)
122
+ }
123
+ return text, true, nil
124
+ }
125
+ object, ok := raw.(map[string]any)
126
+ if !ok {
127
+ return "", true, fmt.Errorf("@ttsc/banner: %s must be a string or an object with a non-empty \"text\" string", label)
128
+ }
129
+ rawText, ok := object["text"]
130
+ if !ok {
131
+ return "", false, nil
132
+ }
133
+ text, ok = rawText.(string)
134
+ if !ok || strings.TrimSpace(text) == "" {
135
+ return "", true, fmt.Errorf("@ttsc/banner: %s.text must be a non-empty string", label)
136
+ }
137
+ return text, true, nil
123
138
  }
124
139
 
140
+ // findBannerConfigFile walks up from the tsconfig (or cwd) directory looking for
141
+ // a banner.config.{js,cjs,mjs,ts,cts,mts} file. Returns the path when exactly
142
+ // one match is found per directory, "" when none exists at any level, or an
143
+ // error when multiple candidates exist in the same directory.
125
144
  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
- }
145
+ dir := discoveryConfigBaseDir(cwd, tsconfigPath)
146
+ for {
147
+ matches := make([]string, 0, 1)
148
+ for _, name := range []string{
149
+ "banner.config.js",
150
+ "banner.config.cjs",
151
+ "banner.config.mjs",
152
+ "banner.config.ts",
153
+ "banner.config.cts",
154
+ "banner.config.mts",
155
+ } {
156
+ candidate := filepath.Join(dir, name)
157
+ if stat, err := os.Stat(candidate); err == nil && !stat.IsDir() {
158
+ matches = append(matches, candidate)
159
+ }
160
+ }
161
+ if len(matches) > 1 {
162
+ return "", fmt.Errorf("@ttsc/banner: multiple banner.config.* files found in %s", dir)
163
+ }
164
+ if len(matches) == 1 {
165
+ return matches[0], nil
166
+ }
167
+ parent := filepath.Dir(dir)
168
+ if parent == dir {
169
+ return "", nil
170
+ }
171
+ dir = parent
172
+ }
154
173
  }
155
174
 
175
+ // resolveBannerConfigPath resolves a config path from the plugin entry.
176
+ // Absolute paths are returned as-is; relative paths are resolved against the
177
+ // tsconfig directory (or cwd when no tsconfig is set).
156
178
  func resolveBannerConfigPath(configPath, cwd, tsconfigPath string) string {
157
- if filepath.IsAbs(configPath) {
158
- return configPath
159
- }
160
- return filepath.Join(tsconfigBaseDir(cwd, tsconfigPath), configPath)
179
+ if filepath.IsAbs(configPath) {
180
+ return configPath
181
+ }
182
+ return filepath.Join(tsconfigBaseDir(cwd, tsconfigPath), configPath)
161
183
  }
162
184
 
185
+ // tsconfigBaseDir returns the directory that contains the tsconfig file, or cwd
186
+ // when tsconfigPath is empty. Used as the base for resolving explicit config paths.
163
187
  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)
188
+ if tsconfigPath == "" {
189
+ return cwd
190
+ }
191
+ resolvedTsconfig := tsconfigPath
192
+ if !filepath.IsAbs(resolvedTsconfig) {
193
+ resolvedTsconfig = filepath.Join(cwd, resolvedTsconfig)
194
+ }
195
+ return filepath.Dir(resolvedTsconfig)
172
196
  }
173
197
 
198
+ // discoveryConfigBaseDir returns the starting directory for the upward banner
199
+ // config file search. Semantically identical to tsconfigBaseDir; kept separate
200
+ // to make the call sites self-documenting.
174
201
  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
202
+ if tsconfigPath != "" {
203
+ resolvedTsconfig := tsconfigPath
204
+ if !filepath.IsAbs(resolvedTsconfig) {
205
+ resolvedTsconfig = filepath.Join(cwd, resolvedTsconfig)
206
+ }
207
+ return filepath.Dir(resolvedTsconfig)
208
+ }
209
+ return cwd
183
210
  }
184
211
 
212
+ // loadBannerConfigFile loads and evaluates a banner config file, returning its
213
+ // exported value as a Go any (string or map[string]any). The file must be named
214
+ // banner.config.{js,cjs,mjs,ts,cts,mts}; JS/CJS/MJS variants run under Node,
215
+ // TypeScript variants compile and run via ttsx in a temp directory.
185
216
  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)
217
+ if !isBannerConfigFileName(filepath.Base(location)) {
218
+ return nil, fmt.Errorf("@ttsc/banner: config file must be named banner.config.{js,cjs,mjs,ts,mts,cts}: %s", location)
219
+ }
220
+ ext := strings.ToLower(filepath.Ext(location))
221
+ switch ext {
222
+ case ".js", ".cjs", ".mjs":
223
+ return loadBannerScriptConfigFile(location)
224
+ }
225
+ return loadBannerTypeScriptConfigFile(location)
195
226
  }
196
227
 
228
+ // isBannerConfigFileName reports whether name is an allowed banner config file name.
197
229
  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
- }
230
+ switch name {
231
+ case "banner.config.js",
232
+ "banner.config.cjs",
233
+ "banner.config.mjs",
234
+ "banner.config.ts",
235
+ "banner.config.cts",
236
+ "banner.config.mts":
237
+ return true
238
+ default:
239
+ return false
240
+ }
209
241
  }
210
242
 
243
+ // loadBannerScriptConfigFile evaluates a JS/CJS/MJS banner config file by
244
+ // running a small Node.js loader script that dynamic-imports the file and
245
+ // serializes its exported value to stdout as JSON.
211
246
  func loadBannerScriptConfigFile(location string) (any, error) {
212
- const script = `
247
+ const script = `
213
248
  const { pathToFileURL } = require("node:url");
214
249
 
215
250
  (async () => {
@@ -232,99 +267,107 @@ function toSerializableBanner(value) {
232
267
  throw new Error("config file must export a string or an object with a text string");
233
268
  }
234
269
  `
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
270
+ node := os.Getenv("TTSC_NODE_BINARY")
271
+ if node == "" {
272
+ node = "node"
273
+ }
274
+ cmd := exec.Command(node, "-e", script, location)
275
+ cmd.Env = nodeConfigLoaderEnv(location)
276
+ output, err := cmd.Output()
277
+ if err != nil {
278
+ stderr := ""
279
+ if exit, ok := err.(*exec.ExitError); ok {
280
+ stderr = strings.TrimSpace(string(exit.Stderr))
281
+ }
282
+ if stderr != "" {
283
+ return nil, fmt.Errorf("@ttsc/banner: load config file %s: %s", location, stderr)
284
+ }
285
+ return nil, fmt.Errorf("@ttsc/banner: load config file %s: %w", location, err)
286
+ }
287
+ var out any
288
+ if err := json.Unmarshal(output, &out); err != nil {
289
+ return nil, fmt.Errorf("@ttsc/banner: parse config file %s output: %w", location, err)
290
+ }
291
+ return out, nil
257
292
  }
258
293
 
294
+ // loadBannerTypeScriptConfigFile compiles and runs a TypeScript banner config
295
+ // file using ttsx in a temp directory. A symlink to the nearest node_modules
296
+ // is created so the config file can import its own dependencies.
259
297
  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
298
+ tempDir, err := os.MkdirTemp("", "ttsc-banner-config-")
299
+ if err != nil {
300
+ return nil, fmt.Errorf("@ttsc/banner: create config loader tempdir: %w", err)
301
+ }
302
+ defer os.RemoveAll(tempDir)
303
+
304
+ if err := linkConfigNodeModules(tempDir, filepath.Dir(location)); err != nil {
305
+ return nil, err
306
+ }
307
+
308
+ loader := filepath.Join(tempDir, "loader.mts")
309
+ tsconfig := filepath.Join(tempDir, "tsconfig.json")
310
+ importSpecifier, err := relativeImportSpecifier(tempDir, location)
311
+ if err != nil {
312
+ return nil, err
313
+ }
314
+ importLiteral, _ := json.Marshal(importSpecifier)
315
+ if err := writeConfigLoaderFile(loader, []byte(bannerTypeScriptConfigLoaderSource(string(importLiteral))), 0o644); err != nil {
316
+ return nil, fmt.Errorf("@ttsc/banner: write config loader: %w", err)
317
+ }
318
+ if err := writeConfigLoaderFile(tsconfig, []byte(typeScriptConfigLoaderTsconfig(loader, location, tempDir)), 0o644); err != nil {
319
+ return nil, fmt.Errorf("@ttsc/banner: write config loader tsconfig: %w", err)
320
+ }
321
+
322
+ args := []string{
323
+ "--project", tsconfig,
324
+ "--cwd", tempDir,
325
+ "--cache-dir", filepath.Join(tempDir, "cache"),
326
+ }
327
+ if tsgo := os.Getenv("TTSC_TSGO_BINARY"); tsgo != "" {
328
+ args = append(args, "--binary", tsgo)
329
+ }
330
+ args = append(args, loader)
331
+
332
+ cmd := ttsxCommand(args...)
333
+ cmd.Env = nodeConfigLoaderEnv(location)
334
+ output, err := cmd.Output()
335
+ if err != nil {
336
+ stderr := ""
337
+ if exit, ok := err.(*exec.ExitError); ok {
338
+ stderr = strings.TrimSpace(string(exit.Stderr))
339
+ }
340
+ if stderr != "" {
341
+ return nil, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %s", location, stderr)
342
+ }
343
+ return nil, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %w", location, err)
344
+ }
345
+ var out any
346
+ if err := json.Unmarshal(output, &out); err != nil {
347
+ return nil, fmt.Errorf("@ttsc/banner: parse TypeScript config file %s output: %w", location, err)
348
+ }
349
+ return out, nil
312
350
  }
313
351
 
352
+ // relativeImportSpecifier returns a "./" or "../"-prefixed slash-separated
353
+ // import specifier for location relative to fromDir.
314
354
  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
355
+ relative, err := filepath.Rel(fromDir, location)
356
+ if err != nil {
357
+ return "", fmt.Errorf("@ttsc/banner: resolve relative config import %s: %w", location, err)
358
+ }
359
+ relative = filepath.ToSlash(relative)
360
+ if strings.HasPrefix(relative, "../") || strings.HasPrefix(relative, "./") {
361
+ return relative, nil
362
+ }
363
+ return "./" + relative, nil
324
364
  }
325
365
 
366
+ // bannerTypeScriptConfigLoaderSource returns the source of a TypeScript loader
367
+ // module that imports the banner config file specified by importLiteral (a
368
+ // JSON-encoded import specifier) and writes the serialized banner value to stdout.
326
369
  func bannerTypeScriptConfigLoaderSource(importLiteral string) string {
327
- return fmt.Sprintf(`import * as importedConfig from %s;
370
+ return fmt.Sprintf(`import * as importedConfig from %s;
328
371
 
329
372
  declare const process: {
330
373
  stdout: { write(value: string): void };
@@ -375,105 +418,123 @@ function toSerializableBanner(value: unknown): unknown {
375
418
  `, importLiteral)
376
419
  }
377
420
 
421
+ // typeScriptConfigLoaderTsconfig returns the JSON content of a tsconfig that
422
+ // compiles loader and location together so ttsx can execute the loader.
378
423
  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)
424
+ content := map[string]any{
425
+ "compilerOptions": map[string]any{
426
+ "allowImportingTsExtensions": true,
427
+ "module": "ESNext",
428
+ "moduleResolution": "bundler",
429
+ "outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
430
+ "rewriteRelativeImportExtensions": true,
431
+ "rootDir": "/",
432
+ "skipLibCheck": true,
433
+ "strict": true,
434
+ "target": "ES2022",
435
+ },
436
+ "files": []string{
437
+ filepath.ToSlash(loader),
438
+ filepath.ToSlash(location),
439
+ },
440
+ }
441
+ body, _ := json.MarshalIndent(content, "", " ")
442
+ return string(body)
398
443
  }
399
444
 
445
+ // ttsxCommand builds an exec.Cmd that runs ttsx with the given args.
446
+ // When TTSC_TTSX_BINARY has a script extension (.js, .ts, …) the binary is
447
+ // invoked via the Node runtime so it is executed correctly on all platforms.
400
448
  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...)
449
+ ttsx := os.Getenv("TTSC_TTSX_BINARY")
450
+ if ttsx == "" {
451
+ ttsx = "ttsx"
452
+ }
453
+ if shouldRunTtsxThroughNode(ttsx) {
454
+ node := os.Getenv("TTSC_NODE_BINARY")
455
+ if node == "" {
456
+ node = "node"
457
+ }
458
+ return exec.Command(node, append([]string{ttsx}, args...)...)
459
+ }
460
+ return exec.Command(ttsx, args...)
413
461
  }
414
462
 
463
+ // shouldRunTtsxThroughNode reports whether binary has a script file extension
464
+ // and therefore must be launched via node rather than executed directly.
415
465
  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
- }
466
+ switch strings.ToLower(filepath.Ext(binary)) {
467
+ case ".js", ".cjs", ".mjs", ".ts", ".cts", ".mts":
468
+ return true
469
+ default:
470
+ return false
471
+ }
422
472
  }
423
473
 
474
+ // nodeConfigLoaderEnv builds an environment slice for the config-loader Node
475
+ // process. It prepends the nearest node_modules directory to NODE_PATH so
476
+ // the config file can resolve its own package dependencies.
424
477
  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)))
478
+ env := os.Environ()
479
+ parts := make([]string, 0, 2)
480
+ if nodeModules := findNearestNodeModules(filepath.Dir(location)); nodeModules != "" {
481
+ parts = append(parts, nodeModules)
482
+ }
483
+ if existing := os.Getenv("NODE_PATH"); existing != "" {
484
+ parts = append(parts, existing)
485
+ }
486
+ if len(parts) == 0 {
487
+ return env
488
+ }
489
+ return setEnv(env, "NODE_PATH", strings.Join(parts, string(os.PathListSeparator)))
437
490
  }
438
491
 
492
+ // linkNearestNodeModules creates a node_modules symlink inside tempDir pointing
493
+ // to the nearest node_modules ancestor of sourceDir. Does nothing when none is found.
439
494
  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
495
+ nodeModules := findNearestNodeModules(sourceDir)
496
+ if nodeModules == "" {
497
+ return nil
498
+ }
499
+ link := filepath.Join(tempDir, "node_modules")
500
+ if err := os.Symlink(nodeModules, link); err != nil {
501
+ return fmt.Errorf("@ttsc/banner: link config node_modules %s: %w", nodeModules, err)
502
+ }
503
+ return nil
449
504
  }
450
505
 
506
+ // findNearestNodeModules walks up from start looking for a node_modules directory.
507
+ // Returns the absolute path of the first match, or "" when none is found.
451
508
  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
- }
509
+ dir := filepath.Clean(start)
510
+ for {
511
+ candidate := filepath.Join(dir, "node_modules")
512
+ if stat, err := os.Stat(candidate); err == nil && stat.IsDir() {
513
+ return candidate
514
+ }
515
+ parent := filepath.Dir(dir)
516
+ if parent == dir {
517
+ return ""
518
+ }
519
+ dir = parent
520
+ }
464
521
  }
465
522
 
523
+ // setEnv returns a copy of env with key=value. If key already exists in env,
524
+ // its value is updated in-place; otherwise the entry is appended.
466
525
  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)
526
+ prefix := key + "="
527
+ for i, entry := range env {
528
+ if strings.HasPrefix(entry, prefix) {
529
+ env[i] = prefix + value
530
+ return env
531
+ }
532
+ }
533
+ return append(env, prefix+value)
475
534
  }
476
535
 
536
+ // sanitizeJSDocLine escapes any JSDoc-closing sequence in a banner text line
537
+ // by replacing "*/" with "* /" so the generated block comment stays valid.
477
538
  func sanitizeJSDocLine(line string) string {
478
- return strings.ReplaceAll(line, "*/", "* /")
539
+ return strings.ReplaceAll(line, "*/", "* /")
479
540
  }
package/lib/index.js CHANGED
@@ -21,11 +21,21 @@ exports.default = createTtscBanner;
21
21
  const node_path_1 = __importDefault(require("node:path"));
22
22
  __exportStar(require("./structures/index"), exports);
23
23
  /**
24
+ * Plugin factory for `@ttsc/banner` — called by the ttsc host to obtain the
25
+ * plugin descriptor.
26
+ *
27
+ * The factory is intentionally minimal: the banner plugin has no factory-level
28
+ * configuration that would alter which Go source to compile. All banner options
29
+ * (text, config path, enabled flag) are read at transform time by the Go
30
+ * driver.
31
+ *
24
32
  * @internal
25
33
  */
26
34
  function createTtscBanner(_context) {
27
35
  return {
28
36
  name: "@ttsc/banner",
37
+ // Point at the `driver/` directory one level above `lib/` in the
38
+ // installed package tree (where the Go sources live).
29
39
  source: node_path_1.default.resolve(__dirname, "..", "driver"),
30
40
  stage: "transform",
31
41
  };
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;;;;;;;;;;GAUG;AACH,0BACE,QAA2D;IAE3D,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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttsc/banner",
3
- "version": "0.12.3",
3
+ "version": "0.12.4",
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.20260519.1",
37
37
  "rimraf": "^6.1.2",
38
- "ttsc": "0.12.3"
38
+ "ttsc": "0.12.4"
39
39
  },
40
40
  "repository": {
41
41
  "type": "git",
package/src/index.ts CHANGED
@@ -4,21 +4,57 @@ 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
+ * Plugin factory for `@ttsc/banner` — called by the ttsc host to obtain the
51
+ * plugin descriptor.
52
+ *
53
+ * The factory is intentionally minimal: the banner plugin has no factory-level
54
+ * configuration that would alter which Go source to compile. All banner options
55
+ * (text, config path, enabled flag) are read at transform time by the Go
56
+ * driver.
57
+ *
22
58
  * @internal
23
59
  */
24
60
  export default function createTtscBanner(
@@ -26,6 +62,8 @@ export default function createTtscBanner(
26
62
  ): TtscPluginDescriptor {
27
63
  return {
28
64
  name: "@ttsc/banner",
65
+ // Point at the `driver/` directory one level above `lib/` in the
66
+ // installed package tree (where the Go sources live).
29
67
  source: path.resolve(__dirname, "..", "driver"),
30
68
  stage: "transform",
31
69
  };