@ttsc/unplugin 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -16
  3. package/lib/api.js +13 -18
  4. package/lib/api.js.map +1 -1
  5. package/lib/api.mjs +4 -0
  6. package/lib/api.mjs.map +1 -0
  7. package/lib/bun.js +13 -12
  8. package/lib/bun.js.map +1 -1
  9. package/lib/bun.mjs +31 -0
  10. package/lib/bun.mjs.map +1 -0
  11. package/lib/core/index.d.ts +2 -2
  12. package/lib/core/index.js +28 -18
  13. package/lib/core/index.js.map +1 -1
  14. package/lib/core/index.mjs +46 -0
  15. package/lib/core/index.mjs.map +1 -0
  16. package/lib/core/options.d.ts +3 -3
  17. package/lib/core/options.js +5 -5
  18. package/lib/core/options.js.map +1 -1
  19. package/lib/core/options.mjs +14 -0
  20. package/lib/core/options.mjs.map +1 -0
  21. package/lib/core/transform.d.ts +10 -2
  22. package/lib/core/transform.js +264 -119
  23. package/lib/core/transform.js.map +1 -1
  24. package/lib/core/transform.mjs +446 -0
  25. package/lib/core/transform.mjs.map +1 -0
  26. package/lib/esbuild.js +9 -8
  27. package/lib/esbuild.js.map +1 -1
  28. package/lib/esbuild.mjs +6 -0
  29. package/lib/esbuild.mjs.map +1 -0
  30. package/lib/farm.js +9 -8
  31. package/lib/farm.js.map +1 -1
  32. package/lib/farm.mjs +6 -0
  33. package/lib/farm.mjs.map +1 -0
  34. package/lib/index.js +10 -8
  35. package/lib/index.js.map +1 -1
  36. package/lib/index.mjs +6 -0
  37. package/lib/index.mjs.map +1 -0
  38. package/lib/next.js +10 -9
  39. package/lib/next.js.map +1 -1
  40. package/lib/next.mjs +18 -0
  41. package/lib/next.mjs.map +1 -0
  42. package/lib/rolldown.js +9 -8
  43. package/lib/rolldown.js.map +1 -1
  44. package/lib/rolldown.mjs +6 -0
  45. package/lib/rolldown.mjs.map +1 -0
  46. package/lib/rollup.js +9 -8
  47. package/lib/rollup.js.map +1 -1
  48. package/lib/rollup.mjs +6 -0
  49. package/lib/rollup.mjs.map +1 -0
  50. package/lib/rspack.js +9 -8
  51. package/lib/rspack.js.map +1 -1
  52. package/lib/rspack.mjs +6 -0
  53. package/lib/rspack.mjs.map +1 -0
  54. package/lib/vite.js +9 -8
  55. package/lib/vite.js.map +1 -1
  56. package/lib/vite.mjs +6 -0
  57. package/lib/vite.mjs.map +1 -0
  58. package/lib/webpack.js +9 -8
  59. package/lib/webpack.js.map +1 -1
  60. package/lib/webpack.mjs +6 -0
  61. package/lib/webpack.mjs.map +1 -0
  62. package/package.json +30 -18
  63. package/src/bun.ts +1 -1
  64. package/src/core/index.ts +16 -4
  65. package/src/core/options.ts +3 -3
  66. package/src/core/transform.ts +306 -61
@@ -1,7 +1,7 @@
1
- import * as Diff from "diff-match-patch-es";
2
1
  import fs from "node:fs";
