@ttsc/unplugin 0.15.2 → 0.15.3

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.
@@ -10,6 +10,10 @@ import { TtscCompiler } from "ttsc";
10
10
  import type { TransformResult } from "unplugin";
11
11
 
12
12
  import type { ResolvedTtscUnpluginOptions } from "./options";
13
+ import {
14
+ absolutizePathsTarget,
15
+ readEffectiveTsconfigPaths,
16
+ } from "./tsconfigPaths";
13
17
 
14
18
  /**
15
19
  * The normalised transform result type that this module produces.
@@ -71,6 +75,21 @@ export function createTtscTransformCache(): TtscTransformCache {
71
75
  return new Map();
72
76
  }
73
77
 
78
+ /**
79
+ * Hooks the bundler adapter passes into {@link transformTtsc} so transform
80
+ * side-channels (currently the plugin-reported dependency list) reach the
81
+ * bundler without leaking extra fields on the returned `TransformResult`.
82
+ */
83
+ export interface TtscTransformHooks {
84
+ /**
85
+ * Invoked once per absolute dependency path the plugin reported for the
86
+ * transformed file (`dependencies` in the transform envelope). Adapters
87
+ * forward this to the bundler's `addWatchFile` so type-only inputs
88
+ * participate in HMR invalidation.
89
+ */
90
+ addWatchFile?: (file: string) => void;
91
+ }
92
+
74
93
  /**
75
94
  * Apply the ttsc plugin transform to a single source file.
76
95
  *
@@ -90,6 +109,9 @@ export function createTtscTransformCache(): TtscTransformCache {
90
109
  * object).
91
110
  * @param cache - Optional per-build cache; cleared by the caller on
92
111
  * `buildStart`.
112
+ * @param hooks - Optional adapter callbacks; see {@link TtscTransformHooks}.
113
+ * Dependency notifications fire on cache hits too — watch registrations are
114
+ * per build, not per compilation.
93
115
  */
