ttsc 0.8.0 → 0.9.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.
Files changed (84) hide show
  1. package/README.md +75 -66
  2. package/cmd/ttsc/api_compile.go +5 -1
  3. package/driver/program.go +23 -1
  4. package/driver/rewrite.go +126 -9
  5. package/driver/rewrite_test.go +52 -0
  6. package/lib/TtscCompiler.js +28 -46
  7. package/lib/TtscCompiler.js.map +1 -1
  8. package/lib/compiler/internal/buildNativeCompiler.js +8 -38
  9. package/lib/compiler/internal/buildNativeCompiler.js.map +1 -1
  10. package/lib/compiler/internal/compileProjectInMemory.js +36 -63
  11. package/lib/compiler/internal/compileProjectInMemory.js.map +1 -1
  12. package/lib/compiler/internal/project/readProjectConfig.js +108 -60
  13. package/lib/compiler/internal/project/readProjectConfig.js.map +1 -1
  14. package/lib/compiler/internal/project/resolveProjectConfig.js +15 -45
  15. package/lib/compiler/internal/project/resolveProjectConfig.js.map +1 -1
  16. package/lib/compiler/internal/resolveBinary.js +10 -40
  17. package/lib/compiler/internal/resolveBinary.js.map +1 -1
  18. package/lib/compiler/internal/resolveEmittedJavaScript.js +21 -48
  19. package/lib/compiler/internal/resolveEmittedJavaScript.js.map +1 -1
  20. package/lib/compiler/internal/resolveTsgo.js +14 -44
  21. package/lib/compiler/internal/resolveTsgo.js.map +1 -1
  22. package/lib/compiler/internal/runBuild.d.ts +7 -0
  23. package/lib/compiler/internal/runBuild.js +47 -66
  24. package/lib/compiler/internal/runBuild.js.map +1 -1
  25. package/lib/compiler/internal/runSingleFileEmit.js +18 -48
  26. package/lib/compiler/internal/runSingleFileEmit.js.map +1 -1
  27. package/lib/compiler/internal/transformProjectInMemory.js +41 -75
  28. package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
  29. package/lib/launcher/internal/getCompilerVersionText.js +7 -37
  30. package/lib/launcher/internal/getCompilerVersionText.js.map +1 -1
  31. package/lib/launcher/internal/prepareExecution.js +48 -69
  32. package/lib/launcher/internal/prepareExecution.js.map +1 -1
  33. package/lib/launcher/internal/runTtsc.js +53 -64
  34. package/lib/launcher/internal/runTtsc.js.map +1 -1
  35. package/lib/launcher/internal/runTtsx.js +328 -57
  36. package/lib/launcher/internal/runTtsx.js.map +1 -1
  37. package/lib/plugin/internal/buildSourcePlugin.d.ts +8 -0
  38. package/lib/plugin/internal/buildSourcePlugin.js +179 -125
  39. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  40. package/lib/plugin/internal/loadProjectPlugins.d.ts +2 -0
  41. package/lib/plugin/internal/loadProjectPlugins.js +197 -9
  42. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  43. package/lib/structures/ITtscCompilerContext.d.ts +16 -3
  44. package/lib/structures/ITtscCompilerResult.d.ts +4 -0
  45. package/lib/structures/ITtscCompilerTransformation.d.ts +2 -0
  46. package/lib/structures/ITtscPluginFactoryContext.d.ts +6 -3
  47. package/lib/structures/ITtscProjectPluginConfig.d.ts +8 -7
  48. package/lib/structures/internal/ITtscParsedProjectConfig.d.ts +2 -0
  49. package/lib/structures/internal/ITtscProjectLocatorOptions.d.ts +2 -0
  50. package/lib/structures/internal/TtscCommonOptions.d.ts +2 -0
  51. package/package.json +9 -14
  52. package/shim/diagnosticwriter/lint.go +8 -0
  53. package/src/TtscCompiler.ts +20 -5
  54. package/src/compiler/internal/buildNativeCompiler.ts +2 -2
  55. package/src/compiler/internal/compileProjectInMemory.ts +21 -16
  56. package/src/compiler/internal/project/readProjectConfig.ts +134 -35
  57. package/src/compiler/internal/project/resolveProjectConfig.ts +2 -2
  58. package/src/compiler/internal/resolveBinary.ts +2 -2
  59. package/src/compiler/internal/resolveEmittedJavaScript.ts +11 -7
  60. package/src/compiler/internal/resolveTsgo.ts +2 -2
  61. package/src/compiler/internal/runBuild.ts +34 -21
  62. package/src/compiler/internal/runSingleFileEmit.ts +3 -3
  63. package/src/compiler/internal/transformProjectInMemory.ts +37 -37
  64. package/src/launcher/internal/getCompilerVersionText.ts +2 -2
  65. package/src/launcher/internal/prepareExecution.ts +14 -4
  66. package/src/launcher/internal/runTtsc.ts +30 -4
  67. package/src/launcher/internal/runTtsx.ts +377 -26
  68. package/src/plugin/internal/buildSourcePlugin.ts +127 -32
  69. package/src/plugin/internal/loadProjectPlugins.ts +266 -10
  70. package/src/structures/ITtscCompilerContext.ts +17 -3
  71. package/src/structures/ITtscCompilerResult.ts +5 -0
  72. package/src/structures/ITtscCompilerTransformation.ts +3 -0
  73. package/src/structures/ITtscPluginFactoryContext.ts +6 -3
  74. package/src/structures/ITtscProjectPluginConfig.ts +8 -7
  75. package/src/structures/internal/ITtscParsedProjectConfig.ts +2 -0
  76. package/src/structures/internal/ITtscProjectLocatorOptions.ts +2 -0
  77. package/src/structures/internal/TtscCommonOptions.ts +2 -0
  78. package/utility/host.go +610 -18
  79. package/utility/host_test.go +162 -0
  80. package/test/compiler.test.ts +0 -422
  81. package/test/platform.test.ts +0 -12
  82. package/test/project.test.ts +0 -197
  83. package/test/source-build.test.ts +0 -45
  84. package/test/tsgo.test.ts +0 -69
