@ttsc/unplugin 0.28.3 → 0.28.5

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 (46) hide show
  1. package/README.md +70 -8
  2. package/lib/api.js +3 -0
  3. package/lib/api.js.map +1 -1
  4. package/lib/api.mjs +1 -0
  5. package/lib/api.mjs.map +1 -1
  6. package/lib/core/index.d.cts +17 -6
  7. package/lib/core/index.d.mts +17 -6
  8. package/lib/core/index.d.ts +17 -6
  9. package/lib/core/index.js +117 -21
  10. package/lib/core/index.js.map +1 -1
  11. package/lib/core/index.mjs +115 -21
  12. package/lib/core/index.mjs.map +1 -1
  13. package/lib/core/transform.d.cts +93 -25
  14. package/lib/core/transform.d.mts +93 -25
  15. package/lib/core/transform.d.ts +93 -25
  16. package/lib/core/transform.js +803 -121
  17. package/lib/core/transform.js.map +1 -1
  18. package/lib/core/transform.mjs +804 -122
  19. package/lib/core/transform.mjs.map +1 -1
  20. package/lib/core/tsconfigPaths.d.cts +61 -0
  21. package/lib/core/tsconfigPaths.d.mts +61 -0
  22. package/lib/core/tsconfigPaths.d.ts +61 -0
  23. package/lib/core/tsconfigPaths.js +190 -7
  24. package/lib/core/tsconfigPaths.js.map +1 -1
  25. package/lib/core/tsconfigPaths.mjs +188 -8
  26. package/lib/core/tsconfigPaths.mjs.map +1 -1
  27. package/lib/next.d.cts +27 -10
  28. package/lib/next.d.mts +27 -10
  29. package/lib/next.d.ts +27 -10
  30. package/lib/next.js +229 -8
  31. package/lib/next.js.map +1 -1
  32. package/lib/next.mjs +229 -8
  33. package/lib/next.mjs.map +1 -1
  34. package/lib/turbopack.d.cts +5 -4
  35. package/lib/turbopack.d.mts +5 -4
  36. package/lib/turbopack.d.ts +5 -4
  37. package/lib/turbopack.js +13 -7
  38. package/lib/turbopack.js.map +1 -1
  39. package/lib/turbopack.mjs +14 -8
  40. package/lib/turbopack.mjs.map +1 -1
  41. package/package.json +3 -3
  42. package/src/core/index.ts +122 -21
  43. package/src/core/transform.ts +1073 -132
  44. package/src/core/tsconfigPaths.ts +254 -8
  45. package/src/next.ts +262 -10
  46. package/src/turbopack.ts +13 -9
@@ -46,6 +46,189 @@ export function readEffectiveTsconfigPaths(
46
46
  return output;
47
47
  }
48
48
 
