@ttsc/unplugin 0.28.5 → 0.29.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 (86) hide show
  1. package/README.md +15 -11
  2. package/lib/api.js +10 -0
  3. package/lib/api.js.map +1 -1
  4. package/lib/api.mjs +3 -2
  5. package/lib/api.mjs.map +1 -1
  6. package/lib/bun-register.d.cts +10 -7
  7. package/lib/bun-register.d.mts +10 -7
  8. package/lib/bun-register.d.ts +10 -7
  9. package/lib/bun-register.js +90 -24
  10. package/lib/bun-register.js.map +1 -1
  11. package/lib/bun-register.mjs +90 -24
  12. package/lib/bun-register.mjs.map +1 -1
  13. package/lib/bun.d.cts +10 -8
  14. package/lib/bun.d.mts +10 -8
  15. package/lib/bun.d.ts +10 -8
  16. package/lib/bun.js +19 -17
  17. package/lib/bun.js.map +1 -1
  18. package/lib/bun.mjs +20 -18
  19. package/lib/bun.mjs.map +1 -1
  20. package/lib/core/index.d.cts +10 -8
  21. package/lib/core/index.d.mts +10 -8
  22. package/lib/core/index.d.ts +10 -8
  23. package/lib/core/index.js +88 -16
  24. package/lib/core/index.js.map +1 -1
  25. package/lib/core/index.mjs +81 -18
  26. package/lib/core/index.mjs.map +1 -1
  27. package/lib/core/projectDiscovery.d.cts +55 -0
  28. package/lib/core/projectDiscovery.d.mts +55 -0
  29. package/lib/core/projectDiscovery.d.ts +55 -0
  30. package/lib/core/projectDiscovery.js +187 -0
  31. package/lib/core/projectDiscovery.js.map +1 -0
  32. package/lib/core/projectDiscovery.mjs +182 -0
  33. package/lib/core/projectDiscovery.mjs.map +1 -0
  34. package/lib/core/sourceExtensions.d.cts +10 -0
  35. package/lib/core/sourceExtensions.d.mts +10 -0
  36. package/lib/core/sourceExtensions.d.ts +10 -0
  37. package/lib/core/sourceExtensions.js +38 -0
  38. package/lib/core/sourceExtensions.js.map +1 -0
  39. package/lib/core/sourceExtensions.mjs +32 -0
  40. package/lib/core/sourceExtensions.mjs.map +1 -0
  41. package/lib/core/transform.d.cts +96 -16
  42. package/lib/core/transform.d.mts +96 -16
  43. package/lib/core/transform.d.ts +96 -16
  44. package/lib/core/transform.js +1295 -309
  45. package/lib/core/transform.js.map +1 -1
  46. package/lib/core/transform.mjs +1290 -310
  47. package/lib/core/transform.mjs.map +1 -1
  48. package/lib/core/tsconfigPaths.d.cts +55 -3
  49. package/lib/core/tsconfigPaths.d.mts +55 -3
  50. package/lib/core/tsconfigPaths.d.ts +55 -3
  51. package/lib/core/tsconfigPaths.js +260 -46
  52. package/lib/core/tsconfigPaths.js.map +1 -1
  53. package/lib/core/tsconfigPaths.mjs +255 -47
  54. package/lib/core/tsconfigPaths.mjs.map +1 -1
  55. package/lib/core/viteServe.d.cts +18 -21
  56. package/lib/core/viteServe.d.mts +18 -21
  57. package/lib/core/viteServe.d.ts +18 -21
  58. package/lib/core/viteServe.js +88 -47
  59. package/lib/core/viteServe.js.map +1 -1
  60. package/lib/core/viteServe.mjs +89 -49
  61. package/lib/core/viteServe.mjs.map +1 -1
  62. package/lib/next.d.cts +7 -0
  63. package/lib/next.d.mts +7 -0
  64. package/lib/next.d.ts +7 -0
  65. package/lib/next.js +186 -54
  66. package/lib/next.js.map +1 -1
  67. package/lib/next.mjs +186 -55
  68. package/lib/next.mjs.map +1 -1
  69. package/lib/turbopack.d.cts +6 -4
  70. package/lib/turbopack.d.mts +6 -4
  71. package/lib/turbopack.d.ts +6 -4
  72. package/lib/turbopack.js +11 -10
  73. package/lib/turbopack.js.map +1 -1
  74. package/lib/turbopack.mjs +11 -10
  75. package/lib/turbopack.mjs.map +1 -1
  76. package/package.json +7 -4
  77. package/src/bun-register.ts +112 -25
  78. package/src/bun.ts +60 -50
  79. package/src/core/index.ts +114 -19
  80. package/src/core/projectDiscovery.ts +256 -0
  81. package/src/core/sourceExtensions.ts +47 -0
  82. package/src/core/transform.ts +1671 -361
  83. package/src/core/tsconfigPaths.ts +406 -46
  84. package/src/core/viteServe.ts +125 -74
  85. package/src/next.ts +230 -54
  86. package/src/turbopack.ts +11 -10
