@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.
- package/README.md +70 -8
- package/lib/api.js +3 -0
- package/lib/api.js.map +1 -1
- package/lib/api.mjs +1 -0
- package/lib/api.mjs.map +1 -1
- package/lib/core/index.d.cts +17 -6
- package/lib/core/index.d.mts +17 -6
- package/lib/core/index.d.ts +17 -6
- package/lib/core/index.js +117 -21
- package/lib/core/index.js.map +1 -1
- package/lib/core/index.mjs +115 -21
- package/lib/core/index.mjs.map +1 -1
- package/lib/core/transform.d.cts +93 -25
- package/lib/core/transform.d.mts +93 -25
- package/lib/core/transform.d.ts +93 -25
- package/lib/core/transform.js +803 -121
- package/lib/core/transform.js.map +1 -1
- package/lib/core/transform.mjs +804 -122
- package/lib/core/transform.mjs.map +1 -1
- package/lib/core/tsconfigPaths.d.cts +61 -0
- package/lib/core/tsconfigPaths.d.mts +61 -0
- package/lib/core/tsconfigPaths.d.ts +61 -0
- package/lib/core/tsconfigPaths.js +190 -7
- package/lib/core/tsconfigPaths.js.map +1 -1
- package/lib/core/tsconfigPaths.mjs +188 -8
- package/lib/core/tsconfigPaths.mjs.map +1 -1
- package/lib/next.d.cts +27 -10
- package/lib/next.d.mts +27 -10
- package/lib/next.d.ts +27 -10
- package/lib/next.js +229 -8
- package/lib/next.js.map +1 -1
- package/lib/next.mjs +229 -8
- package/lib/next.mjs.map +1 -1
- package/lib/turbopack.d.cts +5 -4
- package/lib/turbopack.d.mts +5 -4
- package/lib/turbopack.d.ts +5 -4
- package/lib/turbopack.js +13 -7
- package/lib/turbopack.js.map +1 -1
- package/lib/turbopack.mjs +14 -8
- package/lib/turbopack.mjs.map +1 -1
- package/package.json +3 -3
- package/src/core/index.ts +122 -21
- package/src/core/transform.ts +1073 -132
- package/src/core/tsconfigPaths.ts +254 -8
- package/src/next.ts +262 -10
- 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
|
|
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
|
|
98
|
-
if (
|
|
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 =
|
|
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
|
|
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
|
|
30
|
-
*
|
|
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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
|
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
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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
|
-
|
|
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
|
}
|