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