49
+ /** Source extensions TypeScript always admits as program inputs. */
50
+ const TYPESCRIPT_INPUT_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"];
51
+ /** Extensions admitted only when `allowJs` widens the program. */
52
+ const JAVASCRIPT_INPUT_EXTENSIONS = [".js", ".jsx", ".mjs", ".cjs"];
53
+
54
+ /**
55
+ * What the resolved configuration says can and cannot enter the program.
56
+ *
57
+ * The project walk exists to notice files entering and leaving the _program_,
58
+ * so both halves of that question belong to configuration rather than to a
59
+ * guess. Before this the walk answered both from one hardcoded list of
60
+ * directory names, which was wrong in both directions at once: a bundler
61
+ * writing to any directory the list did not name changed project membership
62
+ * with its own output, and a source directory whose name the list did name was
63
+ * dropped from the walk entirely (samchon/ttsc#1307).
64
+ */
65
+ export interface ITtscProjectMembershipPolicy {
66
+ /** Absolute directories the resolved configuration keeps out of the program. */
67
+ excludedDirectories: readonly string[];
68
+ /** Lowercased extensions a file needs to be a possible program input. */
69
+ inputExtensions: readonly string[];
70
+ /**
71
+ * Every config file consulted to produce this policy, the leaf and its whole
72
+ * `extends` ancestry.
73
+ *
74
+ * A caller that memoizes a policy has to know when to stop trusting it, and
75
+ * the leaf alone cannot tell it: adding `exclude` to a shared
76
+ * `tsconfig.base.json` leaves the leaf untouched while changing every answer
77
+ * this policy gives.
78
+ */
79
+ sources: readonly string[];
80
+ }
81
+
82
+ /**
83
+ * The policy every project falls back to when no configuration is available.
84
+ *
85
+ * Deliberately the widest one: admitting a file that cannot enter the program
86
+ * costs a compile, while refusing one that can costs correctness, and only the
87
+ * second is a defect the user cannot see.
88
+ */
89
+ export const PERMISSIVE_PROJECT_MEMBERSHIP_POLICY: ITtscProjectMembershipPolicy =
90
+ {
91
+ excludedDirectories: [],
92
+ inputExtensions: [
93
+ ...TYPESCRIPT_INPUT_EXTENSIONS,
94
+ ...JAVASCRIPT_INPUT_EXTENSIONS,
95
+ ".json",
96
+ ],
97
+ sources: [],
98
+ };
99
+
100
+ /**
101
+ * Read the membership policy the resolved tsconfig implies, following its
102
+ * `extends` chain for every option the answer depends on.
103
+ *
104
+ * `allowJs` and `resolveJsonModule` decide which extensions can enter the
105
+ * program at all, so a `bundle.a1b2c3.js` emitted beside the sources is not a
106
+ * membership change for a project that admits no JavaScript. `outDir`,
107
+ * `declarationDir`, and the plain entries of `exclude` name the directories the
108
+ * program does not contain, which is where a bundler's own output lives in
109
+ * every project that configures one.
110
+ *
111
+ * A glob in `exclude` is skipped rather than approximated. Failing to exclude
112
+ * costs a walk; excluding the wrong tree hides real sources, and this function
113
+ * refuses to guess in the direction that loses correctness.
114
+ */
115
+ export function readProjectMembershipPolicy(
116
+ tsconfig: string,
117
+ ): ITtscProjectMembershipPolicy {
118
+ const resolved = path.resolve(tsconfig);
119
+ // Every config the chain touches, so a caller memoizing this policy can tell
120
+ // when it has gone stale. `findDeclaredValue` walks `extends` for each option
121
+ // independently, and each walk records what it read.
122
+ const sources = new Set<string>();
123
+ const flag = (key: string): boolean =>
124
+ findDeclaredValue(
125
+ resolved,
126
+ (parsed) => {
127
+ const value = (parsed as { compilerOptions?: Record<string, unknown> })
128
+ .compilerOptions?.[key];
129
+ return typeof value === "boolean" ? value : undefined;
130
+ },
131
+ new Set(),
132
+ sources,
133
+ )?.value === true;
134
+
135
+ const inputExtensions = [...TYPESCRIPT_INPUT_EXTENSIONS];
136
+ if (flag("allowJs")) {
137
+ inputExtensions.push(...JAVASCRIPT_INPUT_EXTENSIONS);
138
+ }
139
+ if (flag("resolveJsonModule")) {
140
+ inputExtensions.push(".json");
141
+ }
142
+
143
+ const excludedDirectories: string[] = [];
144
+ for (const key of ["outDir", "declarationDir"]) {
145
+ const declared = findDeclaredValue(
146
+ resolved,
147
+ (parsed) => {
148
+ const value = (parsed as { compilerOptions?: Record<string, unknown> })
149
+ .compilerOptions?.[key];
150
+ return typeof value === "string" && value.length !== 0
151
+ ? value
152
+ : undefined;
153
+ },
154
+ new Set(),
155
+ sources,
156
+ );
157
+ if (declared !== null) {
158
+ excludedDirectories.push(path.resolve(declared.baseDir, declared.value));
159
+ }
160
+ }
161
+ const excluded = findDeclaredValue(
162
+ resolved,
163
+ (parsed) => {
164
+ const value = (parsed as { exclude?: unknown }).exclude;
165
+ return Array.isArray(value) ? value : undefined;
166
+ },
167
+ new Set(),
168
+ sources,
169
+ );
170
+ if (excluded !== null) {
171
+ for (const entry of excluded.value) {
172
+ if (typeof entry !== "string" || entry.length === 0) {
173
+ continue;
174
+ }
175
+ // `dist/**` names exactly one directory; `**/*.spec.ts` names a set this
176
+ // walk cannot evaluate without a matcher, so it is left in.
177
+ const plain = entry.endsWith("/**") ? entry.slice(0, -3) : entry;
178
+ if (plain.length === 0 || /[*?]/.test(plain)) {
179
+ continue;
180
+ }
181
+ excludedDirectories.push(path.resolve(excluded.baseDir, plain));
182
+ }
183
+ }
184
+ return { excludedDirectories, inputExtensions, sources: [...sources] };
185
+ }
186
+
187
+ /**
188
+ * Apply the caller's compiler-options overlay on top of a policy read from the
189
+ * project config.
190
+ *
191
+ * The overlay wins for the compile, so it wins here too. A caller that turns
192
+ * `allowJs` on gets a program that admits JavaScript, and a membership rule
193
+ * that still refused it would miss files entering that program; a caller that
194
+ * turns it off gets the narrower rule for the same reason.
195
+ */
196
+ export function mergeMembershipPolicyOverlay(
197
+ policy: ITtscProjectMembershipPolicy,
198
+ compilerOptions: Record<string, unknown>,
199
+ baseDir: string,
200
+ ): ITtscProjectMembershipPolicy {
201
+ const inputExtensions = new Set(policy.inputExtensions);
202
+ const applyFlag = (key: string, extensions: readonly string[]): void => {
203
+ const value = compilerOptions[key];
204
+ if (typeof value !== "boolean") {
205
+ return;
206
+ }
207
+ for (const extension of extensions) {
208
+ if (value) {
209
+ inputExtensions.add(extension);
210
+ } else {
211
+ inputExtensions.delete(extension);
212
+ }
213
+ }
214
+ };
215
+ applyFlag("allowJs", JAVASCRIPT_INPUT_EXTENSIONS);
216
+ applyFlag("resolveJsonModule", [".json"]);
217
+
218
+ const excludedDirectories = [...policy.excludedDirectories];
219
+ for (const key of ["outDir", "declarationDir"]) {
220
+ const value = compilerOptions[key];
221
+ if (typeof value === "string" && value.length !== 0) {
222
+ excludedDirectories.push(path.resolve(baseDir, value));
223
+ }
224
+ }
225
+ return {
226
+ excludedDirectories,
227
+ inputExtensions: [...inputExtensions],
228
+ sources: policy.sources,
229
+ };
230
+ }
231
+
49
232
  /**
50
233
  * Anchor a single `paths` target at `baseDir` unless it is already absolute,
51
234
  * normalizing to forward slashes. The `*` wildcard survives `path.resolve` as a
@@ -78,13 +261,57 @@ function findDeclaredPaths(
78
261
  tsconfig: string,
79
262
  seen: Set<string>,
80
263
  ): IDeclaredPaths | null {
264
+ const declared = findDeclaredValue(
265
+ tsconfig,
266
+ (parsed) => {
267
+ const own = (parsed as { compilerOptions?: { paths?: unknown } })
268
+ .compilerOptions?.paths;
269
+ return typeof own === "object" && own !== null && !Array.isArray(own)
270
+ ? (own as Record<string, unknown>)
271
+ : undefined;
272
+ },
273
+ seen,
274
+ );
275
+ return declared === null
276
+ ? null
277
+ : { baseDir: declared.baseDir, paths: declared.value };
278
+ }
279
+
280
+ /**
281
+ * Find the nearest declaration of one config value along the `extends` chain,
282
+ * with the directory of the config that declared it.
283
+ *
284
+ * TypeScript merges configs per key, so the effective value of a key is the
285
+ * whole value from the nearest config that declares one: the config itself
286
+ * first, then its `extends` entries in reverse priority order. The declaring
287
+ * directory travels with the value because a path-valued option (`outDir`,
288
+ * `exclude`) is anchored at the config that wrote it, not at the one that
289
+ * inherited it.
290
+ *
291
+ * Best-effort by design, like {@link readEffectiveTsconfigPaths}: a missing or
292
+ * unparsable config in the chain yields `null` here and a real config error
293
+ * from the compiler, which owns config diagnostics.
294
+ */
295
+ function findDeclaredValue<T>(
296
+ tsconfig: string,
297
+ select: (parsed: object) => T | undefined,
298
+ seen: Set<string>,
299
+ /**
300
+ * Every config this walk reads, accumulated across walks. Kept apart from
301
+ * `seen`, which guards one walk against an `extends` cycle and must start
302
+ * empty each time: sharing one set would make the second option's walk treat
303
+ * the leaf as already visited and answer `null` for everything.
304
+ */
305
+ collect?: Set<string>,
306
+ ): { baseDir: string; value: T } | null {
81
307
  const canonical = resolveRealPath(tsconfig);
82
308
  if (seen.has(canonical)) {
83
309
  return null;
84
310
  }
85
311
  seen.add(canonical);
312
+ collect?.add(canonical);
86
313
 
87
- let parsed: { extends?: unknown; compilerOptions?: { paths?: unknown } };
314
+ let parsed: { extends?: unknown };
88
315
  try {
89
316
  parsed = parseJsonc(fs.readFileSync(canonical, "utf8")) as typeof parsed;
90
317
  } catch {
@@ -94,20 +321,39 @@ function findDeclaredPaths(
94
321
  return null;
95
322
  }
96
323
 
97
- const own = parsed.compilerOptions?.paths;
98
- if (typeof own === "object" && own !== null && !Array.isArray(own)) {
99
- return {
100
- baseDir: path.dirname(canonical),
101
- paths: own as Record<string, unknown>,
102
- };
324
+ const own = select(parsed);
325
+ if (own !== undefined) {
326
+ return { baseDir: path.dirname(canonical), value: own };
103
327
  }
104
328
 
105
329
  for (const specifier of extendsSpecifiers(parsed.extends).reverse()) {
106
330
  const base = resolveExtendsConfig(canonical, specifier);
107
331
  if (base === null) {
332
+ // Record where a relative or absolute specifier *would* have resolved,
333
+ // even though nothing is there. A caller stamping this policy has to
334
+ // notice the config appearing later, and a base config can be absent for
335
+ // ordinary reasons: generated during install, or missing across a branch
336
+ // switch. Without this the stamp never moves and a long-lived worker
337
+ // keeps a policy the next run's walk already disagrees with. A bare
338
+ // specifier is skipped, since it has no single candidate path.
339
+ if (isRelativeSpecifier(specifier) || path.isAbsolute(specifier)) {
340
+ // Both spellings the resolver would have tried, since it falls back to
341
+ // `<specifier>.json`. Recording only the literal one leaves the stamp
342
+ // unmoved when `./tsconfig.base` later appears as `tsconfig.base.json`,
343
+ // which is the same staleness this recording exists to prevent.
344
+ const candidate = path.resolve(path.dirname(canonical), specifier);
345
+ collect?.add(candidate);
346
+ // Case-sensitive, because `resolveExistingExtendsPath` is: it appends
347
+ // `.json` unless the spelling already ends in exactly that, so a
348
+ // `./base.JSON` specifier really does resolve to `base.JSON.json` on a
349
+ // case-sensitive filesystem, and the stamp has to know that name.
350
+ if (!candidate.endsWith(".json")) {
351
+ collect?.add(`${candidate}.json`);
352
+ }
353
+ }
108
354
  continue;
109
355
  }
110
- const declared = findDeclaredPaths(base, seen);
356
+ const declared = findDeclaredValue(base, select, seen, collect);
111
357
  if (declared !== null) {
112
358
  return declared;
113
359
  }
package/src/next.ts CHANGED
@@ -1,11 +1,22 @@
1
1
  import type { TtscUnpluginOptions } from "./core/options";
2
2
  import webpack from "./webpack";
3
3
 
4
+ /** The standalone loader entry Turbopack accepts through `turbopack.rules`. */
5
+ const TURBOPACK_LOADER = "@ttsc/unplugin/turbopack";
6
+ /**
7
+ * The globs the loader is wired for.
8
+ *
9
+ * The same two the manual wiring in the README uses. A wider glob would be
10
+ * harmless, since `isTransformTarget` declines everything else that reaches the
11
+ * loader, but it would also route files through a worker for nothing.
12
+ */
13
+ const TURBOPACK_RULE_GLOBS = ["*.ts", "*.tsx"];
14
+
4
15
  /**
5
16
  * Minimal structural type for a Next.js configuration object.
6
17
  *
7
- * Only the `webpack` field is used by this adapter; all other Next.js options
8
- * are forwarded as-is through the spread operator.
18
+ * Only `webpack` and `turbopack` are used by this adapter; all other Next.js
19
+ * options are forwarded as-is through the spread operator.
9
20
  */
10
21
  export type NextLikeConfig = Record<string, unknown> & {
11
22
  /**
@@ -14,6 +25,11 @@ export type NextLikeConfig = Record<string, unknown> & {
14
25
  * webpack plugin.
15
26
  */
16
27
  webpack?: (config: WebpackLikeConfig, options: unknown) => WebpackLikeConfig;
28
+ /**
29
+ * Optional existing Turbopack configuration. Preserved whole; only the ttsc
30
+ * rules are merged into its `rules` map.
31
+ */
32
+ turbopack?: TurbopackLikeConfig;
17
33
  };
18
34
 
19
35
  /**
@@ -25,25 +41,40 @@ export type WebpackLikeConfig = Record<string, unknown> & {
25
41
  plugins?: unknown[];
26
42
  };
27
43
 
44
+ /** Minimal structural type for Next.js's `turbopack` configuration block. */
45
+ export type TurbopackLikeConfig = Record<string, unknown> & {
46
+ /** Per-glob loader rules. Other Turbopack settings are preserved untouched. */
47
+ rules?: Record<string, unknown>;
48
+ };
49
+
28
50
  /**
29
- * Wrap a Next.js config object so that the ttsc webpack plugin is injected into
30
- * every webpack build Next.js performs.
51
+ * Wrap a Next.js config object so that ttsc runs under whichever bundler
52
+ * Next.js uses.
53
+ *
54
+ * The webpack plugin is injected through the `webpack` hook, and the Turbopack
55
+ * loader is wired through `turbopack.rules`, with the same options reaching
56
+ * both. Covering only webpack meant that a project on Turbopack, which is the
57
+ * default bundler in current Next majors, silently got no transform at all: the
58
+ * build succeeded and every plugin-driven construct in it, a typia
59
+ * `assert<T>()` above all, survived untransformed into a runtime failure
60
+ * (samchon/ttsc#1310).
31
61
  *
32
- * The adapter uses `unshift` to ensure the ttsc plugin runs before any other
33
- * plugins in the array (unplugin `enforce: "pre"` semantics). An existing
34
- * `nextConfig.webpack` hook is preserved and called after the plugin is
35
- * injected.
62
+ * Both halves are additive. An existing `webpack` hook is preserved and called
63
+ * after the plugin is injected, and an existing `turbopack` block keeps every
64
+ * setting and every rule it already had.
36
65
  *
37
66
  * @param nextConfig - The caller's existing Next.js config (spread into the
38
- * returned object unchanged, except for `webpack`).
39
- * @param options - Ttsc plugin options forwarded to the webpack adapter.
67
+ * returned object unchanged, except for `webpack` and `turbopack`).
68
+ * @param options - Ttsc plugin options forwarded to both bundlers.
40
69
  */
41
70
  export default function next(
42
71
  nextConfig: NextLikeConfig = {},
43
72
  options?: TtscUnpluginOptions,
44
73
  ): NextLikeConfig {
74
+ warnAboutSuppressedWebpackConfig(nextConfig);
45
75
  return {
46
76
  ...nextConfig,
77
+ turbopack: withTtscTurbopackRules(nextConfig.turbopack, options),
47
78
  webpack(config: WebpackLikeConfig, webpackOptions: unknown) {
48
79
  config.plugins = Array.isArray(config.plugins) ? config.plugins : [];
49
80
  // Prepend so ttsc runs before any user-added plugins.
@@ -55,3 +86,224 @@ export default function next(
55
86
  },
56
87
  };
57
88
  }
89
+
90
+ /**
91
+ * Say what Next.js can no longer say once this wrapper defines `turbopack`.
92
+ *
93
+ * Next stops the build when a config carries a `webpack` hook and no
94
+ * `turbopack` block, because the webpack hook is then silently ignored. Read
95
+ * from Next 16.3.2's own `dist/lib/turbopack-warning.js`, it is
96
+ * `log.error(...)` followed by `process.exit(1)` — a refusal, not a warning —
97
+ * and it is gated on `process.env.TURBOPACK === "auto"`, which
98
+ * `dist/lib/bundler.js` sets only when no bundler flag was passed. Next's
99
+ * comment there gives the reason: an explicit `--turbopack` means the user
100
+ * chose it and is assumed to have configured it. So the check fires on a plain
101
+ * `next build` or `next dev`, which is how Next 16 is normally run.
102
+ *
103
+ * `hasTurboConfig` is read from the raw exported config, which is this
104
+ * wrapper's return value, and this wrapper always defines `turbopack`. The
105
+ * check therefore can never fire again for anyone who uses `withTtsc`, and a
106
+ * caller's own webpack customisation would be dropped on a Turbopack build with
107
+ * nothing said. Wiring ttsc for Turbopack is worth exactly one line, not the
108
+ * loss of the refusal Next already gave.
109
+ *
110
+ * Verifying that gate is what this docstring exists for: measured with an
111
+ * explicit `--turbopack`, the check is silent, and a reading that stopped there
112
+ * would conclude Next says nothing and delete a true statement.
113
+ *
114
+ * Only for a caller who wrote a `webpack` hook and no `turbopack` block. A
115
+ * caller who configured Turbopack has already made that decision, and a caller
116
+ * with neither has no webpack-only configuration to lose.
117
+ */
118
+ function warnAboutSuppressedWebpackConfig(nextConfig: NextLikeConfig): void {
119
+ if (
120
+ typeof nextConfig.webpack !== "function" ||
121
+ nextConfig.turbopack !== undefined
122
+ ) {
123
+ return;
124
+ }
125
+ process.stderr.write(
126
+ "@ttsc/unplugin: withTtsc now configures Turbopack as well as webpack, so " +
127
+ "Next.js will no longer stop the build to tell you that your own " +
128
+ "`webpack` hook is ignored on a Turbopack build. Port it to `turbopack`, " +
129
+ "or run the bundler you configured with `next build --webpack` / " +
130
+ "`next dev --webpack`." +
131
+ String.fromCharCode(10),
132
+ );
133
+ }
134
+
135
+ /**
136
+ * Merge the ttsc loader rules into a caller's Turbopack configuration.
137
+ *
138
+ * Additive in every direction: unrelated Turbopack settings and unrelated rules
139
+ * are carried through untouched, and a glob the caller already configured keeps
140
+ * its own loaders with ttsc placed where the chain runs it first. A caller who
141
+ * already wired this loader by hand is left exactly as they are, so following
142
+ * the README's manual instructions and then adopting the wrapper cannot
143
+ * register it twice.
144
+ */
145
+ function withTtscTurbopackRules(
146
+ existing: TurbopackLikeConfig | undefined,
147
+ options?: TtscUnpluginOptions,
148
+ ): TurbopackLikeConfig {
149
+ const rules: Record<string, unknown> = { ...(existing?.rules ?? {}) };
150
+ for (const glob of TURBOPACK_RULE_GLOBS) {
151
+ const rule = rules[glob];
152
+ const loaders = selectTurbopackLoaders(rule);
153
+ if (loaders.some(referencesTtscLoader)) {
154
+ continue;
155
+ }
156
+ // The caller may have written the same file set under a different glob.
157
+ // Adding ours beside theirs makes every matching module run the loader
158
+ // twice, and the second pass receives the first pass's output, so the
159
+ // guard has to cover the spellings a caller plausibly uses rather than
160
+ // only the two this wrapper writes (samchon/ttsc#1314).
161
+ if (coveredByAnotherRule(rules, glob)) {
162
+ continue;
163
+ }
164
+ const entry = { loader: TURBOPACK_LOADER, options: options ?? {} };
165
+ // Appended, not prepended. Turbopack runs a rule's loaders right to left,
166
+ // so the last entry is the one that sees the original source. ttsc
167
+ // transforms TypeScript into TypeScript, so it has to be that one, which
168
+ // is the same position `enforce: "pre"` gives it on the webpack half.
169
+ //
170
+ // Measured rather than inferred from webpack's `loader-runner`: two
171
+ // loaders on one rule, each marking the source, came back marked in the
172
+ // order that only the last-runs-first chain produces (samchon/ttsc#1319).
173
+ rules[glob] =
174
+ rule === undefined || loaders.length === 0
175
+ ? { loaders: [entry] }
176
+ : Array.isArray(rule)
177
+ ? { loaders: [...loaders, entry] }
178
+ : { ...(rule as object), loaders: [...loaders, entry] };
179
+ }
180
+ return { ...(existing ?? {}), rules };
181
+ }
182
+
183
+ /**
184
+ * Whether some other rule already routes this glob's files through the loader.
185
+ *
186
+ * Deciding glob equivalence in general means implementing Turbopack's matcher,
187
+ * which is not worth it here. What is recognised instead is the spellings a
188
+ * caller plausibly writes for "every file with this extension", since only a
189
+ * glob that means every file can make the wrapper's own rules redundant. That
190
+ * is narrower than every glob with those semantics, and narrow on purpose:
191
+ * anything unrecognised is left alone and the wrapper still adds its rules,
192
+ * while skipping on a scoped glob would leave every module outside that scope
193
+ * untransformed, which is samchon/ttsc#1310 again and the quieter of the two
194
+ * failures. {@link matchesExtension} owns the rule and names what it declines.
195
+ */
196
+ function coveredByAnotherRule(
197
+ rules: Record<string, unknown>,
198
+ glob: string,
199
+ ): boolean {
200
+ const extension = glob.slice(glob.lastIndexOf(".") + 1);
201
+ return Object.entries(rules).some(([candidate, rule]) => {
202
+ if (candidate === glob) {
203
+ return false;
204
+ }
205
+ if (!selectTurbopackLoaders(rule).some(referencesTtscLoader)) {
206
+ return false;
207
+ }
208
+ return matchesExtension(candidate, extension);
209
+ });
210
+ }
211
+
212
+ /**
213
+ * Whether one glob names this extension across the whole project.
214
+ *
215
+ * Unscoped only. A rule carrying a path segment says nothing about the rest of
216
+ * the project, so treating it as covering everything would leave every module
217
+ * outside it with no ttsc rule at all. That is the silent failure
218
+ * samchon/ttsc#1310 is about, and it is strictly worse than the double
219
+ * registration this guard exists to prevent: a module no loader ever sees fails
220
+ * at runtime, while a module transformed twice costs time.
221
+ *
222
+ * How little a scoped rule covers is worth stating from measurement rather than
223
+ * from the obvious guess, because the guess is wrong. Against Next.js 16.3.2,
224
+ * `src/*.ts` and `src/**` + `/*.ts` match **nothing at all** — not even the
225
+ * `src/` subtree they name — while `./src/*.ts`, `**` + `/src/*.ts` and a bare
226
+ * `nested-probe.ts` all match a file at `src/`. Declining every one of them is
227
+ * therefore even safer than "it only covers its subtree" implies
228
+ * (samchon/ttsc#1319).
229
+ *
230
+ * The answer comes from {@link PROJECT_WIDE_GLOBS}, an exact set of measured
231
+ * spellings, and that document explains why it is a set rather than a rule.
232
+ */
233
+ function matchesExtension(glob: string, extension: string): boolean {
234
+ return PROJECT_WIDE_GLOBS.get(glob.trim())?.includes(extension) === true;
235
+ }
236
+
237
+ /**
238
+ * The exact glob spellings a real Turbopack build has shown to name every file
239
+ * with an extension, and which extensions each one covers.
240
+ *
241
+ * An allowlist rather than a predicate, because recognising a glob is a claim
242
+ * about Turbopack's matcher and every claim here has to be one somebody
243
+ * measured. A rule inferred from glob semantics kept being wrong in the silent
244
+ * direction: `{src/,}*.ts` contains a bare `*.ts` alternative and so must cover
245
+ * the project by any set-theoretic reading, yet Turbopack matches nothing with
246
+ * it, and recognising it suppressed this wrapper's rules in favour of a rule
247
+ * that transforms no file at all — samchon/ttsc#1310 caused by the guard meant
248
+ * to prevent it (samchon/ttsc#1319).
249
+ *
250
+ * The deeper problem was that a predicate is open-ended: it answers for every
251
+ * spelling anyone might write, including ones no build has ever driven.
252
+ * `*.{ts}` and `{,**` + `/}*.ts` were both accepted on that reasoning while
253
+ * nothing had checked whether Turbopack expands a single-alternative group or a
254
+ * leading empty one. An exact set cannot overreach, so an unmeasured spelling
255
+ * is simply not recognised, the wrapper adds its own rules, and the failure —
256
+ * if any — is a second registration rather than a module that no loader ever
257
+ * sees.
258
+ *
259
+ * `experimental/test-unplugin` drives every entry through `next build
260
+ * --turbopack` and asserts a nested `.ts`, a root-level `.ts` and a nested
261
+ * `.tsx` all came out transformed, and
262
+ * `test_next_adapter_does_not_double_register_across_globs` asserts these keys
263
+ * and that list are the same set, so an entry cannot be added here without a
264
+ * build proving it.
265
+ */
266
+ const PROJECT_WIDE_GLOBS: ReadonlyMap<string, readonly string[]> = new Map([
267
+ ["*.ts", ["ts"]],
268
+ ["**/*.ts", ["ts"]],
269
+ ["{**/,}*.ts", ["ts"]],
270
+ ["*.tsx", ["tsx"]],
271
+ ["**/*.tsx", ["tsx"]],
272
+ ["*.{ts,tsx}", ["ts", "tsx"]],
273
+ ["{*.ts,*.tsx}", ["ts", "tsx"]],
274
+ ["**/*.{ts,tsx}", ["ts", "tsx"]],
275
+ ["**/{*.ts,*.tsx}", ["ts", "tsx"]],
276
+ ["**/**/*.{ts,tsx}", ["ts", "tsx"]],
277
+ ]);
278
+
279
+ /**
280
+ * Read the loader list out of one Turbopack rule.
281
+ *
282
+ * Turbopack accepts a bare array of loaders as well as the object form, and a
283
+ * loader is either a module name or a `{ loader, options }` pair, so this
284
+ * normalises only enough to answer "what is already here".
285
+ */
286
+ function selectTurbopackLoaders(rule: unknown): unknown[] {
287
+ if (Array.isArray(rule)) {
288
+ return rule;
289
+ }
290
+ if (typeof rule === "object" && rule !== null) {
291
+ const loaders = (rule as { loaders?: unknown }).loaders;
292
+ if (Array.isArray(loaders)) {
293
+ return loaders;
294
+ }
295
+ }
296
+ return [];
297
+ }
298
+
299
+ /** Whether one Turbopack loader entry is already this package's loader. */
300
+ function referencesTtscLoader(loader: unknown): boolean {
301
+ if (typeof loader === "string") {
302
+ return loader === TURBOPACK_LOADER;
303
+ }
304
+ return (
305
+ typeof loader === "object" &&
306
+ loader !== null &&
307
+ (loader as { loader?: unknown }).loader === TURBOPACK_LOADER
308
+ );
309
+ }
package/src/turbopack.ts CHANGED
@@ -1,9 +1,9 @@
1
+ import { isTransformTarget } from "./core/index";
1
2
  import type { TtscUnpluginOptions } from "./core/options";
2
3
  import { resolveOptions } from "./core/options";
3
4
  import type { TtscTransformHooks } from "./core/transform";
4
5
  import {
5
6
  createTtscTransformCache,
6
- isDeclarationFile,
7
7
  stripQuery,
8
8
  transformTtsc,
9
9
  } from "./core/transform";
@@ -39,9 +39,6 @@ export interface TtscTurbopackLoaderContext {
39
39
  cacheable?(flag: boolean): void;
40
40
  }
41
41
 
42
- /** Matches any path segment that is a `node_modules` directory (cross-platform). */
43
- const nodeModulesPattern = /(?:^|[/\\])node_modules(?:[/\\]|$)/;
44
-
45
42
  /**
46
43
  * Per-process transform cache. Turbopack runs loaders in a worker pool and
47
44
  * never signals build boundaries to a loader, so the cache lives for the
@@ -72,10 +69,11 @@ const transformCache = createTtscTransformCache();
72
69
  * ```
73
70
  *
74
71
  * Pass {@link TtscUnpluginOptions} through the rule's `options` object. The
75
- * loader returns the source unchanged for declaration files, `node_modules`
76
- * paths, and transforms that produce no change, mirroring the unplugin
77
- * adapters' `transformInclude` filter, since a broad rule glob routes
78
- * everything matching the extension through the loader.
72
+ * loader returns the source unchanged for anything {@link isTransformTarget}
73
+ * excludes declaration files, `node_modules` paths, non-TypeScript sources,
74
+ * and virtual ids and for transforms that produce no change. It applies that
75
+ * shared predicate itself rather than a local copy, because a broad rule glob
76
+ * routes everything matching the extension through the loader.
79
77
  */
80
78
  export default function turbopack(
81
79
  this: TtscTurbopackLoaderContext,
@@ -83,7 +81,13 @@ export default function turbopack(
83
81
  ): void {
84
82
  const callback = this.async();
85
83
  const file = stripQuery(this.resourcePath);
86
- if (isDeclarationFile(file) || nodeModulesPattern.test(file)) {
84
+ // The shared predicate itself, not a copy of part of it. A rule glob wider
85
+ // than `*.ts`/`*.tsx` — the natural thing to write for a project with mixed
86
+ // sources, and the reason a loader needs a filter at all — used to route
87
+ // JavaScript and virtual ids into the whole-project transform that every
88
+ // other adapter excludes, where the program has no entry for them and the
89
+ // delivery fails (samchon/ttsc#1305).
90
+ if (!isTransformTarget(file)) {
87
91
  callback(undefined, source);
88
92
  return;
89
93
  }