@@ -1,18 +1,19 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import type { ITtscCompilerTransformation } from "ttsc";
3
4
 
4
- import { pathIdentityKey } from "./transform";
5
+ import { pathIdentityKey, validateGraphInputObservation } from "./transform";
5
6
 
6
7
  /**
7
- * How often each registered missing watch input is stat-polled, in
8
+ * How often each registered Vite-unsafe input is predicate-polled, in
8
9
  * milliseconds.
9
10
  *
10
11
  * Polling is the only watch primitive that covers the whole class: the dev
11
12
  * server's chokidar watcher ignores every `node_modules` directory, which is
12
13
  * exactly where superseding resolution candidates usually live, and `fs.watch`
13
14
  * cannot observe a path whose parent directories do not exist yet. One `stat`
14
- * every half second per missing path is negligible against a dev server's
15
- * baseline.
15
+ * every half second per unavailable path is negligible against a dev server's
16
+ * baseline. Rich inputs replay only the predicates the compiler recorded.
16
17
  */
17
18
  const MISSING_INPUT_POLL_INTERVAL = 500;
18
19
 
@@ -55,24 +56,23 @@ export interface ViteDevServerLike {
55
56
  }
56
57
 
57
58
  /**
58
- * Filesystem watch for derived watch inputs that do not exist while a Vite dev
59
- * server is running.
59
+ * Filesystem watch for derived watch inputs that Vite cannot safely register as
60
+ * added imports while a development server is running.
60
61
  *
61
62
  * Vite serve treats every transform-context `addWatchFile()` registration as an
62
63
  * added import: `TransformPluginContext.addWatchFile` stores the path in
63
64
  * `_addedImports`, and `vite:import-analysis` resolves each entry like a real
64
- * import of the transformed module. A missing path a superseding resolution
65
- * candidate or a plugin-reported dependency that is not generated yet then
66
- * fails that resolve and turns the importer's first request into a 500, even
67
- * though the transform itself succeeded.
65
+ * import of the transformed module. A missing path or an existing directory
66
+ * that failed a compiler file predicate then fails that resolve and turns the
67
+ * importer's first request into a 500, even though the transform succeeded.
68
68
  *
69
69
  * This registry is the serve-only replacement for those registrations. Each
70
- * missing path is stat-polled; when it is created, every importer that
71
- * registered it is invalidated in the server's module graphs and one
72
- * full-reload is sent, so the next request retransforms the importer against
73
- * the new resolution winner. The project transform cache re-validates through
74
- * its external-input hashes (a recorded `missing` marker differs from a content
75
- * hash), so the retransform recompiles instead of replaying.
70
+ * path is polled until its exact compiler predicate observation changes; legacy
71
+ * envelopes retain their exists-or-file availability check. Its importers are
72
+ * then invalidated in the server's module graphs and one full-reload is sent,
73
+ * so the next request retransforms against the new resolution winner. The
74
+ * project transform cache re-validates the compiler observation, so the
75
+ * retransform recompiles instead of replaying.
76
76
  */
