@ttsc/unplugin 0.8.1 → 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.
@@ -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,11 +21,27 @@ 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
46
  const clean = stripQuery(id);
31
47
  if (clean.includes("\0")) {
@@ -40,28 +56,45 @@ 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 {
@@ -79,49 +112,157 @@ export function isDeclarationFile(id: string): boolean {
79
112
  export function createTransformResult(
80
113
  source: string,
81
114
  code: string,
82
- id: string,
83
115
  ): TtscTransformResult | undefined {
84
116
  if (source === code) {
85
117
  return undefined;
86
118
  }
119
+ return { code };
120
+ }
87
121
 
88
- const magic = new MagicString(source);
89
- const diff = Diff.diff(source, code);
90
- 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
+ }
91
132
 
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;
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
+ }
98
149
  }
99
- if (type === 1) {
100
- magic.prependLeft(offset, text);
101
- 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.
102
167
  }
168
+ }
169
+ return hashes;
170
+ }
103
171
 
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);
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
+ }
110
193
  }
111
- offset += text.length;
112
194
  }
195
+ out.sort();
196
+ return out;
197
+ }
113
198
 
114
- if (!magic.hasChanged()) {
115
- 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();
116
265
  }
117
- return {
118
- code: magic.toString(),
119
- map: magic.generateMap({
120
- file: `${id}.map`,
121
- includeContent: true,
122
- source: id,
123
- }),
124
- };
125
266
  }
126
267
 
127
268
  function createTransformTsconfig(props: {
@@ -130,10 +271,13 @@ function createTransformTsconfig(props: {
130
271
  compilerOptions: Record<string, unknown>;
131
272
  tsconfig: string;
132
273
  }): { path: string; dispose: () => void } {
133
- const compilerOptions = {
134
- ...props.compilerOptions,
135
- ...createAliasCompilerOptions(props),
136
- };
274
+ const compilerOptions = normalizeCompilerOptionsForGeneratedTsconfig(
275
+ {
276
+ ...props.compilerOptions,
277
+ ...createAliasCompilerOptions(props),
278
+ },
279
+ path.dirname(props.tsconfig),
280
+ );
137
281
  if (Object.keys(compilerOptions).length === 0) {
138
282
  return {
139
283
  path: props.tsconfig,
@@ -141,18 +285,13 @@ function createTransformTsconfig(props: {
141
285
  };
142
286
  }
143
287
 
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
- );
288
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ttsc-unplugin-"));
289
+ const file = path.join(directory, "tsconfig.json");
151
290
  fs.writeFileSync(
152
291
  file,
153
292
  JSON.stringify(
154
293
  {
155
- extends: `./${path.basename(props.tsconfig)}`,
294
+ extends: normalizePath(props.tsconfig),
156
295
  compilerOptions,
157
296
  },
158
297
  null,
@@ -162,10 +301,55 @@ function createTransformTsconfig(props: {
162
301
  );
163
302
  return {
164
303
  path: file,
165
- dispose: () => fs.rmSync(file, { force: true }),
304
+ dispose: () => fs.rmSync(directory, { force: true, recursive: true }),
166
305
  };
167
306
  }
168
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
+
169
353
  function createAliasCompilerOptions(props: {
170
354
  aliasPaths: Record<string, string[]>;
171
355
  baseUrl: string;
@@ -183,6 +367,15 @@ function createAliasCompilerOptions(props: {
183
367
  };
184
368
  }
185
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
+
186
379
  function readPaths(value: unknown): Record<string, string[]> {
187
380
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
188
381
  return {};
@@ -260,6 +453,44 @@ function normalizeAliases(aliases: unknown): TtscTransformAlias[] {
260
453
  return [];
261
454
  }
262
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
+
263
494
  function isAlias(value: unknown): value is TtscTransformAlias {
264
495
  return (
265
496
  typeof value === "object" &&
@@ -296,6 +527,16 @@ function selectTransformedSource(props: {
296
527
  throw new Error(`ttsc transform did not return output for ${props.file}`);
297
528
  }
298
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
+
299
540
  function formatDiagnostics(diagnostics: ITtscCompilerDiagnostic[]): string {
300
541
  if (diagnostics.length === 0) {
301
542
  return "ttsc transform failed";