2
+ import crypto from "node:crypto";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
- import MagicString from "magic-string";
5
5
  import type {
6
6
  ITtscCompilerDiagnostic,
7
7
  ITtscCompilerTransformation,
@@ -21,13 +21,33 @@ export interface TtscTransformAlias {
21
21
  replacement: string;
22
22
  }
23
23
 
24
+ export interface TtscCachedProjectTransform {
25
+ inputHashes: Record<string, string>;
26
+ projectRoot: string;
27
+ result: ITtscCompilerTransformation;
28
+ }
29
+
30
+ export type TtscTransformCache = Map<
31
+ string,
32
+ Promise<TtscCachedProjectTransform>
33
+ >;
34
+
35
+ export function createTtscTransformCache(): TtscTransformCache {
36
+ return new Map();
37
+ }
38
+
24
39
  export async function transformTtsc(
25
40
  id: string,
26
41
  source: string,
27
42
  options: ResolvedTtscUnpluginOptions,
28
43
  aliases?: unknown,
44
+ cache?: TtscTransformCache,
29
45
  ): Promise<TtscTransformResult | undefined> {
30
- const file = path.resolve(stripQuery(id));
46
+ const clean = stripQuery(id);
47
+ if (clean.includes("\0")) {
48
+ return undefined;
49
+ }
50
+ const file = path.resolve(clean);
31
51
  if (isDeclarationFile(file)) {
32
52
  return undefined;
33
53
  }
@@ -36,28 +56,45 @@ export async function transformTtsc(
36
56
  const tsconfigDir = path.dirname(tsconfig);
37
57
  const baseUrl = resolveBaseUrl(tsconfigDir, options.compilerOptions);
38
58
  const aliasPaths = createAliasPaths(baseUrl, aliases);
39
- const configured = createTransformTsconfig({
59
+ const key = createTransformCacheKey({
40
60
  aliasPaths,
41
- baseUrl,
42
61
  compilerOptions: options.compilerOptions,
62
+ plugins: options.plugins,
43
63
  tsconfig,
44
64
  });
45
- try {
46
- const effectiveTsconfig = configured.path;
47
- const result = new TtscCompiler({
48
- cwd: path.dirname(effectiveTsconfig),
65
+
66
+ let transformed = cache?.get(key);
67
+ if (transformed !== undefined) {
68
+ const cached = await transformed;
69
+ if (matchesCachedSource(cached, file, source)) {
70
+ reportSuccessDiagnostics(cached.result);
71
+ const code = selectTransformedSource({
72
+ file,
73
+ projectRoot: cached.projectRoot,
74
+ result: cached.result,
75
+ });
76
+ return createTransformResult(source, code);
77
+ }
78
+ cache?.delete(key);
79
+ transformed = undefined;
80
+ }
81
+
82
+ if (transformed === undefined) {
83
+ transformed = transformProject({
84
+ aliasPaths,
85
+ baseUrl,
86
+ compilerOptions: options.compilerOptions,
87
+ currentFile: file,
88
+ currentSource: source,
49
89
  plugins: options.plugins,
50
- tsconfig: effectiveTsconfig,
51
- }).transform();
52
- const code = selectTransformedSource({
53
- file,
54
- projectRoot: path.dirname(effectiveTsconfig),
55
- result,
90
+ tsconfig,
56
91
  });
57
- return createTransformResult(source, code, file);
58
- } finally {
59
- configured.dispose();
92
+ cache?.set(key, transformed);
60
93
  }
94
+ const { projectRoot, result } = await transformed;
95
+ reportSuccessDiagnostics(result);
96
+ const code = selectTransformedSource({ file, projectRoot, result });
97
+ return createTransformResult(source, code);
61
98
  }
62
99
 
63
100
  export function stripQuery(id: string): string {
@@ -75,49 +112,157 @@ export function isDeclarationFile(id: string): boolean {
75
112
  export function createTransformResult(
76
113
  source: string,
77
114
  code: string,
78
- id: string,
79
115
  ): TtscTransformResult | undefined {
80
116
  if (source === code) {
81
117
  return undefined;
82
118
  }
119
+ return { code };
120
+ }
83
121
 
84
- const magic = new MagicString(source);
85
- const diff = Diff.diff(source, code);
86
- Diff.diffCleanupSemantic(diff);
122
+ function matchesCachedSource(
123
+ cached: TtscCachedProjectTransform,
124
+ file: string,
125
+ source: string,
126
+ ): boolean {
127
+ const currentKey = toProjectKey(cached.projectRoot, file);
128
+ const currentHashes = collectProjectInputHashes(cached.projectRoot);
129
+ currentHashes[currentKey] = hashText(source);
130
+ return sameHashes(cached.inputHashes, currentHashes);
131
+ }
87
132
 
88
- let offset = 0;
89
- for (let index = 0; index < diff.length; index += 1) {
90
- const [type, text] = diff[index]!;
91
- if (type === 0) {
92
- offset += text.length;
93
- continue;
133
+ function collectInputHashes(props: {
134
+ currentFile: string;
135
+ currentSource: string;
136
+ projectRoot: string;
137
+ result: ITtscCompilerTransformation;
138
+ }): Record<string, string> {
139
+ const hashes = collectProjectInputHashes(props.projectRoot);
140
+ if (props.result.type !== "exception") {
141
+ for (const key of Object.keys(props.result.typescript)) {
142
+ const file = path.resolve(props.projectRoot, key);
143
+ try {
144
+ hashes[key] = hashText(fs.readFileSync(file, "utf8"));
145
+ } catch {
146
+ // A plugin may synthesize a virtual TypeScript file. It should not
147
+ // decide cache reuse for real source files.
148
+ }
94
149
  }
95
- if (type === 1) {
96
- magic.prependLeft(offset, text);
97
- continue;
150
+ }
151
+ hashes[toProjectKey(props.projectRoot, props.currentFile)] = hashText(
152
+ props.currentSource,
153
+ );
154
+ return hashes;
155
+ }
156
+
157
+ function collectProjectInputHashes(
158
+ projectRoot: string,
159
+ ): Record<string, string> {
160
+ const hashes: Record<string, string> = {};
161
+ for (const file of listProjectInputFiles(projectRoot)) {
162
+ try {
163
+ hashes[toProjectKey(projectRoot, file)] = hashText(fs.readFileSync(file));
164
+ } catch {
165
+ // File watchers may observe a transform while another process is moving
166
+ // or deleting files. The missing key invalidates older cache entries.
98
167
  }
168
+ }
169
+ return hashes;
170
+ }
99
171
 
100
- const next = diff[index + 1];
101
- if (next?.[0] === 1) {
102
- magic.update(offset, offset + text.length, next[1]);
103
- index += 1;
104
- } else {
105
- magic.remove(offset, offset + text.length);
172
+ function listProjectInputFiles(root: string): string[] {
173
+ const out: string[] = [];
174
+ const stack = [root];
175
+ while (stack.length !== 0) {
176
+ const current = stack.pop()!;
177
+ let entries: fs.Dirent[];
178
+ try {
179
+ entries = fs.readdirSync(current, { withFileTypes: true });
180
+ } catch {
181
+ continue;
182
+ }
183
+ for (const entry of entries) {
184
+ if (isIgnoredProjectDirectory(entry.name)) {
185
+ continue;
186
+ }
187
+ const file = path.join(current, entry.name);
188
+ if (entry.isDirectory()) {
189
+ stack.push(file);
190
+ } else if (entry.isFile()) {
191
+ out.push(file);
192
+ }
106
193
  }
107
- offset += text.length;
108
194
  }
195
+ out.sort();
196
+ return out;
197
+ }
109
198
 
110
- if (!magic.hasChanged()) {
111
- return undefined;
199
+ function isIgnoredProjectDirectory(name: string): boolean {
200
+ return (
201
+ name === ".git" ||
202
+ name === ".ttsc" ||
203
+ name === ".cache" ||
204
+ name === ".next" ||
205
+ name === ".nuxt" ||
206
+ name === ".svelte-kit" ||
207
+ name === ".turbo" ||
208
+ name === ".vite" ||
209
+ name === "build" ||
210
+ name === "coverage" ||
211
+ name === "dist" ||
212
+ name === "node_modules" ||
213
+ name === "out" ||
214
+ name === "temp" ||
215
+ name === "tmp"
216
+ );
217
+ }
218
+
219
+ function sameHashes(
220
+ left: Record<string, string>,
221
+ right: Record<string, string>,
222
+ ): boolean {
223
+ const leftKeys = Object.keys(left);
224
+ const rightKeys = Object.keys(right);
225
+ if (leftKeys.length !== rightKeys.length) {
226
+ return false;
227
+ }
228
+ return leftKeys.every((key) => right[key] === left[key]);
229
+ }
230
+
231
+ function hashText(input: string | Buffer): string {
232
+ return crypto.createHash("sha256").update(input).digest("hex");
233
+ }
234
+
235
+ async function transformProject(props: {
236
+ aliasPaths: Record<string, string[]>;
237
+ baseUrl: string;
238
+ compilerOptions: Record<string, unknown>;
239
+ currentFile: string;
240
+ currentSource: string;
241
+ plugins?: ResolvedTtscUnpluginOptions["plugins"];
242
+ tsconfig: string;
243
+ }): Promise<TtscCachedProjectTransform> {
244
+ const configured = createTransformTsconfig(props);
245
+ const projectRoot = path.dirname(props.tsconfig);
246
+ try {
247
+ const result = new TtscCompiler({
248
+ cwd: projectRoot,
249
+ plugins: props.plugins,
250
+ projectRoot,
251
+ tsconfig: configured.path,
252
+ }).transform();
253
+ return {
254
+ inputHashes: collectInputHashes({
255
+ currentFile: props.currentFile,
256
+ currentSource: props.currentSource,
257
+ projectRoot,
258
+ result,
259
+ }),
260
+ projectRoot,
261
+ result,
262
+ };
263
+ } finally {
264
+ configured.dispose();
112
265
  }
113
- return {
114
- code: magic.toString(),
115
- map: magic.generateMap({
116
- file: `${id}.map`,
117
- includeContent: true,
118
- source: id,
119
- }),
120
- };
121
266
  }
122
267
 
123
268
  function createTransformTsconfig(props: {
@@ -126,10 +271,13 @@ function createTransformTsconfig(props: {
126
271
  compilerOptions: Record<string, unknown>;
127
272
  tsconfig: string;
128
273
  }): { path: string; dispose: () => void } {
129
- const compilerOptions = {
130
- ...props.compilerOptions,
131
- ...createAliasCompilerOptions(props),
132
- };
274
+ const compilerOptions = normalizeCompilerOptionsForGeneratedTsconfig(
275
+ {
276
+ ...props.compilerOptions,
277
+ ...createAliasCompilerOptions(props),
278
+ },
279
+ path.dirname(props.tsconfig),
280
+ );
133
281
  if (Object.keys(compilerOptions).length === 0) {
134
282
  return {
135
283
  path: props.tsconfig,
@@ -137,18 +285,13 @@ function createTransformTsconfig(props: {
137
285
  };
138
286
  }
139
287
 
140
- const directory = path.dirname(props.tsconfig);
141
- const file = path.join(
142
- directory,
143
- `.ttsc-unplugin-${process.pid}-${Date.now()}-${Math.random()
144
- .toString(36)
145
- .slice(2)}.json`,
146
- );
288
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ttsc-unplugin-"));
289
+ const file = path.join(directory, "tsconfig.json");
147
290
  fs.writeFileSync(
148
291
  file,
149
292
  JSON.stringify(
150
293
  {
151
- extends: `./${path.basename(props.tsconfig)}`,
294
+ extends: normalizePath(props.tsconfig),
152
295
  compilerOptions,
153
296
  },
154
297
  null,
@@ -158,10 +301,55 @@ function createTransformTsconfig(props: {
158
301
  );
159
302
  return {
160
303
  path: file,
161
- dispose: () => fs.rmSync(file, { force: true }),
304
+ dispose: () => fs.rmSync(directory, { force: true, recursive: true }),
162
305
  };
163
306
  }
164
307
 
308
+ function normalizeCompilerOptionsForGeneratedTsconfig(
309
+ compilerOptions: Record<string, unknown>,
310
+ tsconfigDir: string,
311
+ ): Record<string, unknown> {
312
+ const output = { ...compilerOptions };
313
+ for (const key of ["baseUrl", "declarationDir", "outDir", "rootDir"]) {
314
+ if (typeof output[key] === "string") {
315
+ output[key] = path.resolve(tsconfigDir, output[key]);
316
+ }
317
+ }
318
+ for (const key of ["rootDirs", "typeRoots"]) {
319
+ if (Array.isArray(output[key])) {
320
+ output[key] = output[key].map((entry) =>
321
+ typeof entry === "string" ? path.resolve(tsconfigDir, entry) : entry,
322
+ );
323
+ }
324
+ }
325
+ if (hasPaths(output.paths) && typeof output.baseUrl !== "string") {
326
+ output.baseUrl = tsconfigDir;
327
+ }
328
+ if (Array.isArray(output.plugins)) {
329
+ output.plugins = output.plugins.map((entry) =>
330
+ normalizePluginConfigForGeneratedTsconfig(entry, tsconfigDir),
331
+ );
332
+ }
333
+ return output;
334
+ }
335
+
336
+ function normalizePluginConfigForGeneratedTsconfig(
337
+ entry: unknown,
338
+ tsconfigDir: string,
339
+ ): unknown {
340
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
341
+ return entry;
342
+ }
343
+ const output: Record<string, unknown> = { ...entry };
344
+ for (const key of ["config", "source", "transform"]) {
345
+ const value = output[key];
346
+ if (typeof value === "string" && isRelativeSpecifier(value)) {
347
+ output[key] = path.resolve(tsconfigDir, value);
348
+ }
349
+ }
350
+ return output;
351
+ }
352
+
165
353
  function createAliasCompilerOptions(props: {
166
354
  aliasPaths: Record<string, string[]>;
167
355
  baseUrl: string;
@@ -179,6 +367,15 @@ function createAliasCompilerOptions(props: {
179
367
  };
180
368
  }
181
369
 
370
+ function hasPaths(value: unknown): boolean {
371
+ return (
372
+ typeof value === "object" &&
373
+ value !== null &&
374
+ !Array.isArray(value) &&
375
+ Object.keys(value).length !== 0
376
+ );
377
+ }
378
+
182
379
  function readPaths(value: unknown): Record<string, string[]> {
183
380
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
184
381
  return {};
@@ -256,6 +453,44 @@ function normalizeAliases(aliases: unknown): TtscTransformAlias[] {
256
453
  return [];
257
454
  }
258
455
 
456
+ function createTransformCacheKey(props: {
457
+ aliasPaths: Record<string, string[]>;
458
+ compilerOptions: Record<string, unknown>;
459
+ plugins?: ResolvedTtscUnpluginOptions["plugins"];
460
+ tsconfig: string;
461
+ }): string {
462
+ return stableStringify({
463
+ aliasPaths: props.aliasPaths,
464
+ compilerOptions: props.compilerOptions,
465
+ plugins: props.plugins,
466
+ tsconfig: path.resolve(props.tsconfig),
467
+ });
468
+ }
469
+
470
+ function stableStringify(value: unknown): string {
471
+ if (Array.isArray(value)) {
472
+ return `[${value.map(stableStringify).join(",")}]`;
473
+ }
474
+ if (value && typeof value === "object") {
475
+ return `{${Object.entries(value)
476
+ .sort(([a], [b]) => a.localeCompare(b))
477
+ .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
478
+ .join(",")}}`;
479
+ }
480
+ return JSON.stringify(value);
481
+ }
482
+
483
+ function isRelativeSpecifier(value: string): boolean {
484
+ return (
485
+ value === "." ||
486
+ value === ".." ||
487
+ value.startsWith("./") ||
488
+ value.startsWith("../") ||
489
+ value.startsWith(".\\") ||
490
+ value.startsWith("..\\")
491
+ );
492
+ }
493
+
259
494
  function isAlias(value: unknown): value is TtscTransformAlias {
260
495
  return (
261
496
  typeof value === "object" &&
@@ -292,6 +527,16 @@ function selectTransformedSource(props: {
292
527
  throw new Error(`ttsc transform did not return output for ${props.file}`);
293
528
  }
294
529
 
530
+ function reportSuccessDiagnostics(result: ITtscCompilerTransformation): void {
531
+ if (result.type !== "success" || result.diagnostics === undefined) {
532
+ return;
533
+ }
534
+ const text = formatDiagnostics(result.diagnostics);
535
+ if (text.length !== 0) {
536
+ process.stderr.write(`${text}\n`);
537
+ }
538
+ }
539
+
295
540
  function formatDiagnostics(diagnostics: ITtscCompilerDiagnostic[]): string {
296
541
  if (diagnostics.length === 0) {
297
542
  return "ttsc transform failed";