package/utility/host.go CHANGED
@@ -5,6 +5,7 @@ import (
5
5
  "flag"
6
6
  "fmt"
7
7
  "os"
8
+ "os/exec"
8
9
  "path/filepath"
9
10
  "sort"
10
11
  "strings"
@@ -43,6 +44,22 @@ type transformResult struct {
43
44
  TypeScript map[string]string `json:"typescript"`
44
45
  }
45
46
 
47
+ // RunCheck validates the project and first-party utility plugin configuration
48
+ // without emitting output.
49
+ func RunCheck(args []string) int {
50
+ opts, ok := parseHostOptions("check", args)
51
+ if !ok {
52
+ return 2
53
+ }
54
+ opts.noEmit = true
55
+ prog, _, _, _, ok := loadUtilityProgram(opts)
56
+ if !ok {
57
+ return 2
58
+ }
59
+ defer prog.Close()
60
+ return 0
61
+ }
62
+
46
63
  // RunBuild hosts first-party utility transform plugins inside one compiler emit.
47
64
  func RunBuild(args []string) int {
48
65
  opts, ok := parseHostOptions("build", args)
@@ -76,6 +93,9 @@ func RunBuild(args []string) int {
76
93
  for _, d := range eDiags {
77
94
  fmt.Fprintln(os.Stderr, " -", d.String())
78
95
  }
96
+ if driver.CountErrors(eDiags) > 0 {
97
+ return 2
98
+ }
79
99
  if res != nil && !opts.quiet {
80
100
  fmt.Fprintf(os.Stdout, "// ttsc utility: emitted=%d files\n", len(res.EmittedFiles))
81
101
  }
@@ -117,7 +137,7 @@ func parseHostOptions(command string, args []string) (hostOptions, bool) {
117
137
  quiet := fs.Bool("quiet", true, "suppress summary")
118
138
  tsconfig := fs.String("tsconfig", "tsconfig.json", "project tsconfig")
119
139
  verbose := fs.Bool("verbose", false, "print summary")
120
- if err := fs.Parse(args); err != nil {
140
+ if err := fs.Parse(filterHostArgs(args)); err != nil {
121
141
  return hostOptions{}, false
122
142
  }
123
143
  resolvedCwd := *cwd
@@ -149,13 +169,57 @@ func parseHostOptions(command string, args []string) (hostOptions, bool) {
149
169
  }, true
150
170
  }
151
171
 
172
+ func filterHostArgs(args []string) []string {
173
+ known := map[string]bool{
174
+ "cwd": true,
175
+ "emit": false,
176
+ "noEmit": false,
177
+ "outDir": true,
178
+ "plugins-json": true,
179
+ "quiet": false,
180
+ "tsconfig": true,
181
+ "verbose": false,
182
+ }
183
+ filtered := make([]string, 0, len(args))
184
+ for i := 0; i < len(args); i++ {
185
+ current := args[i]
186
+ if current == "--" {
187
+ break
188
+ }
189
+ if !strings.HasPrefix(current, "--") {
190
+ filtered = append(filtered, current)
191
+ continue
192
+ }
193
+ name, hasInlineValue := flagName(current)
194
+ takesValue, ok := known[name]
195
+ if ok {
196
+ filtered = append(filtered, current)
197
+ if takesValue && !hasInlineValue && i+1 < len(args) {
198
+ i++
199
+ filtered = append(filtered, args[i])
200
+ }
201
+ continue
202
+ }
203
+ if !hasInlineValue && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
204
+ i++
205
+ }
206
+ }
207
+ return filtered
208
+ }
209
+
210
+ func flagName(arg string) (string, bool) {
211
+ name := strings.TrimPrefix(arg, "--")
212
+ before, _, found := strings.Cut(name, "=")
213
+ return before, found
214
+ }
215
+
152
216
  func loadUtilityProgram(opts hostOptions) (*driver.Program, []pluginEntry, transformState, string, bool) {
153
217
  entries, err := parsePluginEntries(opts.pluginsJSON)
154
218
  if err != nil {
155
219
  fmt.Fprintln(os.Stderr, err)
156
220
  return nil, nil, transformState{}, "", false
157
221
  }
158
- sourcePreamble, err := prepareSourcePreamble(entries)
222
+ sourcePreamble, err := prepareSourcePreamble(entries, opts.cwd, opts.tsconfig)
159
223
  if err != nil {
160
224
  fmt.Fprintln(os.Stderr, err)
161
225
  return nil, nil, transformState{}, "", false
@@ -172,19 +236,25 @@ func loadUtilityProgram(opts hostOptions) (*driver.Program, []pluginEntry, trans
172
236
  }
173
237
  if len(diags) > 0 {
174
238
  driver.WritePrettyDiagnostics(os.Stderr, diags, opts.cwd)
239
+ if prog != nil {
240
+ _ = prog.Close()
241
+ }
175
242
  return nil, nil, transformState{}, "", false
176
243
  }
177
244
  if diags := prog.Diagnostics(); len(diags) > 0 {
178
245
  driver.WritePrettyDiagnostics(os.Stderr, diags, opts.cwd)
246
+ _ = prog.Close()
179
247
  return nil, nil, transformState{}, "", false
180
248
  }
181
249
  state, err := prepareTransforms(prog, entries)
182
250
  if err != nil {
183
251
  fmt.Fprintln(os.Stderr, err)
252
+ _ = prog.Close()
184
253
  return nil, nil, transformState{}, "", false
185
254
  }
186
255
  if err := applySourceTransforms(prog, state); err != nil {
187
256
  fmt.Fprintln(os.Stderr, err)
257
+ _ = prog.Close()
188
258
  return nil, nil, transformState{}, "", false
189
259
  }
190
260
  return prog, entries, state, sourcePreamble, true
@@ -218,13 +288,13 @@ func prepareTransforms(prog *driver.Program, entries []pluginEntry) (transformSt
218
288
  return state, nil
219
289
  }
220
290
 
221
- func prepareSourcePreamble(entries []pluginEntry) (string, error) {
291
+ func prepareSourcePreamble(entries []pluginEntry, cwd, tsconfigPath string) (string, error) {
222
292
  var preamble strings.Builder
223
293
  for _, entry := range entries {
224
294
  if entry.Name != "@ttsc/banner" {
225
295
  continue
226
296
  }
227
- banner, err := parseBanner(entry.Config)
297
+ banner, err := parseBanner(entry.Config, cwd, tsconfigPath)
228
298
  if err != nil {
229
299
  return "", err
230
300
  }
@@ -239,7 +309,7 @@ func makeSourcePreambleWriteFile(prog *driver.Program, sourcePreamble string) sh
239
309
  }
240
310
  return func(fileName, text string, _ *shimcompiler.WriteFileData) error {
241
311
  if shouldEnsureSourcePreamble(fileName, text, sourcePreamble) {
242
- text = sourcePreamble + text
312
+ text = driver.ApplySourcePreamble(text, sourcePreamble)
243
313
  }
244
314
  return driver.DefaultWriteFile(fileName, text)
245
315
  }
@@ -280,20 +350,20 @@ func applySourceTransforms(prog *driver.Program, state transformState) error {
280
350
 
281
351
  func apiOutputKey(cwd, fileName string) string {
282
352
  rel, err := filepath.Rel(cwd, fileName)
283
- if err != nil || strings.HasPrefix(rel, "..") {
353
+ if err != nil || isOutsideRelativePath(rel) {
284
354
  return filepath.ToSlash(fileName)
285
355
  }
286
356
  return filepath.ToSlash(rel)
287
357
  }
288
358
 
289
- func parseBanner(config map[string]any) (string, error) {
290
- raw, ok := config["banner"]
291
- if !ok {
292
- return "", fmt.Errorf("@ttsc/banner: \"banner\" must be a non-empty string")
293
- }
294
- text, ok := raw.(string)
295
- if !ok || strings.TrimSpace(text) == "" {
296
- return "", fmt.Errorf("@ttsc/banner: \"banner\" must be a non-empty string")
359
+ func isOutsideRelativePath(rel string) bool {
360
+ return rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator))
361
+ }
362
+
363
+ func parseBanner(config map[string]any, cwd, tsconfigPath string) (string, error) {
364
+ text, err := resolveBannerText(config, cwd, tsconfigPath)
365
+ if err != nil {
366
+ return "", err
297
367
  }
298
368
  lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
299
369
  for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
@@ -316,6 +386,437 @@ func parseBanner(config map[string]any) (string, error) {
316
386
  return b.String(), nil
317
387
  }
318
388
 
389
+ func resolveBannerText(config map[string]any, cwd, tsconfigPath string) (string, error) {
390
+ if text, ok, err := bannerTextFromConfigValue(config["text"], `"text"`); ok || err != nil {
391
+ return text, err
392
+ }
393
+ if rawConfigPath, ok := config["config"]; ok {
394
+ configPath, ok := rawConfigPath.(string)
395
+ if !ok || strings.TrimSpace(configPath) == "" {
396
+ return "", fmt.Errorf("@ttsc/banner: \"config\" must be a non-empty string path")
397
+ }
398
+ location := resolveBannerConfigPath(configPath, cwd, tsconfigPath)
399
+ raw, err := loadBannerConfigFile(location)
400
+ if err != nil {
401
+ return "", err
402
+ }
403
+ text, ok, err := bannerTextFromConfigValue(raw, filepath.Base(location))
404
+ if err != nil {
405
+ return "", err
406
+ }
407
+ if !ok {
408
+ return "", fmt.Errorf("@ttsc/banner: %s must export a non-empty string or an object with a non-empty \"text\" string", location)
409
+ }
410
+ return text, nil
411
+ }
412
+ location, err := findBannerConfigFile(cwd, tsconfigPath)
413
+ if err != nil {
414
+ return "", err
415
+ }
416
+ if location == "" {
417
+ 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")
418
+ }
419
+ raw, err := loadBannerConfigFile(location)
420
+ if err != nil {
421
+ return "", err
422
+ }
423
+ text, ok, err := bannerTextFromConfigValue(raw, filepath.Base(location))
424
+ if err != nil {
425
+ return "", err
426
+ }
427
+ if !ok {
428
+ return "", fmt.Errorf("@ttsc/banner: %s must export a non-empty string or an object with a non-empty \"text\" string", location)
429
+ }
430
+ return text, nil
431
+ }
432
+
433
+ func bannerTextFromConfigValue(raw any, label string) (string, bool, error) {
434
+ if raw == nil {
435
+ return "", false, nil
436
+ }
437
+ text, ok := raw.(string)
438
+ if ok {
439
+ if strings.TrimSpace(text) == "" {
440
+ return "", true, fmt.Errorf("@ttsc/banner: %s must be a non-empty string", label)
441
+ }
442
+ return text, true, nil
443
+ }
444
+ object, ok := raw.(map[string]any)
445
+ if !ok {
446
+ return "", true, fmt.Errorf("@ttsc/banner: %s must be a string or an object with a non-empty \"text\" string", label)
447
+ }
448
+ rawText, ok := object["text"]
449
+ if !ok {
450
+ return "", false, nil
451
+ }
452
+ text, ok = rawText.(string)
453
+ if !ok || strings.TrimSpace(text) == "" {
454
+ return "", true, fmt.Errorf("@ttsc/banner: %s.text must be a non-empty string", label)
455
+ }
456
+ return text, true, nil
457
+ }
458
+
459
+ func findBannerConfigFile(cwd, tsconfigPath string) (string, error) {
460
+ dir := discoveryConfigBaseDir(cwd, tsconfigPath)
461
+ for {
462
+ matches := make([]string, 0, 1)
463
+ for _, name := range []string{
464
+ "banner.config.js",
465
+ "banner.config.cjs",
466
+ "banner.config.mjs",
467
+ "banner.config.ts",
468
+ "banner.config.cts",
469
+ "banner.config.mts",
470
+ } {
471
+ candidate := filepath.Join(dir, name)
472
+ if stat, err := os.Stat(candidate); err == nil && !stat.IsDir() {
473
+ matches = append(matches, candidate)
474
+ }
475
+ }
476
+ if len(matches) > 1 {
477
+ return "", fmt.Errorf("@ttsc/banner: multiple banner.config.* files found in %s", dir)
478
+ }
479
+ if len(matches) == 1 {
480
+ return matches[0], nil
481
+ }
482
+ parent := filepath.Dir(dir)
483
+ if parent == dir {
484
+ return "", nil
485
+ }
486
+ dir = parent
487
+ }
488
+ }
489
+
490
+ func resolveBannerConfigPath(configPath, cwd, tsconfigPath string) string {
491
+ if filepath.IsAbs(configPath) {
492
+ return configPath
493
+ }
494
+ return filepath.Join(tsconfigBaseDir(cwd, tsconfigPath), configPath)
495
+ }
496
+
497
+ func tsconfigBaseDir(cwd, tsconfigPath string) string {
498
+ if tsconfigPath == "" {
499
+ return cwd
500
+ }
501
+ resolvedTsconfig := tsconfigPath
502
+ if !filepath.IsAbs(resolvedTsconfig) {
503
+ resolvedTsconfig = filepath.Join(cwd, resolvedTsconfig)
504
+ }
505
+ return filepath.Dir(resolvedTsconfig)
506
+ }
507
+
508
+ func discoveryConfigBaseDir(cwd, tsconfigPath string) string {
509
+ if tsconfigPath != "" {
510
+ resolvedTsconfig := tsconfigPath
511
+ if !filepath.IsAbs(resolvedTsconfig) {
512
+ resolvedTsconfig = filepath.Join(cwd, resolvedTsconfig)
513
+ }
514
+ return filepath.Dir(resolvedTsconfig)
515
+ }
516
+ return cwd
517
+ }
518
+
519
+ func loadBannerConfigFile(location string) (any, error) {
520
+ if !isBannerConfigFileName(filepath.Base(location)) {
521
+ return nil, fmt.Errorf("@ttsc/banner: config file must be named banner.config.{js,cjs,mjs,ts,mts,cts}: %s", location)
522
+ }
523
+ ext := strings.ToLower(filepath.Ext(location))
524
+ switch ext {
525
+ case ".js", ".cjs", ".mjs":
526
+ return loadBannerScriptConfigFile(location)
527
+ case ".ts", ".cts", ".mts":
528
+ return loadBannerTypeScriptConfigFile(location)
529
+ default:
530
+ return nil, fmt.Errorf("@ttsc/banner: unsupported config file extension %q for %s", ext, location)
531
+ }
532
+ }
533
+
534
+ func isBannerConfigFileName(name string) bool {
535
+ switch name {
536
+ case "banner.config.js",
537
+ "banner.config.cjs",
538
+ "banner.config.mjs",
539
+ "banner.config.ts",
540
+ "banner.config.cts",
541
+ "banner.config.mts":
542
+ return true
543
+ default:
544
+ return false
545
+ }
546
+ }
547
+
548
+ func loadBannerScriptConfigFile(location string) (any, error) {
549
+ const script = `
550
+ const { pathToFileURL } = require("node:url");
551
+
552
+ (async () => {
553
+ const mod = await import(pathToFileURL(process.argv[1]).href);
554
+ const candidate = Object.prototype.hasOwnProperty.call(mod, "default") ? mod.default : mod;
555
+ const value = typeof candidate === "function" ? await candidate() : candidate;
556
+ process.stdout.write(JSON.stringify(toSerializableBanner(value)));
557
+ })().catch((error) => {
558
+ process.stderr.write(error && error.stack ? error.stack : String(error));
559
+ process.exit(1);
560
+ });
561
+
562
+ function toSerializableBanner(value) {
563
+ if (typeof value === "string") {
564
+ return value;
565
+ }
566
+ if (value !== null && typeof value === "object" && typeof value.text === "string") {
567
+ return { text: value.text };
568
+ }
569
+ throw new Error("config file must export a string or an object with a text string");
570
+ }
571
+ `
572
+ node := os.Getenv("TTSC_NODE_BINARY")
573
+ if node == "" {
574
+ node = "node"
575
+ }
576
+ cmd := exec.Command(node, "-e", script, location)
577
+ cmd.Env = nodeConfigLoaderEnv(location)
578
+ output, err := cmd.Output()
579
+ if err != nil {
580
+ stderr := ""
581
+ if exit, ok := err.(*exec.ExitError); ok {
582
+ stderr = strings.TrimSpace(string(exit.Stderr))
583
+ }
584
+ if stderr != "" {
585
+ return nil, fmt.Errorf("@ttsc/banner: load config file %s: %s", location, stderr)
586
+ }
587
+ return nil, fmt.Errorf("@ttsc/banner: load config file %s: %w", location, err)
588
+ }
589
+ var out any
590
+ if err := json.Unmarshal(output, &out); err != nil {
591
+ return nil, fmt.Errorf("@ttsc/banner: parse config file %s output: %w", location, err)
592
+ }
593
+ return out, nil
594
+ }
595
+
596
+ func loadBannerTypeScriptConfigFile(location string) (any, error) {
597
+ tempDir, err := os.MkdirTemp("", "ttsc-banner-config-")
598
+ if err != nil {
599
+ return nil, fmt.Errorf("@ttsc/banner: create config loader tempdir: %w", err)
600
+ }
601
+ defer os.RemoveAll(tempDir)
602
+
603
+ if err := linkNearestNodeModules(tempDir, filepath.Dir(location)); err != nil {
604
+ return nil, err
605
+ }
606
+
607
+ loader := filepath.Join(tempDir, "loader.mts")
608
+ tsconfig := filepath.Join(tempDir, "tsconfig.json")
609
+ importSpecifier, err := relativeImportSpecifier(tempDir, location)
610
+ if err != nil {
611
+ return nil, err
612
+ }
613
+ importLiteral, err := json.Marshal(importSpecifier)
614
+ if err != nil {
615
+ return nil, fmt.Errorf("@ttsc/banner: encode config import %s: %w", location, err)
616
+ }
617
+ if err := os.WriteFile(loader, []byte(bannerTypeScriptConfigLoaderSource(string(importLiteral))), 0o644); err != nil {
618
+ return nil, fmt.Errorf("@ttsc/banner: write config loader: %w", err)
619
+ }
620
+ if err := os.WriteFile(tsconfig, []byte(typeScriptConfigLoaderTsconfig(loader, location, tempDir)), 0o644); err != nil {
621
+ return nil, fmt.Errorf("@ttsc/banner: write config loader tsconfig: %w", err)
622
+ }
623
+
624
+ args := []string{
625
+ "--project", tsconfig,
626
+ "--cwd", tempDir,
627
+ "--cache-dir", filepath.Join(tempDir, "cache"),
628
+ }
629
+ if tsgo := os.Getenv("TTSC_TSGO_BINARY"); tsgo != "" {
630
+ args = append(args, "--binary", tsgo)
631
+ }
632
+ args = append(args, loader)
633
+
634
+ cmd := ttsxCommand(args...)
635
+ cmd.Env = nodeConfigLoaderEnv(location)
636
+ output, err := cmd.Output()
637
+ if err != nil {
638
+ stderr := ""
639
+ if exit, ok := err.(*exec.ExitError); ok {
640
+ stderr = strings.TrimSpace(string(exit.Stderr))
641
+ }
642
+ if stderr != "" {
643
+ return nil, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %s", location, stderr)
644
+ }
645
+ return nil, fmt.Errorf("@ttsc/banner: load TypeScript config file %s: %w", location, err)
646
+ }
647
+ var out any
648
+ if err := json.Unmarshal(output, &out); err != nil {
649
+ return nil, fmt.Errorf("@ttsc/banner: parse TypeScript config file %s output: %w", location, err)
650
+ }
651
+ return out, nil
652
+ }
653
+
654
+ func relativeImportSpecifier(fromDir, location string) (string, error) {
655
+ relative, err := filepath.Rel(fromDir, location)
656
+ if err != nil {
657
+ return "", fmt.Errorf("@ttsc/banner: resolve relative config import %s: %w", location, err)
658
+ }
659
+ relative = filepath.ToSlash(relative)
660
+ if strings.HasPrefix(relative, "../") || strings.HasPrefix(relative, "./") {
661
+ return relative, nil
662
+ }
663
+ return "./" + relative, nil
664
+ }
665
+
666
+ func bannerTypeScriptConfigLoaderSource(importLiteral string) string {
667
+ return fmt.Sprintf(`import * as importedConfig from %s;
668
+
669
+ declare const process: {
670
+ stdout: { write(value: string): void };
671
+ stderr: { write(value: string): void };
672
+ exit(code?: number): never;
673
+ };
674
+
675
+ try {
676
+ const value = await resolveConfig(importedConfig);
677
+ process.stdout.write(JSON.stringify(toSerializableBanner(value)));
678
+ } catch (error) {
679
+ process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
680
+ process.exit(1);
681
+ }
682
+
683
+ async function resolveConfig(value: unknown): Promise<unknown> {
684
+ let current = value;
685
+ for (let i = 0; i < 8; i++) {
686
+ if (isObject(current) && hasOwn(current, "default")) {
687
+ current = current.default;
688
+ continue;
689
+ }
690
+ break;
691
+ }
692
+ if (typeof current === "function") {
693
+ return await (current as () => unknown | Promise<unknown>)();
694
+ }
695
+ return current;
696
+ }
697
+
698
+ function isObject(value: unknown): value is Record<string, unknown> {
699
+ return value !== null && typeof value === "object";
700
+ }
701
+
702
+ function hasOwn(value: Record<string, unknown>, key: string): boolean {
703
+ return Object.prototype.hasOwnProperty.call(value, key);
704
+ }
705
+
706
+ function toSerializableBanner(value: unknown): unknown {
707
+ if (typeof value === "string") {
708
+ return value;
709
+ }
710
+ if (isObject(value) && typeof value.text === "string") {
711
+ return { text: value.text };
712
+ }
713
+ throw new Error("config file must export a string or an object with a text string");
714
+ }
715
+ `, importLiteral)
716
+ }
717
+
718
+ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
719
+ content := map[string]any{
720
+ "compilerOptions": map[string]any{
721
+ "allowImportingTsExtensions": true,
722
+ "module": "ESNext",
723
+ "moduleResolution": "bundler",
724
+ "outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
725
+ "rewriteRelativeImportExtensions": true,
726
+ "rootDir": "/",
727
+ "skipLibCheck": true,
728
+ "strict": true,
729
+ "target": "ES2022",
730
+ },
731
+ "files": []string{
732
+ filepath.ToSlash(loader),
733
+ filepath.ToSlash(location),
734
+ },
735
+ }
736
+ body, err := json.MarshalIndent(content, "", " ")
737
+ if err != nil {
738
+ panic(err)
739
+ }
740
+ return string(body)
741
+ }
742
+
743
+ func ttsxCommand(args ...string) *exec.Cmd {
744
+ ttsx := os.Getenv("TTSC_TTSX_BINARY")
745
+ if ttsx == "" {
746
+ ttsx = "ttsx"
747
+ }
748
+ if shouldRunTtsxThroughNode(ttsx) {
749
+ node := os.Getenv("TTSC_NODE_BINARY")
750
+ if node == "" {
751
+ node = "node"
752
+ }
753
+ return exec.Command(node, append([]string{ttsx}, args...)...)
754
+ }
755
+ return exec.Command(ttsx, args...)
756
+ }
757
+
758
+ func shouldRunTtsxThroughNode(binary string) bool {
759
+ switch strings.ToLower(filepath.Ext(binary)) {
760
+ case ".js", ".cjs", ".mjs", ".ts", ".cts", ".mts":
761
+ return true
762
+ default:
763
+ return false
764
+ }
765
+ }
766
+
767
+ func nodeConfigLoaderEnv(location string) []string {
768
+ env := os.Environ()
769
+ parts := make([]string, 0, 2)
770
+ if nodeModules := findNearestNodeModules(filepath.Dir(location)); nodeModules != "" {
771
+ parts = append(parts, nodeModules)
772
+ }
773
+ if existing := os.Getenv("NODE_PATH"); existing != "" {
774
+ parts = append(parts, existing)
775
+ }
776
+ if len(parts) == 0 {
777
+ return env
778
+ }
779
+ return setEnv(env, "NODE_PATH", strings.Join(parts, string(os.PathListSeparator)))
780
+ }
781
+
782
+ func linkNearestNodeModules(tempDir, sourceDir string) error {
783
+ nodeModules := findNearestNodeModules(sourceDir)
784
+ if nodeModules == "" {
785
+ return nil
786
+ }
787
+ link := filepath.Join(tempDir, "node_modules")
788
+ if err := os.Symlink(nodeModules, link); err != nil {
789
+ return fmt.Errorf("@ttsc/banner: link config node_modules %s: %w", nodeModules, err)
790
+ }
791
+ return nil
792
+ }
793
+
794
+ func findNearestNodeModules(start string) string {
795
+ dir := filepath.Clean(start)
796
+ for {
797
+ candidate := filepath.Join(dir, "node_modules")
798
+ if stat, err := os.Stat(candidate); err == nil && stat.IsDir() {
799
+ return candidate
800
+ }
801
+ parent := filepath.Dir(dir)
802
+ if parent == dir {
803
+ return ""
804
+ }
805
+ dir = parent
806
+ }
807
+ }
808
+
809
+ func setEnv(env []string, key, value string) []string {
810
+ prefix := key + "="
811
+ for i, entry := range env {
812
+ if strings.HasPrefix(entry, prefix) {
813
+ env[i] = prefix + value
814
+ return env
815
+ }
816
+ }
817
+ return append(env, prefix+value)
818
+ }
819
+
319
820
  func sanitizeJSDocLine(line string) string {
320
821
  return strings.ReplaceAll(line, "*/", "* /")
321
822
  }
@@ -402,9 +903,14 @@ func visitModuleSpecifiers(node *shimast.Node, visit func(*shimast.Node)) {
402
903
  if arg != nil && arg.Kind == shimast.KindLiteralType {
403
904
  visit(arg.AsLiteralTypeNode().Literal)
404
905
  }
906
+ case shimast.KindModuleDeclaration:
907
+ decl := node.AsModuleDeclaration()
908
+ if decl != nil {
909
+ visit(decl.Name())
910
+ }
405
911
  case shimast.KindCallExpression:
406
912
  call := node.AsCallExpression()
407
- if call.Expression != nil && call.Expression.Kind == shimast.KindImportKeyword && call.Arguments != nil && len(call.Arguments.Nodes) > 0 {
913
+ if isModuleSpecifierCall(call) && call.Arguments != nil && len(call.Arguments.Nodes) > 0 {
408
914
  visit(call.Arguments.Nodes[0])
409
915
  }
410
916
  }
@@ -414,6 +920,20 @@ func visitModuleSpecifiers(node *shimast.Node, visit func(*shimast.Node)) {
414
920
  })
415
921
  }
416
922
 
923
+ func isModuleSpecifierCall(call *shimast.CallExpression) bool {
924
+ if call == nil || call.Expression == nil {
925
+ return false
926
+ }
927
+ switch call.Expression.Kind {
928
+ case shimast.KindImportKeyword:
929
+ return true
930
+ case shimast.KindIdentifier:
931
+ return call.Expression.Text() == "require"
932
+ default:
933
+ return false
934
+ }
935
+ }
936
+
417
937
  func (r *pathsRewriter) rewrite(fromSource string, specifier string) (string, bool) {
418
938
  if specifier == "" || strings.HasPrefix(specifier, ".") || strings.HasPrefix(specifier, "/") {
419
939
  return specifier, false
@@ -468,6 +988,11 @@ func (r *pathsRewriter) lookupSource(candidate string) (string, bool) {
468
988
  return source, true
469
989
  }
470
990
  }
991
+ for _, ext := range []string{".ts", ".tsx", ".mts", ".cts"} {
992
+ if source, ok := r.sourceFiles[normalizePath(filepath.Join(stem, "index"+ext))]; ok {
993
+ return source, true
994
+ }
995
+ }
471
996
  return "", false
472
997
  }
473
998
 
@@ -476,10 +1001,21 @@ func (r *pathsRewriter) outputPathForSource(source string) string {
476
1001
  return ""
477
1002
  }
478
1003
  rel, err := filepath.Rel(r.rootDir, source)
479
- if err != nil || strings.HasPrefix(rel, "..") {
1004
+ if err != nil || isOutsideRelativePath(rel) {
480
1005
  return ""
481
1006
  }
482
- return normalizePath(filepath.Join(r.outDir, replaceSourceExtension(rel, ".js")))
1007
+ return normalizePath(filepath.Join(r.outDir, replaceSourceExtension(rel, emittedJavaScriptExtension(rel))))
1008
+ }
1009
+
1010
+ func emittedJavaScriptExtension(source string) string {
1011
+ switch strings.ToLower(filepath.Ext(source)) {
1012
+ case ".mts":
1013
+ return ".mjs"
1014
+ case ".cts":
1015
+ return ".cjs"
1016
+ default:
1017
+ return ".js"
1018
+ }
483
1019
  }
484
1020
 
485
1021
  func matchPattern(pattern string, specifier string) (string, bool) {
@@ -557,6 +1093,14 @@ type callPattern struct {
557
1093
  }
558
1094
 
559
1095
  func parseStrip(config map[string]any) (*stripRewriter, error) {
1096
+ _, hasCalls := config["calls"]
1097
+ _, hasStatements := config["statements"]
1098
+ if !hasCalls && !hasStatements {
1099
+ config = map[string]any{
1100
+ "calls": []any{"console.log", "console.debug", "assert.*"},
1101
+ "statements": []any{"debugger"},
1102
+ }
1103
+ }
560
1104
  calls, err := stringArrayConfig(config, "calls")
561
1105
  if err != nil {
562
1106
  return nil, fmt.Errorf("@ttsc/strip: %w", err)
@@ -595,7 +1139,7 @@ func filterStatements(list *shimast.NodeList, strip *stripRewriter) {
595
1139
  if list == nil || len(list.Nodes) == 0 {
596
1140
  return
597
1141
  }
598
- out := list.Nodes[:0]
1142
+ out := make([]*shimast.Node, 0, len(list.Nodes))
599
1143
  for _, stmt := range list.Nodes {
600
1144
  if shouldStripStatement(stmt, strip) {
601
1145
  continue
@@ -610,6 +1154,7 @@ func filterChildStatements(node *shimast.Node, strip *stripRewriter) {
610
1154
  if node == nil {
611
1155
  return
612
1156
  }
1157
+ filterEmbeddedStatements(node, strip)
613
1158
  if node.CanHaveStatements() {
614
1159
  filterStatements(node.StatementList(), strip)
615
1160
  }
@@ -619,6 +1164,53 @@ func filterChildStatements(node *shimast.Node, strip *stripRewriter) {
619
1164
  })
620
1165
  }
621
1166
 
1167
+ func filterEmbeddedStatements(node *shimast.Node, strip *stripRewriter) {
1168
+ switch node.Kind {
1169
+ case shimast.KindIfStatement:
1170
+ stmt := node.AsIfStatement()
1171
+ stmt.ThenStatement = filterEmbeddedStatement(stmt.ThenStatement, strip)
1172
+ stmt.ElseStatement = filterEmbeddedStatement(stmt.ElseStatement, strip)
1173
+ case shimast.KindDoStatement:
1174
+ stmt := node.AsDoStatement()
1175
+ stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip)
1176
+ case shimast.KindWhileStatement:
1177
+ stmt := node.AsWhileStatement()
1178
+ stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip)
1179
+ case shimast.KindForStatement:
1180
+ stmt := node.AsForStatement()
1181
+ stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip)
1182
+ case shimast.KindForInStatement, shimast.KindForOfStatement:
1183
+ stmt := node.AsForInOrOfStatement()
1184
+ stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip)
1185
+ case shimast.KindWithStatement:
1186
+ stmt := node.AsWithStatement()
1187
+ stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip)
1188
+ case shimast.KindLabeledStatement:
1189
+ stmt := node.AsLabeledStatement()
1190
+ stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip)
1191
+ }
1192
+ }
1193
+
1194
+ func filterEmbeddedStatement(stmt *shimast.Statement, strip *stripRewriter) *shimast.Statement {
1195
+ if stmt == nil {
1196
+ return nil
1197
+ }
1198
+ if shouldStripStatement(stmt, strip) {
1199
+ return emptyStatement(stmt)
1200
+ }
1201
+ filterChildStatements(stmt, strip)
1202
+ return stmt
1203
+ }
1204
+
1205
+ func emptyStatement(original *shimast.Node) *shimast.Statement {
1206
+ empty := shimast.NewNodeFactory(shimast.NodeFactoryHooks{}).NewEmptyStatement()
1207
+ empty.Flags |= shimast.NodeFlagsSynthesized
1208
+ if original != nil {
1209
+ empty.Loc = original.Loc
1210
+ }
1211
+ return empty
1212
+ }
1213
+
622
1214
  func shouldStripStatement(node *shimast.Node, strip *stripRewriter) bool {
623
1215
  if node == nil {
624
1216
  return false