77
77
  export interface ViteServeMissingInputWatch {
78
78
  /** Adopt the dev server whose module graphs creation events invalidate. */
@@ -86,32 +86,57 @@ export interface ViteServeMissingInputWatch {
86
86
  * config's `command`, as the adapter does.
87
87
  */
88
88
  serving(): boolean;
89
- /**
90
- * Register one missing watch input derived for `importer`.
91
- *
92
- * `identity` is the filesystem identity the transform generation already
93
- * resolved for `input`. Resolving it here instead costs a `realpath` and a
94
- * case-sensitivity directory listing per input per delivery, because a
95
- * per-call identity context memoizes nothing (samchon/ttsc#1246).
96
- */
97
- watch(input: string, importer: string, identity?: string): void;
89
+ /** Register one unsafe watch input and its exact recorded condition. */
90
+ watch(
91
+ input: string,
92
+ importer: string,
93
+ condition: ViteServeInputWatchCondition,
94
+ ): void;
98
95
  }
99
96
 
100
- /** Poll bookkeeping for one registered missing path. */
97
+ /** A legacy availability condition or an exact compiler predicate proof. */
98
+ export type ViteServeInputWatchCondition =
99
+ | "exists"
100
+ | "file"
101
+ | ITtscCompilerTransformation.IInputObservation;
102
+
103
+ /** Poll bookkeeping for one registered unsafe path. */
101
104
  interface IMissingInputEntry {
105
+ condition: ViteServeInputWatchCondition;
102
106
  importers: Set<string>;
103
- listener: (current: fs.Stats) => void;
104
107
  spelling: string;
105
108
  }
106
109
 
107
110
  /** Create an empty missing-input watch for one plugin instance. */
108
111
  export function createViteServeMissingInputWatch(): ViteServeMissingInputWatch {
109
112
  const entries = new Map<string, IMissingInputEntry>();
113
+ let poller: NodeJS.Timeout | undefined;
110
114
  let server: ViteDevServerLike | undefined;
111
115
 
112
- const unwatch = (identity: string, entry: IMissingInputEntry): void => {
113
- fs.unwatchFile(entry.spelling, entry.listener);
114
- entries.delete(identity);
116
+ const stopPollingIfEmpty = (): void => {
117
+ if (entries.size !== 0 || poller === undefined) {
118
+ return;
119
+ }
120
+ clearInterval(poller);
121
+ poller = undefined;
122
+ };
123
+ const poll = (): void => {
124
+ const importers = new Set<string>();
125
+ for (const [identity, entry] of entries) {
126
+ if (!viteServeInputWatchConditionChanged(entry)) {
127
+ continue;
128
+ }
129
+ entries.delete(identity);
130
+ for (const importer of entry.importers) {
131
+ importers.add(importer);
132
+ }
133
+ }
134
+ stopPollingIfEmpty();
135
+ if (server === undefined || importers.size === 0) {
136
+ return;
137
+ }
138
+ invalidateImporters(server, importers);
139
+ sendFullReload(server);
115
140
  };
116
141
 
117
142
  return {
@@ -119,8 +144,10 @@ export function createViteServeMissingInputWatch(): ViteServeMissingInputWatch {
119
144
  server = next;
120
145
  },
121
146
  dispose() {
122
- for (const [identity, entry] of entries) {
123
- unwatch(identity, entry);
147
+ entries.clear();
148
+ if (poller !== undefined) {
149
+ clearInterval(poller);
150
+ poller = undefined;
124
151
  }
125
152
  // The server reference deliberately survives: `vite.restartServer`
126
153
  // configures the replacement server (attach) before it closes the old
@@ -132,60 +159,84 @@ export function createViteServeMissingInputWatch(): ViteServeMissingInputWatch {
132
159
  serving() {
133
160
  return server !== undefined;
134
161
  },
135
- watch(input, importer, resolved) {
162
+ watch(input, importer, condition) {
136
163
  const spelling = path.resolve(input);
137
- const identity = resolved ?? pathIdentityKey(spelling);
164
+ const identity = viteServeMissingInputWatchKey(spelling, condition);
138
165
  const existing = entries.get(identity);
139
166
  if (existing !== undefined) {
140
167
  existing.importers.add(path.resolve(importer));
141
168
  return;
142
169
  }
143
- const entry: IMissingInputEntry = {
170
+ entries.set(identity, {
171
+ condition,
144
172
  importers: new Set([path.resolve(importer)]),
145
- listener: (current) => {
146
- // `fs.watchFile` reports a missing path as zeroed stats (and fires
147
- // once with them right after registration); only a poll that
148
- // observes a real file is a creation event.
149
- if (current.mtimeMs === 0 && !fs.existsSync(entry.spelling)) {
150
- return;
151
- }
152
- unwatch(identity, entry);
153
- if (server === undefined) {
154
- return;
155
- }
156
- invalidateImporters(server, entry.importers);
157
- sendFullReload(server);
158
- },
159
- spelling,
160
- };
161
- entries.set(identity, entry);
162
- const watcher = fs.watchFile(
163
173
  spelling,
164
- { interval: MISSING_INPUT_POLL_INTERVAL },
165
- entry.listener,
166
- );
167
- // A poller must never keep the dev-server process alive on its own.
168
- watcher.unref?.();
169
- // `fs.watchFile` snapshots the path's stats at registration and fires
170
- // only on a subsequent change, so a file created between the adapter's
171
- // existence check and this registration would count as "unchanged" and
172
- // never fire. One deferred recheck closes that window; routing through
173
- // the listener keeps a single finalization path.
174
- const recheck = setTimeout(() => {
175
- if (entries.get(identity) !== entry) {
176
- return;
177
- }
178
- try {
179
- entry.listener(fs.statSync(entry.spelling));
180
- } catch {
181
- // Still missing (or deleted again): the ordinary poll stays armed.
182
- }
183
- }, MISSING_INPUT_POLL_INTERVAL);
184
- recheck.unref?.();
174
+ });
175
+ if (poller === undefined) {
176
+ poller = setInterval(poll, MISSING_INPUT_POLL_INTERVAL);
177
+ // A poller must never keep the dev-server process alive on its own.
178
+ poller.unref?.();
179
+ }
185
180
  },
186
181
  };
187
182
  }
188
183
 
184
+ /** Key a private poll by predicate and exact lexical spelling. */
185
+ export function viteServeMissingInputWatchKey(
186
+ input: string,
187
+ condition: ViteServeInputWatchCondition,
188
+ ): string {
189
+ // Missing aliases can share a physical parent now and later retarget or
190
+ // diverge. Neither predicate may let one lexical spelling answer for another.
191
+ const predicate =
192
+ typeof condition === "string"
193
+ ? condition
194
+ : `predicates:${JSON.stringify([
195
+ condition.accessibleEntries === undefined
196
+ ? null
197
+ : [
198
+ condition.accessibleEntries.directories,
199
+ condition.accessibleEntries.files,
200
+ ],
201
+ condition.directoryExists ?? null,
202
+ condition.fileExists ?? null,
203
+ condition.readFile === undefined
204
+ ? null
205
+ : condition.readFile.ok
206
+ ? [true, condition.readFile.hash]
207
+ : [false],
208
+ condition.realpath === undefined
209
+ ? null
210
+ : condition.realpath.ok
211
+ ? [true, condition.realpath.path]
212
+ : [false],
213
+ condition.stat ?? null,
214
+ ])}`;
215
+ return `${predicate}:${path.resolve(input)}`;
216
+ }
217
+
218
+ /** Whether one registered condition no longer describes its lexical path. */
219
+ function viteServeInputWatchConditionChanged(
220
+ entry: IMissingInputEntry,
221
+ ): boolean {
222
+ if (typeof entry.condition !== "string") {
223
+ return (
224
+ validateGraphInputObservation(entry.spelling, entry.condition).length !==
225
+ 0
226
+ );
227
+ }
228
+ try {
229
+ if (!fs.existsSync(entry.spelling)) {
230
+ return false;
231
+ }
232
+ return (
233
+ entry.condition === "exists" || !fs.statSync(entry.spelling).isDirectory()
234
+ );
235
+ } catch {
236
+ return false;
237
+ }
238
+ }
239
+
189
240
  /**
190
241
  * Invalidate every module-graph node of the registered importers so the next
191
242
  * request retransforms them. Importers keep their original absolute spelling so
package/src/next.ts CHANGED
@@ -1,4 +1,12 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
1
5
  import type { TtscUnpluginOptions } from "./core/options";
6
+ import {
7
+ TYPESCRIPT_TRANSFORM_EXTENSIONS,
8
+ TYPESCRIPT_TURBOPACK_RULE_GLOBS,
9
+ } from "./core/sourceExtensions";
2
10
  import webpack from "./webpack";
3
11
 
4
12
  /** The standalone loader entry Turbopack accepts through `turbopack.rules`. */
@@ -6,11 +14,11 @@ const TURBOPACK_LOADER = "@ttsc/unplugin/turbopack";
6
14
  /**
7
15
  * The globs the loader is wired for.
8
16
  *
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.
17
+ * The same exact set the manual wiring in the README uses. A wider glob would
18
+ * be harmless, since `isTransformTarget` declines everything else that reaches
19
+ * the loader, but it would also route files through a worker for nothing.
12
20
  */
13
- const TURBOPACK_RULE_GLOBS = ["*.ts", "*.tsx"];
21
+ const TURBOPACK_RULE_GLOBS = TYPESCRIPT_TURBOPACK_RULE_GLOBS;
14
22
 
15
23
  /**
16
24
  * Minimal structural type for a Next.js configuration object.
@@ -147,35 +155,33 @@ function withTtscTurbopackRules(
147
155
  options?: TtscUnpluginOptions,
148
156
  ): TurbopackLikeConfig {
149
157
  const rules: Record<string, unknown> = { ...(existing?.rules ?? {}) };
158
+ // Package ownership is stable only for this configuration snapshot. A later
159
+ // call may observe an atomic install or a directory-link retarget at the same
160
+ // lexical loader path, so no verdict can escape this invocation.
161
+ const resolvedLoaderResults = new Map<string, boolean>();
150
162
  for (const glob of TURBOPACK_RULE_GLOBS) {
151
163
  const rule = rules[glob];
152
- const loaders = selectTurbopackLoaders(rule);
153
- if (loaders.some(referencesTtscLoader)) {
164
+ if (hasUnconditionalTtscLoader(rule, resolvedLoaderResults)) {
154
165
  continue;
155
166
  }
156
167
  // The caller may have written the same file set under a different glob.
157
168
  // Adding ours beside theirs makes every matching module run the loader
158
169
  // twice, and the second pass receives the first pass's output, so the
159
170
  // 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)) {
171
+ // only the four this wrapper writes (samchon/ttsc#1314).
172
+ if (coveredByAnotherRule(rules, glob, resolvedLoaderResults)) {
162
173
  continue;
163
174
  }
164
175
  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.
176
+ // Loader shorthand runs right to left, so ttsc is appended there to see
177
+ // the original source. Rule collections run matching items in order, so
178
+ // ttsc becomes an explicit first rule there for the same reason. That is
179
+ // the same position `enforce: "pre"` gives it on the webpack half.
169
180
  //
170
181
  // Measured rather than inferred from webpack's `loader-runner`: two
171
182
  // loaders on one rule, each marking the source, came back marked in the
172
183
  // 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] };
184
+ rules[glob] = addUnconditionalTtscLoader(rule, entry);
179
185
  }
180
186
  return { ...(existing ?? {}), rules };
181
187
  }
@@ -196,13 +202,14 @@ function withTtscTurbopackRules(
196
202
  function coveredByAnotherRule(
197
203
  rules: Record<string, unknown>,
198
204
  glob: string,
205
+ resolvedLoaderResults: Map<string, boolean>,
199
206
  ): boolean {
200
207
  const extension = glob.slice(glob.lastIndexOf(".") + 1);
201
208
  return Object.entries(rules).some(([candidate, rule]) => {
202
209
  if (candidate === glob) {
203
210
  return false;
204
211
  }
205
- if (!selectTurbopackLoaders(rule).some(referencesTtscLoader)) {
212
+ if (!hasUnconditionalTtscLoader(rule, resolvedLoaderResults)) {
206
213
  return false;
207
214
  }
208
215
  return matchesExtension(candidate, extension);
@@ -231,7 +238,7 @@ function coveredByAnotherRule(
231
238
  * spellings, and that document explains why it is a set rather than a rule.
232
239
  */
233
240
  function matchesExtension(glob: string, extension: string): boolean {
234
- return PROJECT_WIDE_GLOBS.get(glob.trim())?.includes(extension) === true;
241
+ return PROJECT_WIDE_GLOBS.get(glob)?.includes(extension) === true;
235
242
  }
236
243
 
237
244
  /**
@@ -256,54 +263,223 @@ function matchesExtension(glob: string, extension: string): boolean {
256
263
  * if any — is a second registration rather than a module that no loader ever
257
264
  * sees.
258
265
  *
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.
266
+ * `experimental/test-unplugin` reads this exported table from the installed
267
+ * package, drives every entry through one `next build --turbopack`, and asserts
268
+ * root, nested, and deep `.ts`, `.tsx`, `.mts`, and `.cts` sources match
269
+ * exactly the extension family recorded here. An entry therefore cannot be
270
+ * recognised without that same build measuring it.
271
+ */
272
+ const TYPESCRIPT_EXTENSION_NAMES = TYPESCRIPT_TRANSFORM_EXTENSIONS.map(
273
+ (extension) => extension.slice(1),
274
+ );
275
+ const TYPESCRIPT_EXTENSION_GROUPS = extensionCombinations(
276
+ TYPESCRIPT_EXTENSION_NAMES,
277
+ );
278
+
279
+ /**
280
+ * Measured project-wide Turbopack globs and the source extensions each covers.
281
+ *
282
+ * Exported so the packed-package E2E can measure the exact production allowlist
283
+ * instead of maintaining a second list that can drift from it.
265
284
  */
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"]],
285
+ export const TURBOPACK_PROJECT_WIDE_GLOB_COVERAGE: ReadonlyArray<
286
+ readonly [string, readonly string[]]
287
+ > = Object.freeze([
288
+ ...TYPESCRIPT_EXTENSION_NAMES.flatMap((extension) => {
289
+ const extensions = Object.freeze([extension]);
290
+ return [
291
+ Object.freeze([`*.${extension}`, extensions] as const),
292
+ Object.freeze([`**/*.${extension}`, extensions] as const),
293
+ Object.freeze([`{**/,}*.${extension}`, extensions] as const),
294
+ ];
295
+ }),
296
+ ...TYPESCRIPT_EXTENSION_GROUPS.flatMap(projectWideBraceGlobEntries),
277
297
  ]);
298
+ const PROJECT_WIDE_GLOBS: ReadonlyMap<string, readonly string[]> = new Map(
299
+ TURBOPACK_PROJECT_WIDE_GLOB_COVERAGE,
300
+ );
301
+
302
+ /** Every source-extension subset of size two or more, in shared-table order. */
303
+ function extensionCombinations(
304
+ extensions: readonly string[],
305
+ ): ReadonlyArray<readonly string[]> {
306
+ const output: Array<readonly string[]> = [];
307
+ const visit = (start: number, selected: string[]): void => {
308
+ for (let index = start; index < extensions.length; index += 1) {
309
+ const next = [...selected, extensions[index]!];
310
+ if (next.length >= 2) output.push(Object.freeze(next));
311
+ visit(index + 1, next);
312
+ }
313
+ };
314
+ visit(0, []);
315
+ return output;
316
+ }
317
+
318
+ /** Measured project-wide brace spellings for one extension family. */
319
+ function projectWideBraceGlobEntries(
320
+ extensions: readonly string[],
321
+ ): ReadonlyArray<readonly [string, readonly string[]]> {
322
+ const suffixes = extensions.join(",");
323
+ const alternatives = extensions
324
+ .map((extension) => `*.${extension}`)
325
+ .join(",");
326
+ return [
327
+ Object.freeze([`*.{${suffixes}}`, extensions] as const),
328
+ Object.freeze([`{${alternatives}}`, extensions] as const),
329
+ Object.freeze([`**/*.{${suffixes}}`, extensions] as const),
330
+ Object.freeze([`**/{${alternatives}}`, extensions] as const),
331
+ Object.freeze([`**/**/*.{${suffixes}}`, extensions] as const),
332
+ ];
333
+ }
278
334
 
279
335
  /**
280
- * Read the loader list out of one Turbopack rule.
336
+ * Add one unconditional loader without flattening Next's rule collection.
281
337
  *
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".
338
+ * An array is either loader shorthand or a collection whose entries may be
339
+ * complete conditional rule objects, so it must remain an array. A collection
340
+ * executes every matching rule in order; its unconditional ttsc rule therefore
341
+ * comes first so it sees the original source before any caller rule. A
342
+ * conditioned object becomes that same two-item collection because putting ttsc
343
+ * inside the condition would leave the rest of the glob uncovered. An
344
+ * unconditioned object can keep its own shape while gaining the loader,
345
+ * including when it had no loaders.
285
346
  */
286
- function selectTurbopackLoaders(rule: unknown): unknown[] {
347
+ function addUnconditionalTtscLoader(
348
+ rule: unknown,
349
+ entry: { loader: string; options: TtscUnpluginOptions },
350
+ ): unknown {
287
351
  if (Array.isArray(rule)) {
288
- return rule;
352
+ return rule.every(isTurbopackLoaderItem)
353
+ ? [...rule, entry]
354
+ : [{ loaders: [entry] }, ...rule];
289
355
  }
290
356
  if (typeof rule === "object" && rule !== null) {
291
- const loaders = (rule as { loaders?: unknown }).loaders;
292
- if (Array.isArray(loaders)) {
293
- return loaders;
357
+ if ((rule as { condition?: unknown }).condition !== undefined) {
358
+ return [{ loaders: [entry] }, rule];
294
359
  }
360
+ return {
361
+ ...rule,
362
+ loaders: [...selectTurbopackConfigLoaders(rule), entry],
363
+ };
295
364
  }
296
- return [];
365
+ return { loaders: [entry] };
297
366
  }
298
367
 
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;
368
+ /** Read the direct loader list from one Turbopack rule configuration item. */
369
+ function selectTurbopackConfigLoaders(rule: unknown): unknown[] {
370
+ if (typeof rule !== "object" || rule === null || Array.isArray(rule)) {
371
+ return [];
372
+ }
373
+ const loaders = (rule as { loaders?: unknown }).loaders;
374
+ return Array.isArray(loaders) ? loaders : [];
375
+ }
376
+
377
+ /** Whether a rule collection provides this loader without a condition. */
378
+ function hasUnconditionalTtscLoader(
379
+ rule: unknown,
380
+ resolvedLoaderResults: Map<string, boolean>,
381
+ ): boolean {
382
+ if (Array.isArray(rule)) {
383
+ return rule.some((item) => {
384
+ if (isTurbopackLoaderItem(item)) {
385
+ return referencesTtscLoader(item, resolvedLoaderResults);
386
+ }
387
+ return (
388
+ typeof item === "object" &&
389
+ item !== null &&
390
+ (item as { condition?: unknown }).condition === undefined &&
391
+ selectTurbopackConfigLoaders(item).some((loader) =>
392
+ referencesTtscLoader(loader, resolvedLoaderResults),
393
+ )
394
+ );
395
+ });
303
396
  }
304
397
  return (
305
- typeof loader === "object" &&
306
- loader !== null &&
307
- (loader as { loader?: unknown }).loader === TURBOPACK_LOADER
398
+ typeof rule === "object" &&
399
+ rule !== null &&
400
+ (rule as { condition?: unknown }).condition === undefined &&
401
+ selectTurbopackConfigLoaders(rule).some((loader) =>
402
+ referencesTtscLoader(loader, resolvedLoaderResults),
403
+ )
308
404
  );
309
405
  }
406
+
407
+ /** Whether one collection item is a direct Turbopack loader. */
408
+ function isTurbopackLoaderItem(item: unknown): boolean {
409
+ return (
410
+ typeof item === "string" ||
411
+ (typeof item === "object" &&
412
+ item !== null &&
413
+ typeof (item as { loader?: unknown }).loader === "string")
414
+ );
415
+ }
416
+
417
+ /** Whether one Turbopack loader entry is already this package's loader. */
418
+ function referencesTtscLoader(
419
+ loader: unknown,
420
+ resolvedLoaderResults: Map<string, boolean>,
421
+ ): boolean {
422
+ const identity =
423
+ typeof loader === "string"
424
+ ? loader
425
+ : typeof loader === "object" && loader !== null
426
+ ? (loader as { loader?: unknown }).loader
427
+ : undefined;
428
+ return (
429
+ typeof identity === "string" &&
430
+ (identity === TURBOPACK_LOADER ||
431
+ isResolvedTtscLoader(identity, resolvedLoaderResults))
432
+ );
433
+ }
434
+
435
+ /** Whether an absolute path is the Turbopack entry of an @ttsc/unplugin copy. */
436
+ function isResolvedTtscLoader(
437
+ identity: string,
438
+ resolvedLoaderResults: Map<string, boolean>,
439
+ ): boolean {
440
+ const cached = resolvedLoaderResults.get(identity);
441
+ if (cached !== undefined) {
442
+ return cached;
443
+ }
444
+ let file: string;
445
+ try {
446
+ file = /^file:/i.test(identity) ? fileURLToPath(identity) : identity;
447
+ } catch {
448
+ resolvedLoaderResults.set(identity, false);
449
+ return false;
450
+ }
451
+ if (!path.isAbsolute(file)) {
452
+ resolvedLoaderResults.set(identity, false);
453
+ return false;
454
+ }
455
+ let resolvedFile: string;
456
+ try {
457
+ resolvedFile = fs.realpathSync.native(file);
458
+ if (!fs.statSync(resolvedFile).isFile()) {
459
+ resolvedLoaderResults.set(identity, false);
460
+ return false;
461
+ }
462
+ } catch {
463
+ resolvedLoaderResults.set(identity, false);
464
+ return false;
465
+ }
466
+ const packageRoot = path.dirname(path.dirname(resolvedFile));
467
+ const relative = path
468
+ .relative(packageRoot, resolvedFile)
469
+ .replaceAll(path.sep, "/");
470
+ if (relative !== "lib/turbopack.js" && relative !== "lib/turbopack.mjs") {
471
+ resolvedLoaderResults.set(identity, false);
472
+ return false;
473
+ }
474
+ let matches = false;
475
+ try {
476
+ const manifest = JSON.parse(
477
+ fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"),
478
+ ) as { name?: unknown };
479
+ matches = manifest.name === "@ttsc/unplugin";
480
+ } catch {
481
+ // An unreadable or malformed owner cannot prove this package's identity.
482
+ }
483
+ resolvedLoaderResults.set(identity, matches);
484
+ return matches;
485
+ }