partial-react 0.0.5 → 0.0.7
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.
- package/package.json +5 -2
- package/src/compiler.ts +1 -7
- package/src/importMap.ts +477 -90
- package/src/importProbe.ts +4 -2
- package/src/nodeWasm.ts +11 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partial-react",
|
|
3
3
|
"description": "A state-preserving renderer for prefix-growing streaming React/TSX.",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.7",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -15,13 +15,16 @@
|
|
|
15
15
|
"./remote-module": "./src/remoteModule.ts",
|
|
16
16
|
"./render-context": "./src/renderContext.ts"
|
|
17
17
|
},
|
|
18
|
+
"browser": {
|
|
19
|
+
"./src/nodeWasm.ts": false
|
|
20
|
+
},
|
|
18
21
|
"files": [
|
|
19
22
|
"src/**/*",
|
|
20
23
|
"!src/**/*.test.*"
|
|
21
24
|
],
|
|
22
25
|
"dependencies": {
|
|
23
26
|
"@esm.sh/tsx": "^1.5.3",
|
|
24
|
-
"partial-tsx": "0.0.
|
|
27
|
+
"partial-tsx": "0.0.4"
|
|
25
28
|
},
|
|
26
29
|
"peerDependencies": {
|
|
27
30
|
"react": "^18.3.0 || ^19.0.0",
|
package/src/compiler.ts
CHANGED
|
@@ -7,16 +7,10 @@ export type CompileResult = { code: string; source: string; changed: boolean };
|
|
|
7
7
|
export type TsxCompiler = { compile: (code: string, options?: CompileOptions) => Promise<CompileResult> };
|
|
8
8
|
|
|
9
9
|
let initPromise: Promise<unknown> | null = null;
|
|
10
|
-
const loadNodeWasm = async () => {
|
|
11
|
-
// `import.meta.resolve` yields a `file://` URL under both Bun and Node/Vite SSR, so `new URL` is safe — don't "fix" it with pathToFileURL.
|
|
12
|
-
const wasmUrl = new URL(import.meta.resolve("@esm.sh/tsx/pkg/tsx_bg.wasm"));
|
|
13
|
-
if (typeof Bun !== "undefined") return Bun.file(wasmUrl).arrayBuffer();
|
|
14
|
-
return (await import("node:fs/promises")).readFile(wasmUrl);
|
|
15
|
-
};
|
|
16
10
|
const initCompiler = () =>
|
|
17
11
|
(initPromise ??= (async () => {
|
|
18
12
|
try {
|
|
19
|
-
if (typeof window === "undefined") return initTsx(await loadNodeWasm());
|
|
13
|
+
if (typeof window === "undefined") return initTsx(await (await import("./nodeWasm")).loadNodeWasm());
|
|
20
14
|
} catch {
|
|
21
15
|
// Environments without a filesystem (e.g. web workers) fall back to the bundled asset below.
|
|
22
16
|
}
|
package/src/importMap.ts
CHANGED
|
@@ -13,9 +13,30 @@ export type RuntimeSingletonSpecifier = (typeof RUNTIME_SINGLETON_SPECIFIERS)[nu
|
|
|
13
13
|
export const isRuntimeSingletonSpecifier = (specifier: string): specifier is RuntimeSingletonSpecifier => RUNTIME_SINGLETON_SPECIFIERS.includes(specifier as RuntimeSingletonSpecifier);
|
|
14
14
|
// A trailing-slash import-map key also owns matching singleton subpaths.
|
|
15
15
|
export const isRuntimeSingletonImportMapKey = (specifier: string) => isRuntimeSingletonSpecifier(specifier) || (specifier.endsWith("/") && RUNTIME_SINGLETON_SPECIFIERS.some((runtimeSpecifier) => runtimeSpecifier.startsWith(specifier)));
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
16
|
+
const IDENTIFIER_START_PATTERN = /[$_\p{ID_Start}]/u;
|
|
17
|
+
const IDENTIFIER_PART_PATTERN = /[$_\u200C\u200D\p{ID_Continue}]/u;
|
|
18
|
+
// Composed from the two above so widening either also widens JSX tag names; `.`/`:`/`-` extend the part set for member,
|
|
19
|
+
// namespaced and dashed tags. The empty alternative matches a fragment (`<>`).
|
|
20
|
+
const JSX_TAG_START_PATTERN = new RegExp(`<(/\\s*)?(${IDENTIFIER_START_PATTERN.source}(?:${IDENTIFIER_PART_PATTERN.source}|[.:-])*|(?=>))(?=[\\s/><])`, "uy");
|
|
21
|
+
const STRING_ESCAPE_PATTERN = /\\(?:x([\da-fA-F]{2})|u\{([\da-fA-F]+)\}|u([\da-fA-F]{4}))/y;
|
|
22
|
+
// Every escape the pattern above doesn't cover denotes the bare character (`\/` → `/`, `\\` → `\`) except these: the
|
|
23
|
+
// named control escapes, and a `\`-LineTerminator line continuation, which denotes nothing. Callers match this value
|
|
24
|
+
// against import-map keys, so returning the source spelling instead yields a third string that is neither the source
|
|
25
|
+
// nor what the runtime resolves — it matches no key, and `rewriteImportSpecifiers` leaves the specifier unrewritten.
|
|
26
|
+
// The escapes that are a syntax error in a module (`\1`-`\9`, `\0` before a digit, a malformed `\x`/`\u`) get no case:
|
|
27
|
+
// such a module never loads, so whatever they decode to names no reachable key. U+2028/U+2029 are spelled as escapes
|
|
28
|
+
// because raw they are invisible.
|
|
29
|
+
const STRING_ESCAPE_VALUES: Record<string, string> = { b: "\b", f: "\f", n: "\n", r: "\r", t: "\t", v: "\v", "0": "\0", "\n": "", "\r": "", "\u2028": "", "\u2029": "" };
|
|
30
|
+
// An escaped identifier character names one code point, so an astral name is spelled `\u{10400}`, never as the two
|
|
31
|
+
// escaped surrogate halves a string literal would pair — no pairing branch belongs here. A lone half is no ID_Start, so
|
|
32
|
+
// the scan stops and the clause falls back to the specifier alone, costing nothing: V8 and tsgo both reject that spelling.
|
|
33
|
+
const IDENTIFIER_ESCAPE_PATTERN = /\\u(?:\{([\da-fA-F]+)\}|([\da-fA-F]{4}))/y;
|
|
34
|
+
// `$` `_` `A-Z` `a-z` as code points (`0-9` joins them for continues, below): an ASCII fast path past
|
|
35
|
+
// IDENTIFIER_START_PATTERN/IDENTIFIER_PART_PATTERN, which answer the same but cost a regex exec per character. Widen
|
|
36
|
+
// alongside them.
|
|
37
|
+
const isAsciiIdentifierStart = (value: number) => value === 36 || value === 95 || (value >= 65 && value <= 90) || (value >= 97 && value <= 122);
|
|
38
|
+
const isIdentifierCodePoint = (value: number, first: boolean) => (value <= 0x7f ? isAsciiIdentifierStart(value) || (!first && value >= 48 && value <= 57) : (first ? IDENTIFIER_START_PATTERN : IDENTIFIER_PART_PATTERN).test(String.fromCodePoint(value)));
|
|
39
|
+
const EXPRESSION_PREFIX_WORDS = new Set(["return", "throw", "case", "delete", "typeof", "void", "new", "in", "of", "await", "yield", "default", "else", "do"]);
|
|
19
40
|
|
|
20
41
|
export const isBareModuleSpecifier = (specifier: string) => !specifier.startsWith(".") && !specifier.startsWith("/") && !/^(?:[a-z][a-z\d+.-]*:|#)/i.test(specifier);
|
|
21
42
|
export const getBarePackageName = (specifier: string) => (specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0] || specifier);
|
|
@@ -28,7 +49,7 @@ export const isSharedInstancePackage = (specifier: string) => SHARED_INSTANCE_PA
|
|
|
28
49
|
export const isLocalRootRelativeTarget = (target: string) => target.startsWith("/src/") || target.startsWith("/node_modules/");
|
|
29
50
|
const canUseEsmShFallback = (specifier: string) => isBareModuleSpecifier(specifier) && !specifier.startsWith("@/");
|
|
30
51
|
|
|
31
|
-
const isKeywordAt = (code: string, index: number, keyword: string) => code.startsWith(keyword, index) && !
|
|
52
|
+
const isKeywordAt = (code: string, index: number, keyword: string) => code.startsWith(keyword, index) && !IDENTIFIER_PART_PATTERN.test(code[index - 1] ?? "") && !IDENTIFIER_PART_PATTERN.test(code[index + keyword.length] ?? "");
|
|
32
53
|
|
|
33
54
|
const skipLineComment = (code: string, index: number) => {
|
|
34
55
|
const newlineIndex = code.indexOf("\n", index + 2);
|
|
@@ -40,18 +61,34 @@ const skipBlockComment = (code: string, index: number) => {
|
|
|
40
61
|
return closeIndex === -1 ? code.length : closeIndex + 2;
|
|
41
62
|
};
|
|
42
63
|
|
|
64
|
+
// Callers must have verified `/` at `index`; the second char picks the terminator.
|
|
65
|
+
const skipComment = (code: string, index: number) => (code[index + 1] === "/" ? skipLineComment(code, index) : skipBlockComment(code, index));
|
|
66
|
+
|
|
67
|
+
const skipScannedComment = (code: string, index: number, commentStarts: Map<number, number>) => {
|
|
68
|
+
const end = skipComment(code, index);
|
|
69
|
+
commentStarts.set(end, index);
|
|
70
|
+
return end;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Only rewind over comments the forward scan observed: searching backwards for `/*` can land inside comment text.
|
|
74
|
+
const previousCodeIndex = (code: string, end: number, commentStarts: ReadonlyMap<number, number>) => {
|
|
75
|
+
while (end > 0) {
|
|
76
|
+
const commentStart = commentStarts.get(end);
|
|
77
|
+
if (commentStart !== undefined) end = commentStart;
|
|
78
|
+
else if (/\s/.test(code[end - 1])) end -= 1;
|
|
79
|
+
else break;
|
|
80
|
+
}
|
|
81
|
+
return end - 1;
|
|
82
|
+
};
|
|
83
|
+
|
|
43
84
|
const skipWhitespaceAndComments = (code: string, index: number) => {
|
|
44
85
|
while (index < code.length) {
|
|
45
86
|
if (/\s/.test(code[index])) {
|
|
46
87
|
index += 1;
|
|
47
88
|
continue;
|
|
48
89
|
}
|
|
49
|
-
if (code[index] === "/" && code[index + 1] === "/") {
|
|
50
|
-
index =
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
53
|
-
if (code[index] === "/" && code[index + 1] === "*") {
|
|
54
|
-
index = skipBlockComment(code, index);
|
|
90
|
+
if (code[index] === "/" && (code[index + 1] === "/" || code[index + 1] === "*")) {
|
|
91
|
+
index = skipComment(code, index);
|
|
55
92
|
continue;
|
|
56
93
|
}
|
|
57
94
|
break;
|
|
@@ -67,8 +104,27 @@ const readStringLiteral = (code: string, index: number) => {
|
|
|
67
104
|
while (index < code.length) {
|
|
68
105
|
const char = code[index];
|
|
69
106
|
if (char === "\\") {
|
|
70
|
-
|
|
71
|
-
|
|
107
|
+
// Mirrors STRING_ESCAPE_PATTERN's alternation heads: skipping exec on the common escapes (`\n`, `\"`, `\\`) is
|
|
108
|
+
// ~1.6x faster on escape-dense literals. Widen this when widening the pattern.
|
|
109
|
+
const next = code[index + 1];
|
|
110
|
+
let escape: RegExpExecArray | null = null;
|
|
111
|
+
if (next === "x" || next === "u") {
|
|
112
|
+
STRING_ESCAPE_PATTERN.lastIndex = index;
|
|
113
|
+
escape = STRING_ESCAPE_PATTERN.exec(code);
|
|
114
|
+
}
|
|
115
|
+
if (escape) {
|
|
116
|
+
const point = Number.parseInt(escape[1] ?? escape[2] ?? escape[3], 16);
|
|
117
|
+
if (point <= 0x10ffff) {
|
|
118
|
+
value += String.fromCodePoint(point);
|
|
119
|
+
index += escape[0].length;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// A lone trailing backslash has no next character to stand for, and `?? next` would append `undefined` verbatim.
|
|
124
|
+
if (next === undefined) break;
|
|
125
|
+
value += STRING_ESCAPE_VALUES[next] ?? next;
|
|
126
|
+
// `\` + CRLF is one line continuation, not a continuation followed by a stray `\n`.
|
|
127
|
+
index += next === "\r" && code[index + 2] === "\n" ? 3 : 2;
|
|
72
128
|
continue;
|
|
73
129
|
}
|
|
74
130
|
if (char === quote) return { value, end: index + 1, terminated: true };
|
|
@@ -80,7 +136,9 @@ const readStringLiteral = (code: string, index: number) => {
|
|
|
80
136
|
return { value, end: code.length, terminated: false };
|
|
81
137
|
};
|
|
82
138
|
|
|
83
|
-
|
|
139
|
+
type JsxScanCache = Map<number, { end: number; expressions: [number, number][] }>;
|
|
140
|
+
|
|
141
|
+
const skipTemplateLiteral = (code: string, index: number, jsxScanCache: JsxScanCache, onExpression?: (start: number, end: number) => void) => {
|
|
84
142
|
index += 1;
|
|
85
143
|
while (index < code.length) {
|
|
86
144
|
if (code[index] === "\\") {
|
|
@@ -90,7 +148,7 @@ const skipTemplateLiteral = (code: string, index: number, onExpression?: (start:
|
|
|
90
148
|
if (code[index] === "`") return index + 1;
|
|
91
149
|
if (code[index] === "$" && code[index + 1] === "{") {
|
|
92
150
|
const expressionStart = index + 2;
|
|
93
|
-
const expressionEnd = findBalancedBlockEnd(code, index + 1, "{", "}");
|
|
151
|
+
const expressionEnd = findBalancedBlockEnd(code, index + 1, "{", "}", jsxScanCache);
|
|
94
152
|
onExpression?.(expressionStart, Math.max(expressionStart, expressionEnd - 1));
|
|
95
153
|
index = expressionEnd;
|
|
96
154
|
continue;
|
|
@@ -100,19 +158,25 @@ const skipTemplateLiteral = (code: string, index: number, onExpression?: (start:
|
|
|
100
158
|
return code.length;
|
|
101
159
|
};
|
|
102
160
|
|
|
103
|
-
const findBalancedBlockEnd = (code: string, index: number, opener: string, closer: string) => {
|
|
161
|
+
const findBalancedBlockEnd = (code: string, index: number, opener: string, closer: string, jsxScanCache: JsxScanCache) => {
|
|
104
162
|
let depth = 0;
|
|
163
|
+
const commentStarts = new Map<number, number>();
|
|
164
|
+
const openParens: number[] = [];
|
|
165
|
+
let closedParen: number | undefined;
|
|
105
166
|
while (index < code.length) {
|
|
106
167
|
const char = code[index];
|
|
107
|
-
if (char === "/" && code[index + 1] === "/") {
|
|
108
|
-
index =
|
|
168
|
+
if (char === "/" && (code[index + 1] === "/" || code[index + 1] === "*")) {
|
|
169
|
+
index = skipScannedComment(code, index, commentStarts);
|
|
109
170
|
continue;
|
|
110
171
|
}
|
|
111
|
-
if (char === "
|
|
112
|
-
|
|
113
|
-
|
|
172
|
+
if (char === "<" && opener !== "<" && isLikelyJsxStart(code, index, closedParen, commentStarts)) {
|
|
173
|
+
const jsxEnd = skipJsxElement(code, index, jsxScanCache);
|
|
174
|
+
if (jsxEnd > index) {
|
|
175
|
+
index = jsxEnd;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
114
178
|
}
|
|
115
|
-
if (isLikelyRegexLiteralStart(code, index)) {
|
|
179
|
+
if (char === "/" && isLikelyRegexLiteralStart(code, index, commentStarts)) {
|
|
116
180
|
index = skipRegexLiteral(code, index);
|
|
117
181
|
continue;
|
|
118
182
|
}
|
|
@@ -121,9 +185,11 @@ const findBalancedBlockEnd = (code: string, index: number, opener: string, close
|
|
|
121
185
|
continue;
|
|
122
186
|
}
|
|
123
187
|
if (char === "`") {
|
|
124
|
-
index = skipTemplateLiteral(code, index);
|
|
188
|
+
index = skipTemplateLiteral(code, index, jsxScanCache);
|
|
125
189
|
continue;
|
|
126
190
|
}
|
|
191
|
+
if (char === "(") openParens.push(index);
|
|
192
|
+
else if (char === ")") closedParen = openParens.pop();
|
|
127
193
|
if (char === opener) depth += 1;
|
|
128
194
|
if (char === closer) {
|
|
129
195
|
depth -= 1;
|
|
@@ -134,17 +200,19 @@ const findBalancedBlockEnd = (code: string, index: number, opener: string, close
|
|
|
134
200
|
return code.length;
|
|
135
201
|
};
|
|
136
202
|
|
|
137
|
-
const isLikelyRegexLiteralStart = (code: string, index: number) => {
|
|
203
|
+
const isLikelyRegexLiteralStart = (code: string, index: number, commentStarts: ReadonlyMap<number, number>) => {
|
|
138
204
|
if (code[index] !== "/" || code[index + 1] === "/" || code[index + 1] === "*") return false;
|
|
139
|
-
|
|
140
|
-
while (previous >= 0 && /\s/.test(code[previous])) previous -= 1;
|
|
205
|
+
const previous = previousCodeIndex(code, index, commentStarts);
|
|
141
206
|
if (previous < 0) return true;
|
|
142
207
|
const previousChar = code[previous];
|
|
208
|
+
// An adjacent `</` opens a JSX closing tag, not a regex after `<` — a real `a < /re/` always has whitespace. Consuming
|
|
209
|
+
// the tag as a regex swallows every import after it.
|
|
210
|
+
if (previous === index - 1 && previousChar === "<") return false;
|
|
143
211
|
if ("([{=,:;!&|?+-*~^<>".includes(previousChar)) return true;
|
|
144
212
|
if (!/[A-Za-z_$]/.test(previousChar)) return false;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return
|
|
213
|
+
const { word, start } = wordEndingAt(code, previous + 1, commentStarts);
|
|
214
|
+
// Keywords also name properties: `object.default / 2` is division, including optional chains and intervening comments.
|
|
215
|
+
return EXPRESSION_PREFIX_WORDS.has(word) && ![".", "#"].includes(code[previousCodeIndex(code, start, commentStarts)]);
|
|
148
216
|
};
|
|
149
217
|
|
|
150
218
|
const skipRegexLiteral = (code: string, index: number) => {
|
|
@@ -167,19 +235,143 @@ const skipRegexLiteral = (code: string, index: number) => {
|
|
|
167
235
|
return code.length;
|
|
168
236
|
};
|
|
169
237
|
|
|
170
|
-
const
|
|
238
|
+
const STATEMENT_CONDITION_WORDS = new Set(["if", "while", "for", "with", "switch", "catch"]);
|
|
239
|
+
|
|
240
|
+
// What separates a keyword from the token after it can be a comment, not just whitespace (`if /* c */ (`), so rewind
|
|
241
|
+
// over the comment spans the forward scan recorded: stopping on the `*/` yields no word at all, losing the keyword.
|
|
242
|
+
const wordEndingAt = (code: string, end: number, commentStarts: ReadonlyMap<number, number>) => {
|
|
243
|
+
end = previousCodeIndex(code, end, commentStarts) + 1;
|
|
244
|
+
let start = end;
|
|
245
|
+
// Consume whole code points and braced escapes so an identifier prefix cannot leave a keyword-looking suffix behind.
|
|
246
|
+
while (start > 0) {
|
|
247
|
+
if (code[start - 1] === "}") {
|
|
248
|
+
let cursor = start - 2;
|
|
249
|
+
while (cursor >= 0 && /[\da-fA-F]/.test(code[cursor])) cursor -= 1;
|
|
250
|
+
if (code.slice(cursor - 2, cursor + 1) === "\\u{") {
|
|
251
|
+
start = cursor - 2;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const previous = start - (code.charCodeAt(start - 1) >= 0xdc00 && code.charCodeAt(start - 1) <= 0xdfff ? 2 : 1);
|
|
256
|
+
if (!IDENTIFIER_PART_PATTERN.test(code.slice(previous, start))) break;
|
|
257
|
+
start = previous;
|
|
258
|
+
}
|
|
259
|
+
return { word: code.slice(start, end), start };
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// Both JavaScript scan loops pair parens while skipping literals, comments and JSX. Rewinding from `)` can start
|
|
263
|
+
// inside one of those constructs and lose later imports; using the observed opener also avoids rescanning conditions.
|
|
264
|
+
const isStatementConditionOpen = (code: string, open: number | undefined, commentStarts: ReadonlyMap<number, number>) => {
|
|
265
|
+
if (open === undefined) return false;
|
|
266
|
+
let preceding = wordEndingAt(code, open, commentStarts);
|
|
267
|
+
// `for await (` is the only production putting a word between the controlling keyword and its `(`, so one step is enough.
|
|
268
|
+
if (preceding.word === "await") preceding = wordEndingAt(code, preceding.start, commentStarts);
|
|
269
|
+
return STATEMENT_CONDITION_WORDS.has(preceding.word);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const isLikelyJsxStart = (code: string, index: number, closedParen: number | undefined, commentStarts: ReadonlyMap<number, number>) => {
|
|
273
|
+
let previous = index - 1;
|
|
274
|
+
while (previous >= 0 && /\s/.test(code[previous])) {
|
|
275
|
+
// Deliberately optimistic, and the widest edge of this heuristic: a preceding newline admits every multi-line
|
|
276
|
+
// non-JSX `<` too (a split generic call, a comparison chain, a wrapped type annotation), each paying an element
|
|
277
|
+
// scan that fails. Results stay right because `skipJsxElement` returns `start` on failure, and affordable only
|
|
278
|
+
// because `jsxScanCache` memoizes that failure — narrowing this branch would break ordinary formatted JSX.
|
|
279
|
+
if (code[previous] === "\n") return true;
|
|
280
|
+
previous -= 1;
|
|
281
|
+
}
|
|
282
|
+
// `call()<p>` is a comparison-like expression, not JSX. `jsxScanCache` cannot absorb this the way it does the newline
|
|
283
|
+
// branch — each `)<` sits at a distinct start — so deleting the guard takes `a=f()<div a={f()};` quadratic (n=500..4000:
|
|
284
|
+
// 13.7 -> 817.7 ms, versus 1.4 -> 11.2 ms). It also changes results: TS reads `<` after a call as relational, so the
|
|
285
|
+
// `import` in `f()<div>{import("x")}</div>` is live code. `)` only — `]` would regress `xs[0]<div>` to inert JSX text.
|
|
286
|
+
// Keep statement-position JSX (`if(ready)<p>`) working by recognizing the controlling keyword before the paren.
|
|
287
|
+
if (previous === index - 1 && code[previous] === ")" && !isStatementConditionOpen(code, closedParen, commentStarts)) return false;
|
|
288
|
+
// Exclude type arguments (`Promise<T>`, `call<T>()`) before attempting a potentially long element scan.
|
|
289
|
+
if (previous < 0 || !IDENTIFIER_PART_PATTERN.test(code[previous])) return true;
|
|
290
|
+
return EXPRESSION_PREFIX_WORDS.has(wordEndingAt(code, previous + 1, commentStarts).word);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// JSX text is not JavaScript: quotes and import-looking prose are inert, but attribute/child expressions still contain
|
|
294
|
+
// real imports. Only commit a complete element; otherwise a TS angle-bracket assertion could consume the rest of a file.
|
|
295
|
+
const skipJsxElement = (code: string, start: number, jsxScanCache: JsxScanCache, onExpression?: (start: number, end: number) => void): number => {
|
|
296
|
+
const cached = jsxScanCache.get(start);
|
|
297
|
+
if (cached) {
|
|
298
|
+
for (const [expressionStart, expressionEnd] of cached.expressions) onExpression?.(expressionStart, expressionEnd);
|
|
299
|
+
return cached.end;
|
|
300
|
+
}
|
|
301
|
+
let index = start;
|
|
302
|
+
const tags: { name: string; start: number }[] = [];
|
|
303
|
+
const expressions: [number, number][] = [];
|
|
304
|
+
const fail = () => {
|
|
305
|
+
// Every still-open ancestor would fail on the same suffix. Cache all of them, not just the outer start, so a
|
|
306
|
+
// streamed stack of unclosed elements does not rescan the tail once per nesting level. The cache is per input.
|
|
307
|
+
jsxScanCache.set(start, { end: start, expressions: [] });
|
|
308
|
+
for (const tag of tags) jsxScanCache.set(tag.start, { end: tag.start, expressions: [] });
|
|
309
|
+
return start;
|
|
310
|
+
};
|
|
311
|
+
const skipExpression = (at: number) => {
|
|
312
|
+
const end = findBalancedBlockEnd(code, at, "{", "}", jsxScanCache);
|
|
313
|
+
expressions.push([at + 1, Math.max(at + 1, end - 1)]);
|
|
314
|
+
return end;
|
|
315
|
+
};
|
|
171
316
|
while (index < code.length) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (char === "/" && code[index + 1] === "/") {
|
|
175
|
-
index = skipLineComment(code, index);
|
|
317
|
+
if (code[index] === "{" && tags.length) {
|
|
318
|
+
index = skipExpression(index);
|
|
176
319
|
continue;
|
|
177
320
|
}
|
|
178
|
-
if (
|
|
179
|
-
index
|
|
321
|
+
if (code[index] !== "<") {
|
|
322
|
+
index += 1;
|
|
180
323
|
continue;
|
|
181
324
|
}
|
|
182
|
-
|
|
325
|
+
JSX_TAG_START_PATTERN.lastIndex = index;
|
|
326
|
+
const tag = JSX_TAG_START_PATTERN.exec(code);
|
|
327
|
+
if (!tag) {
|
|
328
|
+
if (!tags.length) return fail();
|
|
329
|
+
index += 1;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const closing = !!tag[1];
|
|
333
|
+
if (closing && tags.at(-1)?.name !== tag[2]) return fail();
|
|
334
|
+
const tagStart = index;
|
|
335
|
+
index += tag[0].length;
|
|
336
|
+
// Generic arrows use the same expression position as JSX, but `extends`/`=` start type parameters, not attributes.
|
|
337
|
+
const afterName = skipWhitespaceAndComments(code, index);
|
|
338
|
+
if (!tags.length && (code[afterName] === "=" || isKeywordAt(code, afterName, "extends"))) return fail();
|
|
339
|
+
while (index < code.length && code[index] !== ">") {
|
|
340
|
+
const char = code[index];
|
|
341
|
+
if (char === '"' || char === "'") {
|
|
342
|
+
// Backslashes do not escape quotes in JSX attributes.
|
|
343
|
+
const quoteEnd = code.indexOf(char, index + 1);
|
|
344
|
+
if (quoteEnd === -1) return fail();
|
|
345
|
+
index = quoteEnd + 1;
|
|
346
|
+
} else if (char === "{") index = skipExpression(index);
|
|
347
|
+
else if (char === "<") index = findBalancedBlockEnd(code, index, "<", ">", jsxScanCache);
|
|
348
|
+
else if (char === "/" && code[index + 1] === "/") index = skipLineComment(code, index);
|
|
349
|
+
else if (char === "/" && code[index + 1] === "*") index = skipBlockComment(code, index);
|
|
350
|
+
else index += 1;
|
|
351
|
+
}
|
|
352
|
+
if (index === code.length) return fail();
|
|
353
|
+
if (closing) tags.pop();
|
|
354
|
+
else if (code[index - 1] !== "/") tags.push({ name: tag[2], start: tagStart });
|
|
355
|
+
index += 1;
|
|
356
|
+
if (!tags.length) {
|
|
357
|
+
jsxScanCache.set(start, { end: index, expressions });
|
|
358
|
+
for (const [expressionStart, expressionEnd] of expressions) onExpression?.(expressionStart, expressionEnd);
|
|
359
|
+
return index;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return fail();
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const readFromSpecifier = (code: string, index: number, jsxScanCache: JsxScanCache) => {
|
|
366
|
+
const commentStarts = new Map<number, number>();
|
|
367
|
+
while (index < code.length) {
|
|
368
|
+
const char = code[index];
|
|
369
|
+
if (char === ";") return null;
|
|
370
|
+
if (char === "/" && (code[index + 1] === "/" || code[index + 1] === "*")) {
|
|
371
|
+
index = skipScannedComment(code, index, commentStarts);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (char === "/" && isLikelyRegexLiteralStart(code, index, commentStarts)) {
|
|
183
375
|
index = skipRegexLiteral(code, index);
|
|
184
376
|
continue;
|
|
185
377
|
}
|
|
@@ -188,10 +380,10 @@ const readFromSpecifier = (code: string, index: number) => {
|
|
|
188
380
|
continue;
|
|
189
381
|
}
|
|
190
382
|
if (char === "`") {
|
|
191
|
-
index = skipTemplateLiteral(code, index);
|
|
383
|
+
index = skipTemplateLiteral(code, index, jsxScanCache);
|
|
192
384
|
continue;
|
|
193
385
|
}
|
|
194
|
-
if (isKeywordAt(code, index, "from")) {
|
|
386
|
+
if (char === "f" && isKeywordAt(code, index, "from")) {
|
|
195
387
|
const start = skipWhitespaceAndComments(code, index + "from".length);
|
|
196
388
|
const literal = readStringLiteral(code, start);
|
|
197
389
|
if (literal) return { specifier: literal.value, start, end: literal.end, terminated: literal.terminated };
|
|
@@ -203,12 +395,24 @@ const readFromSpecifier = (code: string, index: number) => {
|
|
|
203
395
|
return null;
|
|
204
396
|
};
|
|
205
397
|
|
|
206
|
-
const readExportFromSpecifier = (code: string, index: number) => {
|
|
398
|
+
const readExportFromSpecifier = (code: string, index: number, jsxScanCache: JsxScanCache) => {
|
|
207
399
|
let next = skipWhitespaceAndComments(code, index);
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
400
|
+
// A clause-level `type` erases the whole re-export, unlike an inline `export { type A, B } from` which still links for
|
|
401
|
+
// `B`. Reported as `notLinked` so probing does not fetch what the compiled module never imports.
|
|
402
|
+
const typeOnly = isKeywordAt(code, next, "type");
|
|
403
|
+
if (typeOnly) next = skipWhitespaceAndComments(code, next + "type".length);
|
|
404
|
+
// Only `{` and `*` can start a re-export. Enumerating local declaration keywords misses decorators and lets
|
|
405
|
+
// semicolonless declarations consume a later import's `from`.
|
|
406
|
+
if (code[next] === "{") {
|
|
407
|
+
// A local `export { a }` may have no semicolon either, and the lenient scan below would then read the NEXT
|
|
408
|
+
// statement's `from` — reporting that import as a nameless re-export and losing every name it binds. The clause's
|
|
409
|
+
// own `from` can only sit right after the closing brace, so require it there instead of hunting forward.
|
|
410
|
+
const afterClause = skipWhitespaceAndComments(code, findBalancedBlockEnd(code, next, "{", "}", jsxScanCache));
|
|
411
|
+
if (!isKeywordAt(code, afterClause, "from")) return null;
|
|
412
|
+
next = afterClause;
|
|
413
|
+
} else if (code[next] !== "*") return null;
|
|
414
|
+
const from = readFromSpecifier(code, next, jsxScanCache);
|
|
415
|
+
return from && { ...from, notLinked: typeOnly || undefined };
|
|
212
416
|
};
|
|
213
417
|
|
|
214
418
|
// `.meta.resolve(` after the `import` keyword, returning the string-literal argument. Null means this `import.` is
|
|
@@ -224,36 +428,114 @@ const readImportMetaResolveSpecifier = (code: string, dotIndex: number) => {
|
|
|
224
428
|
if (code[next] !== "(") return null;
|
|
225
429
|
const start = skipWhitespaceAndComments(code, next + 1);
|
|
226
430
|
const literal = readStringLiteral(code, start);
|
|
227
|
-
return literal ? { ...literal, start } : { value: "", start, end: next, terminated: false };
|
|
431
|
+
return literal ? { ...literal, start, openParen: next } : { value: "", start, end: next, terminated: false, openParen: next };
|
|
228
432
|
};
|
|
229
433
|
|
|
230
434
|
const readIdentifierWord = (code: string, index: number) => {
|
|
231
435
|
let end = index;
|
|
232
|
-
|
|
233
|
-
|
|
436
|
+
let escapedName: string | undefined;
|
|
437
|
+
while (end < code.length) {
|
|
438
|
+
let escape: RegExpExecArray | null = null;
|
|
439
|
+
if (code[end] === "\\") {
|
|
440
|
+
IDENTIFIER_ESCAPE_PATTERN.lastIndex = end;
|
|
441
|
+
escape = IDENTIFIER_ESCAPE_PATTERN.exec(code);
|
|
442
|
+
}
|
|
443
|
+
const value = escape ? Number.parseInt(escape[1] ?? escape[2], 16) : code.codePointAt(end)!;
|
|
444
|
+
if (value > 0x10ffff || !isIdentifierCodePoint(value, end === index)) break;
|
|
445
|
+
const characterLength = value > 0xffff ? 2 : 1;
|
|
446
|
+
if (escape) escapedName = `${escapedName ?? code.slice(index, end)}${String.fromCodePoint(value)}`;
|
|
447
|
+
else if (escapedName !== undefined) escapedName += code.slice(end, end + characterLength);
|
|
448
|
+
end += escape?.[0].length ?? characterLength;
|
|
449
|
+
}
|
|
450
|
+
return end > index ? { name: escapedName ?? code.slice(index, end), end } : null;
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
const readImportTypeModifierEnd = (code: string, index: number) => {
|
|
454
|
+
if (!isKeywordAt(code, index, "type")) return undefined;
|
|
455
|
+
const after = skipWhitespaceAndComments(code, index + "type".length);
|
|
456
|
+
// `import type from "x"` / `import type, { X } from "x"` bind a default named `type`.
|
|
457
|
+
return isKeywordAt(code, after, "from") || code[after] === "," ? undefined : after;
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
// A JSON/CSS module is only importable with its `with { type: … }` clause, so a probe that re-imports the specifier
|
|
461
|
+
// without it fails on a dependency that actually linked, reporting a working import as broken (#1824). Read the clause
|
|
462
|
+
// so probeModuleImports can replay it verbatim.
|
|
463
|
+
//
|
|
464
|
+
// `end` lets the caller resume past the clause: rescanned as statements, a reserved-word key (`with { import: "y" }`)
|
|
465
|
+
// matches as a keyword and swallows the next import's `from`. `partial` separates a clause that is present but
|
|
466
|
+
// unreadable — a stream frame cut inside it — from no clause at all; probing the former bare reproduces exactly the
|
|
467
|
+
// false positive above, so those specifiers are withheld until the clause completes.
|
|
468
|
+
type ImportAttributesClause = { attributes?: Record<string, string>; end: number; partial?: boolean };
|
|
469
|
+
const readImportAttributes = (code: string, index: number, jsxScanCache: JsxScanCache): ImportAttributesClause | undefined => {
|
|
470
|
+
const next = skipWhitespaceAndComments(code, index);
|
|
471
|
+
if (!isKeywordAt(code, next, "with")) return;
|
|
472
|
+
const braceStart = skipWhitespaceAndComments(code, next + "with".length);
|
|
473
|
+
// `with` streamed but not yet its `{`: the clause is coming, so this frame cannot be probed.
|
|
474
|
+
if (code[braceStart] !== "{") return { end: braceStart, partial: true };
|
|
475
|
+
const braceEnd = findBalancedBlockEnd(code, braceStart, "{", "}", jsxScanCache);
|
|
476
|
+
// findBalancedBlockEnd returns `code.length` when the brace never closes, i.e. the clause is still streaming.
|
|
477
|
+
// Left undefined until an entry lands, so an empty clause (`with {}`) reads as no attributes — what the engine links with too.
|
|
478
|
+
// Null-prototype once allocated: keys come from generated code, so a `__proto__` key must land as an own property
|
|
479
|
+
// instead of hitting the setter, where it would silently vanish and probe the module without the type it asked for.
|
|
480
|
+
let attributes: Record<string, string> | undefined;
|
|
481
|
+
let partial = code[braceEnd - 1] !== "}";
|
|
482
|
+
let cursor = braceStart + 1;
|
|
483
|
+
while (cursor < braceEnd - 1) {
|
|
484
|
+
cursor = skipWhitespaceAndComments(code, cursor);
|
|
485
|
+
if (cursor >= braceEnd - 1) break;
|
|
486
|
+
// AssertEntry keys are an IdentifierName or a string literal, so `type`, `"type"` and `'type'` are all the same key.
|
|
487
|
+
const quotedKey = readStringLiteral(code, cursor);
|
|
488
|
+
const key = quotedKey ?? readIdentifierWord(code, cursor);
|
|
489
|
+
if (!key || (quotedKey && !quotedKey.terminated)) {
|
|
490
|
+
partial = true;
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
// Both readers decode escapes, so `"type"` and `type` reach the probe as `type`, the key the engine linked with.
|
|
494
|
+
const keyName = "name" in key ? key.name : key.value;
|
|
495
|
+
cursor = skipWhitespaceAndComments(code, key.end);
|
|
496
|
+
if (code[cursor] !== ":") {
|
|
497
|
+
partial = true;
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
cursor = skipWhitespaceAndComments(code, cursor + 1);
|
|
501
|
+
// Values are string literals only — no identifiers, no numbers — so anything else means the clause is truncated.
|
|
502
|
+
const value = readStringLiteral(code, cursor);
|
|
503
|
+
if (!value?.terminated) {
|
|
504
|
+
partial = true;
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
attributes ??= Object.create(null) as Record<string, string>;
|
|
508
|
+
attributes[keyName] = value.value;
|
|
509
|
+
cursor = skipWhitespaceAndComments(code, value.end);
|
|
510
|
+
// The loop head skips whitespace and comments, so stepping over the comma alone is enough.
|
|
511
|
+
if (code[cursor] === ",") cursor += 1;
|
|
512
|
+
// Two entries with no comma between them is not a clause the engine links, and not a frame that becomes one.
|
|
513
|
+
else if (code[cursor] !== "}") {
|
|
514
|
+
partial = true;
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return { attributes, end: braceEnd, partial: partial || undefined };
|
|
234
519
|
};
|
|
235
520
|
|
|
236
521
|
// Parses one static import clause right after the `import` keyword into the export names the module must provide
|
|
237
|
-
// ("default" for default imports; namespace and side-effect imports demand none). `
|
|
238
|
-
//
|
|
239
|
-
// (streaming-truncated ones) so callers can fall
|
|
240
|
-
|
|
522
|
+
// ("default" for default imports; namespace and side-effect imports demand none). `names` holds only the runtime-linked
|
|
523
|
+
// ones and `allNames` adds the erased type positions, which ambient consumers still need to declare; `notLinked` marks a
|
|
524
|
+
// wholly erased clause. Returns null on clauses it does not understand (streaming-truncated ones) so callers can fall
|
|
525
|
+
// back to the lenient readFromSpecifier.
|
|
526
|
+
const readImportBindings = (code: string, index: number): { specifier: string; names: string[]; allNames: string[]; start: number; end: number; terminated: boolean; notLinked?: boolean } | null => {
|
|
241
527
|
const names: string[] = [];
|
|
528
|
+
let typeOnlyNames: string[] | undefined;
|
|
242
529
|
index = skipWhitespaceAndComments(code, index);
|
|
243
530
|
const sideEffect = readStringLiteral(code, index);
|
|
244
|
-
if (sideEffect) return sideEffect.value ? { specifier: sideEffect.value, names, start: index, end: sideEffect.end, terminated: sideEffect.terminated } : null;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if (!isKeywordAt(code, after, "from")) {
|
|
249
|
-
const from = readFromSpecifier(code, after);
|
|
250
|
-
return from ? { ...from, names, notLinked: true } : null;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
531
|
+
if (sideEffect) return sideEffect.value ? { specifier: sideEffect.value, names, allNames: names, start: index, end: sideEffect.end, terminated: sideEffect.terminated } : null;
|
|
532
|
+
const afterType = readImportTypeModifierEnd(code, index);
|
|
533
|
+
const typeOnlyImport = afterType !== undefined;
|
|
534
|
+
index = afterType ?? index;
|
|
253
535
|
if (code[index] !== "{" && code[index] !== "*") {
|
|
254
536
|
const afterDefault = readIdentifierWord(code, index)?.end;
|
|
255
537
|
if (afterDefault === undefined) return null;
|
|
256
|
-
names.push("default");
|
|
538
|
+
(typeOnlyImport ? (typeOnlyNames ??= []) : names).push("default");
|
|
257
539
|
index = skipWhitespaceAndComments(code, afterDefault);
|
|
258
540
|
if (code[index] === ",") index = skipWhitespaceAndComments(code, index + 1);
|
|
259
541
|
}
|
|
@@ -284,7 +566,7 @@ const readImportBindings = (code: string, index: number): { specifier: string; n
|
|
|
284
566
|
if (!local) return null;
|
|
285
567
|
index = skipWhitespaceAndComments(code, local.end);
|
|
286
568
|
}
|
|
287
|
-
|
|
569
|
+
(typeOnlyImport || typeOnly ? (typeOnlyNames ??= []) : names).push(imported.name);
|
|
288
570
|
if (code[index] === ",") index = skipWhitespaceAndComments(code, index + 1);
|
|
289
571
|
else if (code[index] !== "}") return null;
|
|
290
572
|
}
|
|
@@ -294,39 +576,60 @@ const readImportBindings = (code: string, index: number): { specifier: string; n
|
|
|
294
576
|
if (!isKeywordAt(code, index, "from")) return null;
|
|
295
577
|
const start = skipWhitespaceAndComments(code, index + "from".length);
|
|
296
578
|
const literal = readStringLiteral(code, start);
|
|
297
|
-
return literal?.value ? { specifier: literal.value, names, start, end: literal.end, terminated: literal.terminated } : null;
|
|
579
|
+
return literal?.value ? { specifier: literal.value, names, allNames: typeOnlyNames ? [...names, ...typeOnlyNames] : names, start, end: literal.end, terminated: literal.terminated, ...(typeOnlyImport ? { notLinked: true } : {}) } : null;
|
|
298
580
|
};
|
|
299
581
|
|
|
300
|
-
// Walks module-level import/export statements and reports each specifier with
|
|
582
|
+
// Walks module-level import/export statements and reports each specifier with its runtime names plus all source names
|
|
301
583
|
// (empty for namespace, side-effect, re-export and dynamic imports). `onSpecifier` is the single output channel —
|
|
302
584
|
// callers project it into whatever shape they need. `site` separates the module graph from `import.meta.resolve()`,
|
|
303
585
|
// which is a runtime asset lookup rather than an edge.
|
|
304
|
-
type ResolutionSite = "import" | "meta-resolve";
|
|
586
|
+
export type ResolutionSite = "import" | "meta-resolve";
|
|
305
587
|
type SpecifierRange = { start: number; end: number };
|
|
306
|
-
|
|
307
|
-
|
|
588
|
+
// `attributesPartial` marks a specifier whose `with { … }` clause is still streaming. The specifier itself is complete, so
|
|
589
|
+
// import-map building and specifier rewriting must still see it; only probing has to withhold, since replaying a clause that
|
|
590
|
+
// has not fully arrived would link a form the module never requested.
|
|
591
|
+
const collectModuleSpecifiers = (
|
|
592
|
+
code: string,
|
|
593
|
+
onSpecifier: (specifier: string, names: string[], notLinked: boolean | undefined, site: ResolutionSite, range?: SpecifierRange, allNames?: string[], attributes?: Record<string, string>, attributesPartial?: boolean) => void,
|
|
594
|
+
start = 0,
|
|
595
|
+
end = code.length,
|
|
596
|
+
jsxScanCache: JsxScanCache = new Map(),
|
|
597
|
+
) => {
|
|
598
|
+
const scanExpression = (expressionStart: number, expressionEnd: number) => collectModuleSpecifiers(code, onSpecifier, expressionStart, expressionEnd, jsxScanCache);
|
|
599
|
+
const openParens: number[] = [];
|
|
600
|
+
const commentStarts = new Map<number, number>();
|
|
601
|
+
let closedParen: number | undefined;
|
|
602
|
+
// Each branch below already lands on the next unscanned character, so consuming branches `continue` past the tail
|
|
603
|
+
// increment. Adding one on top of them skipped a token abutting the construct before it (`/*! banner */import"react"`).
|
|
604
|
+
let index = start;
|
|
605
|
+
while (index < end) {
|
|
606
|
+
// Each branch below gates its predicate on `char` first: those calls each re-test the same character and bail, so
|
|
607
|
+
// paying them on all ~1.7M characters instead of the <1% that can match cost 1.7x of the whole scan.
|
|
308
608
|
const char = code[index];
|
|
309
|
-
if (char === "
|
|
310
|
-
|
|
311
|
-
|
|
609
|
+
if (char === "<" && isLikelyJsxStart(code, index, closedParen, commentStarts)) {
|
|
610
|
+
const jsxEnd = skipJsxElement(code, index, jsxScanCache, scanExpression);
|
|
611
|
+
if (jsxEnd > index) {
|
|
612
|
+
index = jsxEnd;
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
312
615
|
}
|
|
313
|
-
if (char === "/" && code[index + 1] === "*") {
|
|
314
|
-
index =
|
|
616
|
+
if (char === "/" && (code[index + 1] === "/" || code[index + 1] === "*")) {
|
|
617
|
+
index = skipScannedComment(code, index, commentStarts);
|
|
315
618
|
continue;
|
|
316
619
|
}
|
|
317
|
-
if (isLikelyRegexLiteralStart(code, index)) {
|
|
620
|
+
if (char === "/" && isLikelyRegexLiteralStart(code, index, commentStarts)) {
|
|
318
621
|
index = skipRegexLiteral(code, index);
|
|
319
622
|
continue;
|
|
320
623
|
}
|
|
321
624
|
if (char === '"' || char === "'") {
|
|
322
|
-
index = readStringLiteral(code, index)?.end ?? index;
|
|
625
|
+
index = readStringLiteral(code, index)?.end ?? index + 1;
|
|
323
626
|
continue;
|
|
324
627
|
}
|
|
325
628
|
if (char === "`") {
|
|
326
|
-
index = skipTemplateLiteral(code, index,
|
|
629
|
+
index = skipTemplateLiteral(code, index, jsxScanCache, scanExpression);
|
|
327
630
|
continue;
|
|
328
631
|
}
|
|
329
|
-
if (isKeywordAt(code, index, "import")) {
|
|
632
|
+
if (char === "i" && isKeywordAt(code, index, "import")) {
|
|
330
633
|
const afterImport = skipWhitespaceAndComments(code, index + "import".length);
|
|
331
634
|
if (code[afterImport] === ".") {
|
|
332
635
|
// Keep runtime asset lookups visible to callers that scan all specifiers, while separating them from actual
|
|
@@ -335,6 +638,8 @@ const collectModuleSpecifiers = (code: string, onSpecifier: (specifier: string,
|
|
|
335
638
|
if (literal) {
|
|
336
639
|
// notLinked: nothing is instantiated here, so probing it would fetch what the module never requested.
|
|
337
640
|
if (literal.terminated && literal.value) onSpecifier(literal.value, [], true, "meta-resolve", { start: literal.start, end: literal.end });
|
|
641
|
+
// The fast path skips `(` but leaves `)` to the loop; keep an enclosing condition's parens balanced.
|
|
642
|
+
if (literal.end > literal.openParen) openParens.push(literal.openParen);
|
|
338
643
|
index = literal.end;
|
|
339
644
|
continue;
|
|
340
645
|
}
|
|
@@ -346,23 +651,63 @@ const collectModuleSpecifiers = (code: string, onSpecifier: (specifier: string,
|
|
|
346
651
|
const literal = readStringLiteral(code, literalStart);
|
|
347
652
|
// A dynamic target is not linked at instantiation, so probing it would issue a real request.
|
|
348
653
|
if (literal?.terminated && literal.value) onSpecifier(literal.value, [], true, "import", { start: literalStart, end: literal.end });
|
|
654
|
+
if (literal) openParens.push(afterImport);
|
|
349
655
|
index = literal?.end ?? afterImport;
|
|
350
656
|
continue;
|
|
351
657
|
}
|
|
352
658
|
const bindings = readImportBindings(code, afterImport);
|
|
353
|
-
const from = bindings ?? readFromSpecifier(code, afterImport);
|
|
354
|
-
|
|
355
|
-
|
|
659
|
+
const from = bindings ?? readFromSpecifier(code, afterImport, jsxScanCache);
|
|
660
|
+
// A truncated clause can lose its bindings, but must not turn an erased import into a runtime fetch.
|
|
661
|
+
const notLinked = bindings ? bindings.notLinked : readImportTypeModifierEnd(code, afterImport) !== undefined;
|
|
662
|
+
// Fire the callback even during streaming: rewriteImportSpecifiers needs the range to replace the bare specifier with
|
|
663
|
+
// the resolved target. `clause.partial` tells the probe path to withhold instead — see readImportAttributes.
|
|
664
|
+
if (from?.terminated && from.specifier) {
|
|
665
|
+
const clause = readImportAttributes(code, from.end, jsxScanCache);
|
|
666
|
+
onSpecifier(from.specifier, bindings?.names ?? [], notLinked, "import", { start: from.start, end: from.end }, bindings?.allNames, clause?.attributes, clause?.partial);
|
|
667
|
+
// Only a complete clause is safe to resume past: a partial one's `end` is where the scan gave up, which for an
|
|
668
|
+
// unclosed brace is EOF and for an unterminated value sits inside that string — resuming there would swallow every
|
|
669
|
+
// import after it. Fall back to the specifier's own end, which the pre-attributes scanner used.
|
|
670
|
+
index = clause && !clause.partial ? clause.end : from.end;
|
|
671
|
+
} else {
|
|
672
|
+
index = from?.end ?? afterImport;
|
|
673
|
+
}
|
|
356
674
|
continue;
|
|
357
675
|
}
|
|
358
|
-
if (isKeywordAt(code, index, "export")) {
|
|
359
|
-
const from = readExportFromSpecifier(code, index + "export".length);
|
|
360
|
-
|
|
361
|
-
|
|
676
|
+
if (char === "e" && isKeywordAt(code, index, "export")) {
|
|
677
|
+
const from = readExportFromSpecifier(code, index + "export".length, jsxScanCache);
|
|
678
|
+
// Same split as the import branch: rewriting sees every specifier, the probe path withholds partial clauses.
|
|
679
|
+
if (from?.terminated && from.specifier) {
|
|
680
|
+
const clause = readImportAttributes(code, from.end, jsxScanCache);
|
|
681
|
+
onSpecifier(from.specifier, [], from.notLinked, "import", { start: from.start, end: from.end }, undefined, clause?.attributes, clause?.partial);
|
|
682
|
+
// A partial clause's `end` can sit inside its own unterminated string, where resuming would re-lex the rest of the
|
|
683
|
+
// file as that string and lose every import after it; leave the specifier's own end to advance instead.
|
|
684
|
+
index = clause && !clause.partial ? clause.end : from.end;
|
|
685
|
+
} else {
|
|
686
|
+
// Null means a local `export` that consumed nothing, so step one char rather than rescanning the same `e`.
|
|
687
|
+
index = from?.end ?? index + 1;
|
|
688
|
+
}
|
|
689
|
+
continue;
|
|
362
690
|
}
|
|
691
|
+
if (char === "(") openParens.push(index);
|
|
692
|
+
else if (char === ")") closedParen = openParens.pop();
|
|
693
|
+
index += 1;
|
|
363
694
|
}
|
|
364
695
|
};
|
|
365
696
|
|
|
697
|
+
// Accumulates per-specifier names, deduped across repeated imports of the same module. `select` picks which projection
|
|
698
|
+
// of the clause to merge, or returns undefined to drop the specifier entirely.
|
|
699
|
+
const collectNamesBySpecifier = (code: string, select: (names: string[], allNames: string[], notLinked: boolean | undefined, site: ResolutionSite) => string[] | undefined) => {
|
|
700
|
+
const bindings = new Map<string, Set<string>>();
|
|
701
|
+
collectModuleSpecifiers(code, (specifier, names, notLinked, site, _range, allNames) => {
|
|
702
|
+
const selected = select(names, allNames ?? names, notLinked, site);
|
|
703
|
+
if (!selected) return;
|
|
704
|
+
let collected = bindings.get(specifier);
|
|
705
|
+
if (!collected) bindings.set(specifier, (collected = new Set()));
|
|
706
|
+
for (const name of selected) collected.add(name);
|
|
707
|
+
});
|
|
708
|
+
return new Map([...bindings].map(([specifier, names]) => [specifier, [...names]] as const));
|
|
709
|
+
};
|
|
710
|
+
|
|
366
711
|
const extractSpecifiers = (code: string, keep: (specifier: string, site: ResolutionSite) => boolean) => {
|
|
367
712
|
const specifiers = new Set<string>();
|
|
368
713
|
collectModuleSpecifiers(code, (specifier, _names, _notLinked, site) => {
|
|
@@ -371,6 +716,24 @@ const extractSpecifiers = (code: string, keep: (specifier: string, site: Resolut
|
|
|
371
716
|
return specifiers;
|
|
372
717
|
};
|
|
373
718
|
|
|
719
|
+
// One walk, one Set per kind, for a caller that needs several projections of the same buffer: two `extract*` calls walk
|
|
720
|
+
// it twice, and this scan is not cheap — JSX tag recognition, per-character code-point classification and escape
|
|
721
|
+
// decoding all run per character. `classify` returns the kind a specifier belongs to, or undefined to drop it, and is
|
|
722
|
+
// asked once per occurrence rather than once per specifier — so nothing here makes the kinds exclusive. `site` may gate
|
|
723
|
+
// a kind down to undefined, but a `classify` that answers *different* kinds at different sites buckets one specifier
|
|
724
|
+
// twice, and unioning the buckets then repeats it (`Set`s dedupe within a kind, not across). Buckets are keyed for
|
|
725
|
+
// every `K` the caller asks for, empty ones included, so a destructuring caller never reads undefined.
|
|
726
|
+
// `NoInfer` so `kinds` is the only inference source: otherwise a `classify` answering with an unlisted kind widens `K`
|
|
727
|
+
// instead of being rejected, and the unlisted key has no bucket to `add` to.
|
|
728
|
+
export const extractSpecifiersByKind = <const K extends string>(code: string, kinds: readonly K[], classify: (specifier: string, site: ResolutionSite) => NoInfer<K> | undefined) => {
|
|
729
|
+
const buckets = Object.fromEntries(kinds.map((kind) => [kind, new Set<string>()])) as Record<K, Set<string>>;
|
|
730
|
+
collectModuleSpecifiers(code, (specifier, _names, _notLinked, site) => {
|
|
731
|
+
const kind = classify(specifier, site);
|
|
732
|
+
if (kind !== undefined) buckets[kind].add(specifier);
|
|
733
|
+
});
|
|
734
|
+
return buckets;
|
|
735
|
+
};
|
|
736
|
+
|
|
374
737
|
export const extractBareModuleSpecifiers = (code: string) => extractSpecifiers(code, isBareModuleSpecifier);
|
|
375
738
|
|
|
376
739
|
// Specifiers reached through an `import`/`export` statement only — the actual module graph. Excludes
|
|
@@ -418,17 +781,30 @@ export const rewriteImportMetaResolveSpecifiers = (code: string, imports: Readon
|
|
|
418
781
|
export const rewriteImportSpecifiers = (code: string, imports: Readonly<Record<string, string>>) => rewriteSpecifiers(code, imports, "import");
|
|
419
782
|
|
|
420
783
|
// Maps each specifier linked when the module is instantiated to the export names bound from it ("default" included;
|
|
421
|
-
// namespace, side-effect and re-export imports demand no specific names)
|
|
422
|
-
// neither is linked at instantiation, so probing them would fetch
|
|
423
|
-
// importProbe runs this over *compiled* code, where the import-map rewrite
|
|
784
|
+
// namespace, side-effect and re-export imports demand no specific names) and the `with { … }` attributes it was imported
|
|
785
|
+
// under. Type-only and dynamic imports are excluded: neither is linked at instantiation, so probing them would fetch
|
|
786
|
+
// something the failed module never requested. importProbe runs this over *compiled* code, where the import-map rewrite
|
|
787
|
+
// already resolved bare specifiers.
|
|
424
788
|
export function extractImportBindings(code: string) {
|
|
425
|
-
const bindings = new Map<string, string
|
|
426
|
-
collectModuleSpecifiers(code, (specifier, names, notLinked) => {
|
|
427
|
-
if (
|
|
789
|
+
const bindings = new Map<string, { names: Set<string>; attributes?: Record<string, string> }>();
|
|
790
|
+
collectModuleSpecifiers(code, (specifier, names, notLinked, _site, _range, _allNames, attributes, attributesPartial) => {
|
|
791
|
+
if (notLinked) return;
|
|
792
|
+
// This is the probe path, so a clause that has not fully arrived must not be replayed — see readImportAttributes.
|
|
793
|
+
if (attributesPartial) return;
|
|
794
|
+
let entry = bindings.get(specifier);
|
|
795
|
+
// One probe stands in for every import of a specifier, so it must replay the FIRST clause seen — including no clause
|
|
796
|
+
// at all. Letting a later `with { … }` fill in for a bare import probes a form the module never used, turning a
|
|
797
|
+
// genuine link failure into a silent pass.
|
|
798
|
+
if (!entry) bindings.set(specifier, (entry = { names: new Set(), attributes }));
|
|
799
|
+
for (const name of names) entry.names.add(name);
|
|
428
800
|
});
|
|
429
|
-
return bindings;
|
|
801
|
+
return new Map([...bindings].map(([specifier, { names, attributes }]) => [specifier, { names: [...names], attributes }] as const));
|
|
430
802
|
}
|
|
431
803
|
|
|
804
|
+
// Standalone facade declarations also need names imported only for type positions; unlike runtime probing, those imports
|
|
805
|
+
// are erased from the module graph but still need ambient members.
|
|
806
|
+
export const extractImportedExportNames = (code: string) => collectNamesBySpecifier(code, (_names, allNames, _notLinked, site) => (site === "import" ? allNames : undefined));
|
|
807
|
+
|
|
432
808
|
export function hasImportMapEntry(imports: Record<string, string> | Set<string> | Map<string, string>, specifier: string) {
|
|
433
809
|
const keys = imports instanceof Set ? imports : imports instanceof Map ? new Set(imports.keys()) : new Set(Object.keys(imports));
|
|
434
810
|
if (keys.has(specifier)) return true;
|
|
@@ -633,7 +1009,18 @@ export function packageImportMap(packageName: string, options?: { resolve?: (nam
|
|
|
633
1009
|
if (!importMap) throw new Error(`${packageName} has no package.json#genui.importMap — add it to declare the GenUI importmap contract`);
|
|
634
1010
|
const packageDir = dirname(manifestPath);
|
|
635
1011
|
const rebase = (target: string) => (target.startsWith(".") ? resolvePath(packageDir, target) : target);
|
|
636
|
-
|
|
1012
|
+
const rebaseEntries = (entries: Record<string, string>) => Object.fromEntries(Object.entries(entries).map(([specifier, target]) => [specifier, rebase(target)]));
|
|
1013
|
+
const rebasedScopes =
|
|
1014
|
+
importMap.scopes &&
|
|
1015
|
+
Object.fromEntries(
|
|
1016
|
+
Object.entries(importMap.scopes).map(([scope, entries]) => {
|
|
1017
|
+
// Validate scope keys are valid URL prefixes (absolute path or URL) after rebasing
|
|
1018
|
+
const rebased = rebase(scope);
|
|
1019
|
+
if (!rebased.startsWith("/") && !/^https?:\/\//i.test(rebased)) throw new Error(`Invalid import map scope key "${scope}" (rebased to "${rebased}"): must be an absolute path or URL`);
|
|
1020
|
+
return [rebased, rebaseEntries(entries)];
|
|
1021
|
+
}),
|
|
1022
|
+
);
|
|
1023
|
+
return { imports: rebaseEntries(importMap.imports ?? {}), scopes: rebasedScopes, styles: importMap.styles?.map(rebase) };
|
|
637
1024
|
},
|
|
638
1025
|
};
|
|
639
1026
|
}
|
package/src/importProbe.ts
CHANGED
|
@@ -37,14 +37,16 @@ const reverseResolveSpecifier = (entries: [string, string][], target: string) =>
|
|
|
37
37
|
// targets back to the bare specifiers the model wrote.
|
|
38
38
|
export async function probeModuleImports(code: string, imports?: Record<string, string>): Promise<GenUIImportDiagnostic[]> {
|
|
39
39
|
const importEntries = Object.entries(imports ?? {});
|
|
40
|
-
const probes = [...extractImportBindings(code)].map(async ([target, names]): Promise<GenUIImportDiagnostic | null> => {
|
|
40
|
+
const probes = [...extractImportBindings(code)].map(async ([target, { names, attributes }]): Promise<GenUIImportDiagnostic | null> => {
|
|
41
41
|
const specifier = reverseResolveSpecifier(importEntries, target);
|
|
42
42
|
let module: Record<string, unknown>;
|
|
43
43
|
try {
|
|
44
44
|
// `target` is import-map-resolved at runtime, so it needs the same three-bundler ignore stack as the blob import
|
|
45
45
|
// in runtime.ts — otherwise every probe throws `Cannot find module 'unknown'` and reports each dependency as
|
|
46
46
|
// broken, burying the one that actually is. #1688
|
|
47
|
-
|
|
47
|
+
// The original `with { type: … }` clause must be replayed: a JSON/CSS module re-imported without it always
|
|
48
|
+
// fails, which would report a dependency that actually linked as the broken one. #1818
|
|
49
|
+
module = (await import(/* @vite-ignore */ /* webpackIgnore: true */ /* turbopackIgnore: true */ target, attributes ? { with: attributes } : undefined)) as Record<string, unknown>;
|
|
48
50
|
} catch (error) {
|
|
49
51
|
return { specifier, target, detail: `failed to load: ${error instanceof Error ? error.message : String(error)}` };
|
|
50
52
|
}
|
package/src/nodeWasm.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Kept out of `compiler.ts` so browser bundles never see `import.meta.resolve`: it is a syntax-level construct, so a
|
|
2
|
+
// bundler emitting CJS produces a file that throws on evaluation no matter which branch would run. #1717
|
|
3
|
+
// Typed off `globalThis` instead of the `Bun` global to avoid forcing `@types/bun` on consumers (we ship raw `.ts`).
|
|
4
|
+
const bun = (globalThis as { Bun?: { file(path: URL): { arrayBuffer(): Promise<ArrayBuffer> } } }).Bun;
|
|
5
|
+
|
|
6
|
+
export const loadNodeWasm = async () => {
|
|
7
|
+
// `import.meta.resolve` yields a `file://` URL under both Bun and Node/Vite SSR, so `new URL` is safe — don't "fix" it with pathToFileURL.
|
|
8
|
+
const wasmUrl = new URL(import.meta.resolve("@esm.sh/tsx/pkg/tsx_bg.wasm"));
|
|
9
|
+
if (bun) return bun.file(wasmUrl).arrayBuffer();
|
|
10
|
+
return (await import("node:fs/promises")).readFile(wasmUrl);
|
|
11
|
+
};
|