@ttsc/playground 0.18.3 → 0.19.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,9 +1,6 @@
1
1
  import { BUILT_IN_PLAYGROUND_PACKAGES } from "./BUILT_IN_PLAYGROUND_PACKAGES";
2
2
  import { packageNameFromSpecifier } from "./packageNameFromSpecifier";
3
3
 
4
- const MODULE_SPECIFIER_REGEXP =
5
- /\b(?:import|export)\s+(?:type\s+)?(?:[^"'()]*?\s+from\s*)?["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']\s*\)|require\s*\(\s*["']([^"']+)["']\s*\)/g;
6
-
7
4
  /**
8
5
  * Scan `source` for `import` / `require` specifiers and return the unique
9
6
  * sorted list of bare npm package names that are not in `ignoredPackages`.
@@ -21,14 +18,270 @@ export function collectExternalPackageNames(
21
18
  return [...found].sort();
22
19
  }
23
20
 
21
+ /**
22
+ * Collect the string specifiers of every executable module-loading construct in
23
+ * `source`: `import`/`export ... from`, side-effect `import "x"`, dynamic
24
+ * `import("x")`, and `require("x")` calls.
25
+ *
26
+ * The scan tokenizes `source` first so import/export/require lookalikes that
27
+ * live inside comments, string or template contents, or regular-expression
28
+ * literals never become package requests — only real code-level string
29
+ * arguments are returned. Specifiers that cannot be resolved statically (a
30
+ * template-literal or computed argument) are intentionally skipped so inert
31
+ * text cannot drive a network install.
32
+ */
24
33
  function collectModuleSpecifiers(source: string): string[] {
34
+ const tokens = tokenize(source);
25
35
  const out: string[] = [];
26
- MODULE_SPECIFIER_REGEXP.lastIndex = 0;
27
- for (;;) {
28
- const match = MODULE_SPECIFIER_REGEXP.exec(source);
29
- if (!match) break;
30
- const specifier = match[1] ?? match[2] ?? match[3];
31
- if (specifier) out.push(specifier);
36
+ const asString = (token: Token | undefined): string | null =>
37
+ token && token.kind === "string" ? token.value : null;
38
+ const isOpenParen = (token: Token | undefined): boolean =>
39
+ token !== undefined && token.kind === "punct" && token.value === "(";
40
+ const isMemberAccess = (token: Token | undefined): boolean =>
41
+ token !== undefined && token.kind === "punct" && token.value === ".";
42
+
43
+ for (let i = 0; i < tokens.length; i++) {
44
+ const token = tokens[i];
45
+ if (!token || token.kind !== "word") continue;
46
+
47
+ if (token.value === "require") {
48
+ // `obj.require(...)` is an unrelated method call, not CommonJS require.
49
+ if (isMemberAccess(tokens[i - 1])) continue;
50
+ if (isOpenParen(tokens[i + 1])) {
51
+ const spec = asString(tokens[i + 2]);
52
+ if (spec !== null) out.push(spec);
53
+ }
54
+ continue;
55
+ }
56
+
57
+ if (token.value === "import" || token.value === "export") {
58
+ // `foo.import(...)` / `import.meta` are not module-loading imports.
59
+ if (token.value === "import" && isMemberAccess(tokens[i - 1])) continue;
60
+ // Dynamic `import("x")`.
61
+ if (token.value === "import" && isOpenParen(tokens[i + 1])) {
62
+ const spec = asString(tokens[i + 2]);
63
+ if (spec !== null) out.push(spec);
64
+ continue;
65
+ }
66
+ // Side-effect `import "x"`.
67
+ if (token.value === "import") {
68
+ const bare = asString(tokens[i + 1]);
69
+ if (bare !== null) {
70
+ out.push(bare);
71
+ continue;
72
+ }
73
+ }
74
+ // `import ... from "x"` / `export ... from "x"`.
75
+ const spec = findFromSpecifier(tokens, i + 1);
76
+ if (spec !== null) out.push(spec);
77
+ }
32
78
  }
33
79
  return out;
34
80
  }
81
+
82
+ /**
83
+ * From token index `start`, find the specifier of a `... from "x"` clause,
84
+ * bounded to the current statement. Stops at a `;` terminator or the start of
85
+ * another `import`/`export` so a local `export const x = ...` never borrows a
86
+ * later statement's `from`.
87
+ */
88
+ function findFromSpecifier(tokens: Token[], start: number): string | null {
89
+ for (let i = start; i < tokens.length; i++) {
90
+ const token = tokens[i];
91
+ if (!token) break;
92
+ if (token.kind === "punct" && token.value === ";") return null;
93
+ if (
94
+ token.kind === "word" &&
95
+ (token.value === "import" || token.value === "export")
96
+ )
97
+ return null;
98
+ if (token.kind === "word" && token.value === "from") {
99
+ const next = tokens[i + 1];
100
+ return next && next.kind === "string" ? next.value : null;
101
+ }
102
+ }
103
+ return null;
104
+ }
105
+
106
+ type Token =
107
+ // An identifier or keyword.
108
+ | { kind: "word"; value: string }
109
+ // A single- or double-quoted string literal, with escapes decoded to their
110
+ // literal characters so a specifier survives unchanged.
111
+ | { kind: "string"; value: string }
112
+ // A single punctuation character.
113
+ | { kind: "punct"; value: string }
114
+ // An opaque value token — number, template literal, or regular-expression
115
+ // literal — whose contents can never be a static specifier.
116
+ | { kind: "other" };
117
+
118
+ // Keywords after which a `/` begins a regular-expression literal rather than a
119
+ // division operator. After any other word (an identifier or value keyword such
120
+ // as `this`), `/` is division.
121
+ const REGEX_PRECEDING_KEYWORDS = new Set([
122
+ "return",
123
+ "typeof",
124
+ "instanceof",
125
+ "in",
126
+ "of",
127
+ "new",
128
+ "delete",
129
+ "void",
130
+ "do",
131
+ "else",
132
+ "yield",
133
+ "await",
134
+ "case",
135
+ "throw",
136
+ ]);
137
+
138
+ /**
139
+ * Lexically tokenize `source` into the coarse token stream the specifier
140
+ * collector needs. Comments are dropped; strings, templates, regex literals,
141
+ * and numbers become single tokens so their contents cannot leak into the
142
+ * grammar match.
143
+ */
144
+ function tokenize(source: string): Token[] {
145
+ const tokens: Token[] = [];
146
+ const n = source.length;
147
+ const isIdStart = (c: string): boolean =>
148
+ (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || c === "_" || c === "$";
149
+ const isIdPart = (c: string): boolean =>
150
+ isIdStart(c) || (c >= "0" && c <= "9");
151
+ const isDigit = (c: string): boolean => c >= "0" && c <= "9";
152
+
153
+ // A `/` opens a regex only in operator/statement position — never right after
154
+ // a value (identifier, number, string, template, regex, `)` or `]`).
155
+ const regexAllowed = (): boolean => {
156
+ const prev = tokens[tokens.length - 1];
157
+ if (!prev) return true;
158
+ if (prev.kind === "string" || prev.kind === "other") return false;
159
+ if (prev.kind === "word") return REGEX_PRECEDING_KEYWORDS.has(prev.value);
160
+ return prev.value !== ")" && prev.value !== "]";
161
+ };
162
+
163
+ let i = 0;
164
+ while (i < n) {
165
+ const c = source[i]!;
166
+ // Whitespace.
167
+ if (
168
+ c === " " ||
169
+ c === "\t" ||
170
+ c === "\r" ||
171
+ c === "\n" ||
172
+ c === "\f" ||
173
+ c === "\v"
174
+ ) {
175
+ i++;
176
+ continue;
177
+ }
178
+ // Line comment.
179
+ if (c === "/" && source[i + 1] === "/") {
180
+ i += 2;
181
+ while (i < n && source[i] !== "\n") i++;
182
+ continue;
183
+ }
184
+ // Block comment.
185
+ if (c === "/" && source[i + 1] === "*") {
186
+ i += 2;
187
+ while (i < n && !(source[i] === "*" && source[i + 1] === "/")) i++;
188
+ i += 2;
189
+ continue;
190
+ }
191
+ // Regular-expression literal.
192
+ if (c === "/" && regexAllowed()) {
193
+ i++;
194
+ let inClass = false;
195
+ while (i < n) {
196
+ const d = source[i];
197
+ if (d === "\\") {
198
+ i += 2;
199
+ continue;
200
+ }
201
+ if (d === "\n") break;
202
+ if (d === "[") inClass = true;
203
+ else if (d === "]") inClass = false;
204
+ else if (d === "/" && !inClass) {
205
+ i++;
206
+ break;
207
+ }
208
+ i++;
209
+ }
210
+ while (i < n && isIdPart(source[i]!)) i++; // flags
211
+ tokens.push({ kind: "other" });
212
+ continue;
213
+ }
214
+ // String literal.
215
+ if (c === '"' || c === "'") {
216
+ i++;
217
+ let value = "";
218
+ while (i < n) {
219
+ const d = source[i]!;
220
+ if (d === "\\") {
221
+ value += source[i + 1] ?? "";
222
+ i += 2;
223
+ continue;
224
+ }
225
+ if (d === c) {
226
+ i++;
227
+ break;
228
+ }
229
+ if (d === "\n") break; // unterminated single-line string
230
+ value += d;
231
+ i++;
232
+ }
233
+ tokens.push({ kind: "string", value });
234
+ continue;
235
+ }
236
+ // Template literal. Contents (including `${...}` expressions) are treated as
237
+ // opaque: a template specifier is not statically resolvable.
238
+ if (c === "`") {
239
+ i++;
240
+ let depth = 0;
241
+ while (i < n) {
242
+ const d = source[i];
243
+ if (d === "\\") {
244
+ i += 2;
245
+ continue;
246
+ }
247
+ if (depth === 0 && d === "`") {
248
+ i++;
249
+ break;
250
+ }
251
+ if (d === "$" && source[i + 1] === "{") {
252
+ depth++;
253
+ i += 2;
254
+ continue;
255
+ }
256
+ if (depth > 0 && d === "}") {
257
+ depth--;
258
+ i++;
259
+ continue;
260
+ }
261
+ i++;
262
+ }
263
+ tokens.push({ kind: "other" });
264
+ continue;
265
+ }
266
+ // Identifier / keyword.
267
+ if (isIdStart(c)) {
268
+ let j = i + 1;
269
+ while (j < n && isIdPart(source[j]!)) j++;
270
+ tokens.push({ kind: "word", value: source.slice(i, j) });
271
+ i = j;
272
+ continue;
273
+ }
274
+ // Numeric literal.
275
+ if (isDigit(c) || (c === "." && isDigit(source[i + 1] ?? ""))) {
276
+ let j = i + 1;
277
+ while (j < n && /[0-9a-fA-FxXoObBeE._]/.test(source[j]!)) j++;
278
+ tokens.push({ kind: "other" });
279
+ i = j;
280
+ continue;
281
+ }
282
+ // Single punctuation character.
283
+ tokens.push({ kind: "punct", value: c });
284
+ i++;
285
+ }
286
+ return tokens;
287
+ }
@@ -64,15 +64,13 @@ export function createSandboxRequire(
64
64
  return null;
65
65
  }
66
66
  if (subpath === null) {
67
- // bare "name": honor exports["."] string, else main, else index.
68
- const exportsAny = pj.exports;
69
- if (typeof exportsAny === "object" && exportsAny !== null) {
70
- const root = (exportsAny as Record<string, unknown>)["."];
71
- const r = pickConditionalExport(root);
72
- if (r) {
73
- const resolved = `${pkg}/${stripDotSlash(r)}`;
74
- return has(resolved) ? resolved : null;
75
- }
67
+ // bare "name": honor the root `exports` entry (string, subpath table
68
+ // "." key, or bare condition map) → CJS target, else main, else index.
69
+ const root = rootExportTarget(pj.exports);
70
+ const r = pickConditionalExport(root);
71
+ if (r) {
72
+ const resolved = `${pkg}/${stripDotSlash(r)}`;
73
+ return has(resolved) ? resolved : null;
76
74
  }
77
75
  if (typeof pj.main === "string") {
78
76
  return tryPaths(
@@ -220,6 +218,31 @@ export function createSandboxRequire(
220
218
  };
221
219
  }
222
220
 
221
+ /**
222
+ * Reduce a `package.json` `exports` field to the value describing its ROOT
223
+ * (".") entry, ready for {@link pickConditionalExport}. Node accepts three valid
224
+ * root shapes and they must all resolve consistently:
225
+ *
226
+ * - A bare string target — `"exports": "./index.cjs"`;
227
+ * - A subpath table keyed by "." — `{ ".": <target>, "./sub": ... }`;
228
+ * - A bare condition map whose keys are all conditions, not subpaths — `{
229
+ * "require": "./index.cjs", "default": "./index.cjs" }`.
230
+ *
231
+ * Node forbids mixing subpath keys with condition keys, so the presence of any
232
+ * "."-prefixed key decides the interpretation: a subpath table exposes its root
233
+ * as `exports["."]`, while a condition map is itself the root target. Returns
234
+ * null when `exports` is absent, empty, or describes no root entry.
235
+ */
236
+ function rootExportTarget(exports: unknown): unknown {
237
+ if (typeof exports === "string") return exports;
238
+ if (!exports || typeof exports !== "object") return null;
239
+ const obj = exports as Record<string, unknown>;
240
+ const keys = Object.keys(obj);
241
+ if (keys.length === 0) return null;
242
+ const hasSubpathKey = keys.some((k) => k === "." || k.startsWith("./"));
243
+ return hasSubpathKey ? obj["."] : obj;
244
+ }
245
+
223
246
  function pickConditionalExport(value: unknown): string | null {
224
247
  if (typeof value === "string") return value;
225
248
  if (value && typeof value === "object") {