94
116
  export async function transformTtsc(
95
117
  id: string,
@@ -97,6 +119,7 @@ export async function transformTtsc(
97
119
  options: ResolvedTtscUnpluginOptions,
98
120
  aliases?: unknown,
99
121
  cache?: TtscTransformCache,
122
+ hooks?: TtscTransformHooks,
100
123
  ): Promise<TtscTransformResult | undefined> {
101
124
  const clean = stripQuery(id);
102
125
  if (clean.includes("\0")) {
@@ -111,9 +134,7 @@ export async function transformTtsc(
111
134
  }
112
135
 
113
136
  const tsconfig = resolveTsconfig(file, options.project);
114
- const tsconfigDir = path.dirname(tsconfig);
115
- const baseUrl = resolveBaseUrl(tsconfigDir, options.compilerOptions);
116
- const aliasPaths = createAliasPaths(baseUrl, aliases);
137
+ const aliasPaths = createAliasPaths(aliases);
117
138
  const key = createTransformCacheKey({
118
139
  aliasPaths,
119
140
  compilerOptions: options.compilerOptions,
@@ -131,6 +152,11 @@ export async function transformTtsc(
131
152
  projectRoot: cached.projectRoot,
132
153
  result: cached.result,
133
154
  });
155
+ notifyFileDependencies(hooks, {
156
+ file,
157
+ projectRoot: cached.projectRoot,
158
+ result: cached.result,
159
+ });
134
160
  return createTransformResult(source, code);
135
161
  }
136
162
  cache?.delete(key);
@@ -140,7 +166,6 @@ export async function transformTtsc(
140
166
  if (transformed === undefined) {
141
167
  transformed = transformProject({
142
168
  aliasPaths,
143
- baseUrl,
144
169
  compilerOptions: options.compilerOptions,
145
170
  currentFile: file,
146
171
  currentSource: source,
@@ -152,9 +177,83 @@ export async function transformTtsc(
152
177
  const { projectRoot, result } = await transformed;
153
178
  reportSuccessDiagnostics(result);
154
179
  const code = selectTransformedSource({ file, projectRoot, result });
180
+ notifyFileDependencies(hooks, { file, projectRoot, result });
155
181
  return createTransformResult(source, code);
156
182
  }
157
183
 
184
+ /**
185
+ * Forward the plugin-reported dependency list for `file` to the adapter's
186
+ * `addWatchFile` hook.
187
+ *
188
+ * The transform envelope's `dependencies` keys mirror the `typescript` keys
189
+ * (project-relative); values may be project-relative or absolute. Every path is
190
+ * absolutized against the project root and deduplicated, and the file itself is
191
+ * dropped — the bundler already watches the module it transforms.
192
+ */
193
+ function notifyFileDependencies(
194
+ hooks: TtscTransformHooks | undefined,
195
+ props: {
196
+ file: string;
197
+ projectRoot: string;
198
+ result: ITtscCompilerTransformation;
199
+ },
200
+ ): void {
201
+ const addWatchFile = hooks?.addWatchFile;
202
+ if (addWatchFile === undefined) {
203
+ return;
204
+ }
205
+ for (const dependency of selectFileDependencies(props)) {
206
+ addWatchFile(dependency);
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Extract the absolute, deduplicated dependency list for a single file from the
212
+ * compiler result. Mirrors {@link selectTransformedSource}'s key lookup: fast
213
+ * project-relative match first, then a resolve-based scan. Returns an empty
214
+ * list on exceptions or when the plugin reported nothing.
215
+ */
216
+ function selectFileDependencies(props: {
217
+ file: string;
218
+ projectRoot: string;
219
+ result: ITtscCompilerTransformation;
220
+ }): string[] {
221
+ if (props.result.type === "exception") {
222
+ return [];
223
+ }
224
+ const dependencies = props.result.dependencies;
225
+ if (dependencies === undefined) {
226
+ return [];
227
+ }
228
+ const key = toProjectKey(props.projectRoot, props.file);
229
+ let entries = dependencies[key];
230
+ if (entries === undefined) {
231
+ for (const [candidate, candidateEntries] of Object.entries(dependencies)) {
232
+ if (path.resolve(props.projectRoot, candidate) === props.file) {
233
+ entries = candidateEntries;
234
+ break;
235
+ }
236
+ }
237
+ }
238
+ if (!Array.isArray(entries)) {
239
+ return [];
240
+ }
241
+ const output: string[] = [];
242
+ const seen = new Set<string>();
243
+ for (const entry of entries) {
244
+ if (typeof entry !== "string" || entry.length === 0) {
245
+ continue;
246
+ }
247
+ const absolute = path.resolve(props.projectRoot, entry);
248
+ if (absolute === props.file || seen.has(absolute)) {
249
+ continue;
250
+ }
251
+ seen.add(absolute);
252
+ output.push(absolute);
253
+ }
254
+ return output;
255
+ }
256
+
158
257
  /**
159
258
  * Strip a query string or hash fragment from a bundler module id.
160
259
  *
@@ -337,7 +436,6 @@ function hashText(input: string | Buffer): string {
337
436
 
338
437
  async function transformProject(props: {
339
438
  aliasPaths: Record<string, string[]>;
340
- baseUrl: string;
341
439
  compilerOptions: Record<string, unknown>;
342
440
  currentFile: string;
343
441
  currentSource: string;
@@ -370,7 +468,6 @@ async function transformProject(props: {
370
468
 
371
469
  function createTransformTsconfig(props: {
372
470
  aliasPaths: Record<string, string[]>;
373
- baseUrl: string;
374
471
  compilerOptions: Record<string, unknown>;
375
472
  tsconfig: string;
376
473
  }): { path: string; dispose: () => void } {
@@ -416,9 +513,10 @@ function createTransformTsconfig(props: {
416
513
  * tsconfig must be converted to an absolute path before writing the generated
417
514
  * file. Otherwise TypeScript-Go resolves it against the temp dir.
418
515
  *
419
- * Also inserts a synthetic `baseUrl` equal to `tsconfigDir` when `paths` is
420
- * provided but `baseUrl` is absent TypeScript requires `baseUrl` alongside
421
- * `paths` when the latter contains non-absolute targets.
516
+ * `paths` targets are absolutized for the same reason, with the extra twist
517
+ * that TypeScript-Go rejects bare non-relative targets outright (TS5090) and
518
+ * has removed `baseUrl` (TS5102), so anchoring them as absolute paths is the
519
+ * only temp-dir-safe encoding. No synthetic `baseUrl` is ever written.
422
520
  */
423
521
  function normalizeCompilerOptionsForGeneratedTsconfig(
424
522
  compilerOptions: Record<string, unknown>,
@@ -426,7 +524,7 @@ function normalizeCompilerOptionsForGeneratedTsconfig(
426
524
  ): Record<string, unknown> {
427
525
  const output = { ...compilerOptions };
428
526
  // Scalar path fields: resolve each against the original tsconfig directory.
429
- for (const key of ["baseUrl", "declarationDir", "outDir", "rootDir"]) {
527
+ for (const key of ["declarationDir", "outDir", "rootDir"]) {
430
528
  if (typeof output[key] === "string") {
431
529
  output[key] = path.resolve(tsconfigDir, output[key]);
432
530
  }
@@ -439,8 +537,14 @@ function normalizeCompilerOptionsForGeneratedTsconfig(
439
537
  );
440
538
  }
441
539
  }
442
- if (hasPaths(output.paths) && typeof output.baseUrl !== "string") {
443
- output.baseUrl = tsconfigDir;
540
+ const paths = readPaths(output.paths);
541
+ if (Object.keys(paths).length !== 0) {
542
+ output.paths = Object.fromEntries(
543
+ Object.entries(paths).map(([key, targets]) => [
544
+ key,
545
+ targets.map((target) => absolutizePathsTarget(tsconfigDir, target)),
546
+ ]),
547
+ );
444
548
  }
445
549
  if (Array.isArray(output.plugins)) {
446
550
  output.plugins = output.plugins.map((entry) =>
@@ -467,32 +571,37 @@ function normalizePluginConfigForGeneratedTsconfig(
467
571
  return output;
468
572
  }
469
573
 
574
+ /**
575
+ * Build the `paths` overlay that forwards bundler aliases to the compiler.
576
+ *
577
+ * Because the generated tsconfig `extends` the project one and TypeScript
578
+ * merges `compilerOptions` per option key, declaring `paths` here replaces the
579
+ * project's own `paths` wholesale. The overlay therefore re-states the
580
+ * project's effective mappings first, so tsconfig-only aliases keep resolving;
581
+ * inline `compilerOptions.paths` from the plugin options ride on top, and the
582
+ * bundler aliases win last — they mirror what the bundler will actually do at
583
+ * resolve time.
584
+ *
585
+ * No `baseUrl` is emitted: TypeScript-Go removed the option (TS5102), and all
586
+ * targets are absolute so none is needed.
587
+ */
470
588
  function createAliasCompilerOptions(props: {
471
589
  aliasPaths: Record<string, string[]>;
472
- baseUrl: string;
473
590
  compilerOptions: Record<string, unknown>;
591
+ tsconfig: string;
474
592
  }): Record<string, unknown> {
475
593
  if (Object.keys(props.aliasPaths).length === 0) {
476
594
  return {};
477
595
  }
478
596
  return {
479
- baseUrl: toCompilerPath(props.baseUrl, props.compilerOptions),
480
597
  paths: {
598
+ ...readEffectiveTsconfigPaths(props.tsconfig),
481
599
  ...readPaths(props.compilerOptions.paths),
482
600
  ...props.aliasPaths,
483
601
  },
484
602
  };
485
603
  }
486
604
 
487
- function hasPaths(value: unknown): boolean {
488
- return (
489
- typeof value === "object" &&
490
- value !== null &&
491
- !Array.isArray(value) &&
492
- Object.keys(value).length !== 0
493
- );
494
- }
495
-
496
605
  function readPaths(value: unknown): Record<string, string[]> {
497
606
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
498
607
  return {};
@@ -512,28 +621,14 @@ function readPaths(value: unknown): Record<string, string[]> {
512
621
  return output;
513
622
  }
514
623
 
515
- function resolveBaseUrl(
516
- tsconfigDir: string,
517
- compilerOptions: Record<string, unknown>,
518
- ): string {
519
- return typeof compilerOptions.baseUrl === "string"
520
- ? path.resolve(tsconfigDir, compilerOptions.baseUrl)
521
- : tsconfigDir;
522
- }
523
-
524
- function toCompilerPath(
525
- absoluteBaseUrl: string,
526
- compilerOptions: Record<string, unknown>,
527
- ): string {
528
- return typeof compilerOptions.baseUrl === "string"
529
- ? compilerOptions.baseUrl
530
- : absoluteBaseUrl;
531
- }
532
-
533
- function createAliasPaths(
534
- baseUrl: string,
535
- aliases: unknown,
536
- ): Record<string, string[]> {
624
+ /**
625
+ * Convert bundler aliases into absolute `paths` mappings.
626
+ *
627
+ * Targets are written as absolute paths on purpose: the generated tsconfig
628
+ * lives in a system temp directory, where TypeScript-Go would reject bare
629
+ * relative targets (TS5090) and anchor `./`-style ones at the wrong directory.
630
+ */
631
+ function createAliasPaths(aliases: unknown): Record<string, string[]> {
537
632
  const paths: Record<string, string[]> = {};
538
633
  for (const alias of normalizeAliases(aliases)) {
539
634
  if (typeof alias.find !== "string" || alias.find.length === 0) {
@@ -546,10 +641,11 @@ function createAliasPaths(
546
641
  if (key.length === 0) {
547
642
  continue;
548
643
  }
549
- const replacement = path.isAbsolute(alias.replacement)
550
- ? alias.replacement
551
- : path.resolve(process.cwd(), alias.replacement);
552
- const target = normalizePath(path.relative(baseUrl, replacement) || ".");
644
+ const target = normalizePath(
645
+ path.isAbsolute(alias.replacement)
646
+ ? alias.replacement
647
+ : path.resolve(process.cwd(), alias.replacement),
648
+ );
553
649
  paths[key] = [target];
554
650
  paths[`${key}/*`] = [`${target}/*`];
555
651
  }
@@ -0,0 +1,338 @@
1
+ import fs from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+
5
+ /**
6
+ * Read the effective `compilerOptions.paths` of a tsconfig, following its
7
+ * `extends` chain, and absolutize every mapping target.
8
+ *
9
+ * TypeScript merges `compilerOptions` per option key, so the effective `paths`
10
+ * is the whole object from the nearest config in the chain that declares one
11
+ * (own config first, then `extends` entries in reverse priority order).
12
+ * Relative targets are anchored at the directory of the config that declares
13
+ * them — TypeScript-Go resolves inherited relative `paths` against the
14
+ * declaring file, not the extending one.
15
+ *
16
+ * The generated transform tsconfig replaces `paths` wholesale (standard
17
+ * `extends` semantics), so the alias overlay must re-state these base mappings
18
+ * or every tsconfig-only alias silently stops resolving. Absolutizing is
19
+ * required because the generated config lives in a system temp directory and
20
+ * TypeScript-Go rejects non-relative targets (TS5090) while accepting absolute
21
+ * ones.
22
+ *
23
+ * Best-effort by design: a missing or unparsable config in the chain yields
24
+ * `{}` here and a real config error from the compiler, which owns config
25
+ * diagnostics.
26
+ */
27
+ export function readEffectiveTsconfigPaths(
28
+ tsconfig: string,
29
+ ): Record<string, string[]> {
30
+ const declared = findDeclaredPaths(path.resolve(tsconfig), new Set());
31
+ if (declared === null) {
32
+ return {};
33
+ }
34
+ const output: Record<string, string[]> = {};
35
+ for (const [key, targets] of Object.entries(declared.paths)) {
36
+ if (!Array.isArray(targets)) {
37
+ continue;
38
+ }
39
+ const absolute = targets
40
+ .filter((target): target is string => typeof target === "string")
41
+ .map((target) => absolutizePathsTarget(declared.baseDir, target));
42
+ if (absolute.length !== 0) {
43
+ output[key] = absolute;
44
+ }
45
+ }
46
+ return output;
47
+ }
48
+
49
+ /**
50
+ * Anchor a single `paths` target at `baseDir` unless it is already absolute,
51
+ * normalizing to forward slashes. The `*` wildcard survives `path.resolve` as a
52
+ * literal segment, so patterns like `./src/*` stay patterns.
53
+ */
54
+ export function absolutizePathsTarget(baseDir: string, target: string): string {
55
+ const resolved = path.isAbsolute(target)
56
+ ? target
57
+ : path.resolve(baseDir, target);
58
+ return resolved.replace(/\\/g, "/");
59
+ }
60
+
61
+ /**
62
+ * The `paths` object found while walking one tsconfig's `extends` chain,
63
+ * together with the directory of the config that declared it (the anchor for
64
+ * relative targets).
65
+ */
66
+ interface IDeclaredPaths {
67
+ baseDir: string;
68
+ paths: Record<string, unknown>;
69
+ }
70
+
71
+ /**
72
+ * Locate the nearest `compilerOptions.paths` declaration in the `extends` chain
73
+ * rooted at `tsconfig`. The own config wins over its bases; within an `extends`
74
+ * array, later entries win over earlier ones. `seen` breaks circular chains —
75
+ * the compiler reports the actual config error.
76
+ */
77
+ function findDeclaredPaths(
78
+ tsconfig: string,
79
+ seen: Set<string>,
80
+ ): IDeclaredPaths | null {
81
+ const canonical = resolveRealPath(tsconfig);
82
+ if (seen.has(canonical)) {
83
+ return null;
84
+ }
85
+ seen.add(canonical);
86
+
87
+ let parsed: { extends?: unknown; compilerOptions?: { paths?: unknown } };
88
+ try {
89
+ parsed = parseJsonc(fs.readFileSync(canonical, "utf8")) as typeof parsed;
90
+ } catch {
91
+ return null;
92
+ }
93
+ if (typeof parsed !== "object" || parsed === null) {
94
+ return null;
95
+ }
96
+
97
+ const own = parsed.compilerOptions?.paths;
98
+ if (typeof own === "object" && own !== null && !Array.isArray(own)) {
99
+ return {
100
+ baseDir: path.dirname(canonical),
101
+ paths: own as Record<string, unknown>,
102
+ };
103
+ }
104
+
105
+ for (const specifier of extendsSpecifiers(parsed.extends).reverse()) {
106
+ const base = resolveExtendsConfig(canonical, specifier);
107
+ if (base === null) {
108
+ continue;
109
+ }
110
+ const declared = findDeclaredPaths(base, seen);
111
+ if (declared !== null) {
112
+ return declared;
113
+ }
114
+ }
115
+ return null;
116
+ }
117
+
118
+ /** Normalize the `extends` field into a list of string specifiers. */
119
+ function extendsSpecifiers(extended: unknown): string[] {
120
+ if (typeof extended === "string") {
121
+ return [extended];
122
+ }
123
+ if (Array.isArray(extended)) {
124
+ return extended.filter(
125
+ (entry): entry is string => typeof entry === "string",
126
+ );
127
+ }
128
+ return [];
129
+ }
130
+
131
+ /**
132
+ * Resolve an `extends` specifier to an absolute config path using TypeScript's
133
+ * rules: absolute paths and relative specifiers get `.json` /
134
+ * `tsconfig.json`-directory fallbacks; bare specifiers go through Node's module
135
+ * resolver scoped to the declaring config. Returns `null` instead of throwing —
136
+ * the compiler reports unresolvable `extends` itself.
137
+ */
138
+ function resolveExtendsConfig(
139
+ tsconfig: string,
140
+ specifier: string,
141
+ ): string | null {
142
+ if (path.isAbsolute(specifier)) {
143
+ return resolveExistingExtendsPath(specifier);
144
+ }
145
+ if (isRelativeSpecifier(specifier)) {
146
+ return resolveExistingExtendsPath(
147
+ path.resolve(path.dirname(tsconfig), specifier),
148
+ );
149
+ }
150
+ const resolver = createRequire(tsconfig);
151
+ try {
152
+ return resolveRealPath(resolver.resolve(specifier));
153
+ } catch {
154
+ try {
155
+ return resolveRealPath(resolver.resolve(`${specifier}.json`));
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Try an on-disk `extends` location as-is, with `.json` appended, and as a
164
+ * directory containing `tsconfig.json`. Returns the first existing match.
165
+ */
166
+ function resolveExistingExtendsPath(location: string): string | null {
167
+ for (const candidate of new Set([
168
+ location,
169
+ `${location}.json`,
170
+ path.join(location, "tsconfig.json"),
171
+ ])) {
172
+ if (fs.existsSync(candidate)) {
173
+ return resolveRealPath(candidate);
174
+ }
175
+ }
176
+ return null;
177
+ }
178
+
179
+ /**
180
+ * Return true when `specifier` is a relative path reference: `.`, `..`, or a
181
+ * string starting with `./`, `../`, `.\\`, or `..\\`.
182
+ */
183
+ function isRelativeSpecifier(specifier: string): boolean {
184
+ return (
185
+ specifier === "." ||
186
+ specifier === ".." ||
187
+ specifier.startsWith("./") ||
188
+ specifier.startsWith("../") ||
189
+ specifier.startsWith(".\\") ||
190
+ specifier.startsWith("..\\")
191
+ );
192
+ }
193
+
194
+ /**
195
+ * Resolve symlinks on `location`, returning the original path when
196
+ * `realpathSync` fails (e.g. when the file does not exist).
197
+ */
198
+ function resolveRealPath(location: string): string {
199
+ try {
200
+ return fs.realpathSync(location);
201
+ } catch {
202
+ return location;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Parse a JSONC (JSON with Comments) string by stripping comments and trailing
208
+ * commas before handing off to `JSON.parse`. A leading UTF-8 BOM is dropped —
209
+ * `JSON.parse` rejects it, and this reader must not lose `paths` for a config
210
+ * the compiler accepts.
211
+ */
212
+ function parseJsonc(input: string): unknown {
213
+ const text = input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
214
+ return JSON.parse(stripTrailingCommas(stripComments(text)));
215
+ }
216
+
217
+ /**
218
+ * Remove `//` line comments and `/* block comments *\/` from a JSONC string.
219
+ * Correctly handles strings that contain comment-like character sequences by
220
+ * tracking string boundaries and escape characters.
221
+ */
222
+ function stripComments(input: string): string {
223
+ let output = "";
224
+ let inBlockComment = false;
225
+ let inLineComment = false;
226
+ let inString = false;
227
+ let quote = "";
228
+ let escape = false;
229
+
230
+ for (let i = 0; i < input.length; i += 1) {
231
+ const current = input[i]!;
232
+ const next = input[i + 1];
233
+
234
+ if (inBlockComment) {
235
+ if (current === "*" && next === "/") {
236
+ inBlockComment = false;
237
+ i += 1;
238
+ }
239
+ continue;
240
+ }
241
+ if (inLineComment) {
242
+ if (current === "\n") {
243
+ inLineComment = false;
244
+ output += current;
245
+ }
246
+ continue;
247
+ }
248
+ if (inString) {
249
+ output += current;
250
+ if (escape) {
251
+ escape = false;
252
+ } else if (current === "\\") {
253
+ escape = true;
254
+ } else if (current === quote) {
255
+ inString = false;
256
+ quote = "";
257
+ }
258
+ continue;
259
+ }
260
+
261
+ if (current === '"' || current === "'") {
262
+ inString = true;
263
+ quote = current;
264
+ output += current;
265
+ continue;
266
+ }
267
+ if (current === "/" && next === "/") {
268
+ inLineComment = true;
269
+ i += 1;
270
+ continue;
271
+ }
272
+ if (current === "/" && next === "*") {
273
+ inBlockComment = true;
274
+ i += 1;
275
+ continue;
276
+ }
277
+ output += current;
278
+ }
279
+ return output;
280
+ }
281
+
282
+ /**
283
+ * Remove trailing commas before `}` or `]` from a JSON string (after comments
284
+ * have already been stripped). Handles string boundaries and escape characters
285
+ * to avoid removing commas inside string values.
286
+ */
287
+ function stripTrailingCommas(input: string): string {
288
+ let output = "";
289
+ let inString = false;
290
+ let quote = "";
291
+ let escape = false;
292
+
293
+ for (let i = 0; i < input.length; i += 1) {
294
+ const current = input[i]!;
295
+ if (inString) {
296
+ output += current;
297
+ if (escape) {
298
+ escape = false;
299
+ } else if (current === "\\") {
300
+ escape = true;
301
+ } else if (current === quote) {
302
+ inString = false;
303
+ quote = "";
304
+ }
305
+ continue;
306
+ }
307
+
308
+ if (current === '"' || current === "'") {
309
+ inString = true;
310
+ quote = current;
311
+ output += current;
312
+ continue;
313
+ }
314
+ if (current === ",") {
315
+ const next = nextNonWhitespace(input, i + 1);
316
+ if (next === "}" || next === "]") {
317
+ continue;
318
+ }
319
+ }
320
+ output += current;
321
+ }
322
+ return output;
323
+ }
324
+
325
+ /**
326
+ * Return the first non-whitespace character at or after position `from` in
327
+ * `input`, or `undefined` when only whitespace remains. Used by
328
+ * `stripTrailingCommas` to detect whether a comma is trailing.
329
+ */
330
+ function nextNonWhitespace(input: string, from: number): string | undefined {
331
+ for (let i = from; i < input.length; i += 1) {
332
+ const current = input[i]!;
333
+ if (/\s/.test(current) === false) {
334
+ return current;
335
+ }
336
+ }
337
+ return undefined;
338
+ }