@volter/editor-react 0.5.66
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/LICENSE +661 -0
- package/NOTICE +4 -0
- package/contributions/component-contracts.service.ts +18 -0
- package/dist-node/component-prop-worker.mjs +335 -0
- package/dist-node/serving.mjs +5842 -0
- package/package.json +51 -0
- package/serving/component-prop-worker.ts +72 -0
- package/serving/index.ts +21 -0
- package/serving/ui-oid-plugin.ts +1844 -0
- package/src/source/component-prop-types.ts +517 -0
- package/src/source/ensure-import.ts +132 -0
- package/src/source/oid-transform.ts +1667 -0
- package/src/source/plan-csf-story.ts +196 -0
- package/src/source/plan-extract-component.ts +421 -0
- package/src/source/plan-fork-component.ts +621 -0
- package/src/source/plan-named-style.ts +95 -0
- package/src/source/plan-source-edit.ts +353 -0
- package/src/source/r3f-contract-resolver.ts +327 -0
- package/src/source/r3f-diagnostic-index.ts +165 -0
- package/src/source/r3f-environment-binding.ts +106 -0
- package/src/source/r3f-joint-binding.ts +228 -0
- package/src/source/r3f-lod-binding.ts +81 -0
- package/src/source/r3f-particle-binding.ts +213 -0
- package/src/source/r3f-physics-binding.ts +454 -0
- package/src/source/r3f-project-contracts.ts +444 -0
- package/src/source/relative-import-specifier.ts +34 -0
- package/src/source/reparent-guard.ts +531 -0
- package/src/source/source-edit-request.ts +22 -0
- package/src/source/syntactic-prop-specs.ts +296 -0
- package/src/source/ts-ast.ts +171 -0
- package/src/source/utility-class-support.ts +169 -0
- package/src/source/write-component-default.ts +133 -0
- package/src/source/writer.ts +1982 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declared-prop resolution for a custom R3F component — what the inspector
|
|
3
|
+
* shows for a `<Enemy …>` node.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. The inspector used to describe a node purely by the
|
|
6
|
+
* attributes physically written in its JSX tag: a prop nobody typed was
|
|
7
|
+
* invisible, and its widget was guessed from the raw attribute text, so
|
|
8
|
+
* `variant="sharpshooter"` read as free text even though the component
|
|
9
|
+
* declares a two-value union. That is backwards from every mainstream engine —
|
|
10
|
+
* Unity/Godot/Unreal show a component's whole declared surface at its defaults
|
|
11
|
+
* and treat the authored values as the subset you have overridden.
|
|
12
|
+
*
|
|
13
|
+
* HOW IT PARSES, and why not the way the neighbours do. Two different
|
|
14
|
+
* questions need two different tools:
|
|
15
|
+
*
|
|
16
|
+
* - WHICH props belong to this component is SYNTACTIC. It is the members the
|
|
17
|
+
* author literally wrote in the props type. `ThreeElements['group'] & {…}`
|
|
18
|
+
* contributes only the `{…}`; the forwarded native surface is hundreds of
|
|
19
|
+
* three.js props the author did not author. So this walks the type node's
|
|
20
|
+
* own AST and only descends into declarations that live in the project.
|
|
21
|
+
*
|
|
22
|
+
* - WHAT TYPE each prop has is SEMANTIC, and is exactly the job of the
|
|
23
|
+
* TypeScript type checker. `variant: EnemyVariant` where
|
|
24
|
+
* `EnemyVariant = keyof typeof ENEMY_MODEL_PATHS` resolves to
|
|
25
|
+
* `'raider' | 'sharpshooter'` with no grammar written here at all.
|
|
26
|
+
*
|
|
27
|
+
* The sibling analysers in `oid-transform.ts` answer their (narrower) type
|
|
28
|
+
* question by regexing `TypeNode.getText()`, which is why they only recognize
|
|
29
|
+
* `ThreeElements['group']` spelled that one way. Do not copy that here: a
|
|
30
|
+
* checker was always the right instrument for reading types, and this module
|
|
31
|
+
* pays the one-time cost of a real program to get it.
|
|
32
|
+
*
|
|
33
|
+
* COST. A `ts.LanguageService` over the project's own tsconfig — ~1.7s and
|
|
34
|
+
* ~2.4k files cold for a shipped example, then warm and incremental. It is
|
|
35
|
+
* built lazily on the first inspector query and reused; only project files are
|
|
36
|
+
* version-stamped, so an ordinary edit re-checks without a rebuild.
|
|
37
|
+
*
|
|
38
|
+
* SERVER-SIDE ONLY: this imports `typescript` and `node:fs` and is used from
|
|
39
|
+
* `vite-plugin-ui-oid.ts`. The editor bundle receives the RESULT on the OID
|
|
40
|
+
* index entry — it never parses TypeScript itself.
|
|
41
|
+
*/
|
|
42
|
+
import { statSync } from 'node:fs';
|
|
43
|
+
import { dirname, resolve } from 'node:path';
|
|
44
|
+
import ts from 'typescript';
|
|
45
|
+
import type { ComponentPropSpec } from '@volter/editor-sdk/source-authoring';
|
|
46
|
+
|
|
47
|
+
export type { ComponentPropSpec };
|
|
48
|
+
|
|
49
|
+
// ------------------------------------------------------------- declarations
|
|
50
|
+
|
|
51
|
+
/** The props parameter of a component declaration, however it is spelled. */
|
|
52
|
+
function componentParameterOf(declaration: ts.Declaration): ts.ParameterDeclaration | null {
|
|
53
|
+
if (ts.isFunctionDeclaration(declaration)) return declaration.parameters[0] ?? null;
|
|
54
|
+
if (ts.isVariableDeclaration(declaration) && declaration.initializer) {
|
|
55
|
+
const init = declaration.initializer;
|
|
56
|
+
if (ts.isArrowFunction(init) || ts.isFunctionExpression(init))
|
|
57
|
+
return init.parameters[0] ?? null;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function componentParameter(
|
|
63
|
+
sf: ts.SourceFile,
|
|
64
|
+
componentName: string,
|
|
65
|
+
): ts.ParameterDeclaration | null {
|
|
66
|
+
let found: ts.ParameterDeclaration | null = null;
|
|
67
|
+
const visit = (node: ts.Node): void => {
|
|
68
|
+
if (found) return;
|
|
69
|
+
const name = ts.isFunctionDeclaration(node)
|
|
70
|
+
? node.name?.text
|
|
71
|
+
: ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)
|
|
72
|
+
? node.name.text
|
|
73
|
+
: undefined;
|
|
74
|
+
if (name === componentName) found = componentParameterOf(node as ts.Declaration);
|
|
75
|
+
if (!found) ts.forEachChild(node, visit);
|
|
76
|
+
};
|
|
77
|
+
visit(sf);
|
|
78
|
+
return found;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Any `<Tag …>` element in the file — a location the checker can resolve both
|
|
82
|
+
* the binding AND the accepted props from, without this module resolving
|
|
83
|
+
* modules or unwrapping component wrappers itself. */
|
|
84
|
+
function jsxTag(sf: ts.SourceFile, tagName: string): ts.JsxOpeningLikeElement | null {
|
|
85
|
+
let found: ts.JsxOpeningLikeElement | null = null;
|
|
86
|
+
const visit = (node: ts.Node): void => {
|
|
87
|
+
if (found) return;
|
|
88
|
+
if (
|
|
89
|
+
(ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) &&
|
|
90
|
+
ts.isIdentifier(node.tagName) &&
|
|
91
|
+
node.tagName.text === tagName
|
|
92
|
+
) {
|
|
93
|
+
found = node;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
ts.forEachChild(node, visit);
|
|
97
|
+
};
|
|
98
|
+
visit(sf);
|
|
99
|
+
return found;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The declaration a JSX tag identifier names, following import aliases. A
|
|
103
|
+
* barrel file is two hops (`import {Bot}` → `export {Bot} from './Bot'`), so
|
|
104
|
+
* unwrap until the symbol is the definition itself. */
|
|
105
|
+
function componentDeclaration(
|
|
106
|
+
identifier: ts.JsxTagNameExpression,
|
|
107
|
+
checker: ts.TypeChecker,
|
|
108
|
+
): ts.Declaration | null {
|
|
109
|
+
let symbol = checker.getSymbolAtLocation(identifier);
|
|
110
|
+
for (let hop = 0; symbol && symbol.flags & ts.SymbolFlags.Alias && hop < 8; hop += 1) {
|
|
111
|
+
const next = checker.getAliasedSymbol(symbol);
|
|
112
|
+
if (next === symbol) break;
|
|
113
|
+
symbol = next;
|
|
114
|
+
}
|
|
115
|
+
return symbol?.valueDeclaration ?? symbol?.declarations?.[0] ?? null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Fallback props type when the JSX attributes have no contextual type (a
|
|
119
|
+
* component the checker could not type). Reads the declaration's own
|
|
120
|
+
* parameter, which covers a plain function component. */
|
|
121
|
+
function contextlessPropsType(
|
|
122
|
+
declaration: ts.Declaration,
|
|
123
|
+
checker: ts.TypeChecker,
|
|
124
|
+
): ts.Type | null {
|
|
125
|
+
const parameter = componentParameterOf(declaration);
|
|
126
|
+
return parameter?.type ? checker.getTypeAtLocation(parameter.type) : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Which package a declaration belongs to — the nearest `node_modules/<pkg>`
|
|
131
|
+
* segment, or `PROJECT` for the project's own source. This is the unit of
|
|
132
|
+
* "who owns this prop".
|
|
133
|
+
*/
|
|
134
|
+
const PROJECT = '(project)';
|
|
135
|
+
const NODE_MODULES_PKG_RE = /node_modules\/((?:@[^/]+\/)?[^/]+)\//;
|
|
136
|
+
|
|
137
|
+
function packageOfFile(fileName: string): string {
|
|
138
|
+
return NODE_MODULES_PKG_RE.exec(fileName)?.[1] ?? PROJECT;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function packageOfDeclaration(node: ts.Node): string {
|
|
142
|
+
return packageOfFile(node.getSourceFile().fileName);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The props a component itself declares, as symbols.
|
|
147
|
+
*
|
|
148
|
+
* The checker resolves the whole props type — intersections, `extends`,
|
|
149
|
+
* imported aliases, `Omit<…>`, forwardRef wrappers, anything — and then each
|
|
150
|
+
* property is filtered by WHERE IT WAS DECLARED, against the package the
|
|
151
|
+
* COMPONENT itself is defined in.
|
|
152
|
+
*
|
|
153
|
+
* That symmetry is the whole rule, and it reads correctly in both directions:
|
|
154
|
+
*
|
|
155
|
+
* - `Enemy` lives in the project, so its own props are the project-declared
|
|
156
|
+
* ones. The ~86 members arriving from `ThreeElements['group']` are the
|
|
157
|
+
* FORWARDED native surface — three.js', not the author's config.
|
|
158
|
+
* - `RigidBody` lives in `@react-three/rapier`, so ITS own props are rapier's
|
|
159
|
+
* 48 (`type`, `gravityScale`, `ccd`, …). A library component used as a tag
|
|
160
|
+
* is configured entirely through the props its own package declares.
|
|
161
|
+
* - `PerspectiveCamera` lives in `@react-three/drei`, and the rule yields
|
|
162
|
+
* exactly drei's six additions (`makeDefault`, `manual`, `frames`, …) over
|
|
163
|
+
* the three.js camera surface it forwards.
|
|
164
|
+
*
|
|
165
|
+
* Filtering on declaration site rather than on the shape of the written type
|
|
166
|
+
* expression is what makes this robust: there is no grammar of "ways to spell a
|
|
167
|
+
* props type" to keep up with.
|
|
168
|
+
*/
|
|
169
|
+
function ownProps(
|
|
170
|
+
propsType: ts.Type,
|
|
171
|
+
homePackage: string,
|
|
172
|
+
): Array<{ symbol: ts.Symbol; declaration: ts.Declaration }> {
|
|
173
|
+
const own: Array<{ symbol: ts.Symbol; declaration: ts.Declaration }> = [];
|
|
174
|
+
for (const symbol of propsType.getProperties()) {
|
|
175
|
+
// ANY home-package declaration counts. A prop the author redeclares over
|
|
176
|
+
// the forwarded surface (`name: string` alongside `ThreeElements['group']`)
|
|
177
|
+
// has two declarations, and the library's may well come first.
|
|
178
|
+
const declaration = symbol.declarations?.find(
|
|
179
|
+
(candidate) => packageOfDeclaration(candidate) === homePackage,
|
|
180
|
+
);
|
|
181
|
+
if (declaration) own.push({ symbol, declaration });
|
|
182
|
+
}
|
|
183
|
+
return own;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ------------------------------------------------------------------ widgets
|
|
187
|
+
|
|
188
|
+
/** Map a checked type onto an inspector widget, or null when none is honest. */
|
|
189
|
+
function widgetForType(
|
|
190
|
+
type: ts.Type,
|
|
191
|
+
checker: ts.TypeChecker,
|
|
192
|
+
): { type: ComponentPropSpec['type']; options?: Array<string | number> } {
|
|
193
|
+
const bare = checker.getNonNullableType(type);
|
|
194
|
+
// `boolean` is itself the union `true | false` — settle it before unions.
|
|
195
|
+
if (bare.flags & ts.TypeFlags.BooleanLike) return { type: 'boolean' };
|
|
196
|
+
if (bare.isUnion()) {
|
|
197
|
+
const members = bare.types.filter((member) => !(member.flags & ts.TypeFlags.Undefined));
|
|
198
|
+
if (members.length && members.every((member) => member.isStringLiteral())) {
|
|
199
|
+
return {
|
|
200
|
+
type: 'enum',
|
|
201
|
+
options: members.map((member) => (member as ts.StringLiteralType).value),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
if (members.length && members.every((member) => member.isNumberLiteral())) {
|
|
205
|
+
return {
|
|
206
|
+
type: 'enum',
|
|
207
|
+
options: members.map((member) => (member as ts.NumberLiteralType).value),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (members.length === 1) return widgetForType(members[0]!, checker);
|
|
211
|
+
// A mixed union has no single widget that tells the truth about it.
|
|
212
|
+
return { type: null };
|
|
213
|
+
}
|
|
214
|
+
if (bare.flags & ts.TypeFlags.StringLike) return { type: 'string' };
|
|
215
|
+
if (bare.flags & ts.TypeFlags.NumberLike) return { type: 'number' };
|
|
216
|
+
const sequenceLength = numericSequenceLength(bare, checker);
|
|
217
|
+
if (sequenceLength === 3) return { type: 'vec3' };
|
|
218
|
+
if (sequenceLength !== null) return { type: 'json' };
|
|
219
|
+
return { type: null };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Tuple length, or -1 for an open numeric array; null when not numeric. */
|
|
223
|
+
function numericSequenceLength(type: ts.Type, checker: ts.TypeChecker): number | null {
|
|
224
|
+
const isNumeric = (member: ts.Type): boolean =>
|
|
225
|
+
Boolean(checker.getNonNullableType(member).flags & ts.TypeFlags.NumberLike);
|
|
226
|
+
if (checker.isTupleType(type)) {
|
|
227
|
+
const args = checker.getTypeArguments(type as ts.TypeReference);
|
|
228
|
+
return args.length > 0 && args.every(isNumeric) ? args.length : null;
|
|
229
|
+
}
|
|
230
|
+
if (checker.isArrayType(type)) {
|
|
231
|
+
const element = checker.getTypeArguments(type as ts.TypeReference)[0];
|
|
232
|
+
return element && isNumeric(element) ? -1 : null;
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ----------------------------------------------------------------- defaults
|
|
238
|
+
|
|
239
|
+
/** The literal a default initializer denotes, or undefined when it is an
|
|
240
|
+
* expression (a call, arithmetic, an identifier) with no static value. */
|
|
241
|
+
function literalValue(node: ts.Expression): ComponentPropSpec['defaultValue'] {
|
|
242
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
|
243
|
+
if (ts.isNumericLiteral(node)) return Number(node.text);
|
|
244
|
+
if (node.kind === ts.SyntaxKind.TrueKeyword) return true;
|
|
245
|
+
if (node.kind === ts.SyntaxKind.FalseKeyword) return false;
|
|
246
|
+
if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.MinusToken) {
|
|
247
|
+
const operand = literalValue(node.operand);
|
|
248
|
+
return typeof operand === 'number' ? -operand : undefined;
|
|
249
|
+
}
|
|
250
|
+
if (ts.isArrayLiteralExpression(node)) {
|
|
251
|
+
const values = node.elements.map(literalValue);
|
|
252
|
+
return values.every((value) => typeof value === 'number') ? (values as number[]) : undefined;
|
|
253
|
+
}
|
|
254
|
+
if (ts.isAsExpression(node) || ts.isParenthesizedExpression(node))
|
|
255
|
+
return literalValue(node.expression);
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
interface Destructuring {
|
|
260
|
+
/** Prop names in the order the component destructures them. */
|
|
261
|
+
order: string[];
|
|
262
|
+
defaults: Map<string, ts.Expression>;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function destructuring(parameter: ts.ParameterDeclaration, sf: ts.SourceFile): Destructuring {
|
|
266
|
+
const order: string[] = [];
|
|
267
|
+
const defaults = new Map<string, ts.Expression>();
|
|
268
|
+
if (!ts.isObjectBindingPattern(parameter.name)) return { order, defaults };
|
|
269
|
+
for (const element of parameter.name.elements) {
|
|
270
|
+
if (element.dotDotDotToken) continue;
|
|
271
|
+
const name = element.propertyName?.getText(sf) ?? element.name.getText(sf);
|
|
272
|
+
order.push(name);
|
|
273
|
+
if (element.initializer) defaults.set(name, element.initializer);
|
|
274
|
+
}
|
|
275
|
+
return { order, defaults };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function docOf(symbol: ts.Symbol, checker: ts.TypeChecker): string | undefined {
|
|
279
|
+
const prose = ts
|
|
280
|
+
.displayPartsToString(symbol.getDocumentationComment(checker))
|
|
281
|
+
.replace(/\s+/g, ' ')
|
|
282
|
+
.trim();
|
|
283
|
+
return prose || undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function declaredSpec(
|
|
287
|
+
symbol: ts.Symbol,
|
|
288
|
+
declaration: ts.Declaration,
|
|
289
|
+
checker: ts.TypeChecker,
|
|
290
|
+
defaults: ReadonlyMap<string, ts.Expression>,
|
|
291
|
+
): ComponentPropSpec {
|
|
292
|
+
const name = symbol.getName();
|
|
293
|
+
const widget = widgetForType(checker.getTypeOfSymbolAtLocation(symbol, declaration), checker);
|
|
294
|
+
const doc = docOf(symbol, checker);
|
|
295
|
+
return {
|
|
296
|
+
name,
|
|
297
|
+
type: widget.type,
|
|
298
|
+
...(widget.options ? { options: widget.options } : {}),
|
|
299
|
+
optional: Boolean(symbol.flags & ts.SymbolFlags.Optional) || defaults.has(name),
|
|
300
|
+
...(doc ? { doc } : {}),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function withDefault(
|
|
305
|
+
spec: ComponentPropSpec,
|
|
306
|
+
initializer: ts.Expression | undefined,
|
|
307
|
+
sf: ts.SourceFile,
|
|
308
|
+
): ComponentPropSpec {
|
|
309
|
+
if (!initializer) return spec;
|
|
310
|
+
const value = literalValue(initializer);
|
|
311
|
+
return {
|
|
312
|
+
...spec,
|
|
313
|
+
defaultText: initializer.getText(sf),
|
|
314
|
+
...(value === undefined ? {} : { defaultValue: value }),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Destructured props first, in the order the component itself reads them —
|
|
319
|
+
* that ordering is the author's own emphasis, and matches the source. */
|
|
320
|
+
function orderedByDestructuring(
|
|
321
|
+
specs: Map<string, ComponentPropSpec>,
|
|
322
|
+
order: readonly string[],
|
|
323
|
+
): ComponentPropSpec[] {
|
|
324
|
+
const ordered: ComponentPropSpec[] = [];
|
|
325
|
+
for (const name of order) {
|
|
326
|
+
const spec = specs.get(name);
|
|
327
|
+
if (spec) {
|
|
328
|
+
ordered.push(spec);
|
|
329
|
+
specs.delete(name);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return [...ordered, ...specs.values()];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------- resolver
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* A reusable TypeScript program over one project, answering "what props does
|
|
339
|
+
* this component declare?". Build one per project root and keep it — the cold
|
|
340
|
+
* program is the expensive part, subsequent queries are incremental.
|
|
341
|
+
*/
|
|
342
|
+
export class ComponentPropResolver {
|
|
343
|
+
private readonly service: ts.LanguageService;
|
|
344
|
+
private readonly rootFiles: string[];
|
|
345
|
+
private readonly projectRoot: string;
|
|
346
|
+
|
|
347
|
+
constructor(projectRoot: string) {
|
|
348
|
+
this.projectRoot = projectRoot;
|
|
349
|
+
const configPath = ts.findConfigFile(projectRoot, ts.sys.fileExists, 'tsconfig.json');
|
|
350
|
+
const parsed = configPath
|
|
351
|
+
? ts.parseJsonConfigFileContent(
|
|
352
|
+
ts.readConfigFile(configPath, ts.sys.readFile).config,
|
|
353
|
+
ts.sys,
|
|
354
|
+
dirname(configPath),
|
|
355
|
+
)
|
|
356
|
+
: { fileNames: [], options: {} as ts.CompilerOptions };
|
|
357
|
+
this.rootFiles = parsed.fileNames;
|
|
358
|
+
const options: ts.CompilerOptions = {
|
|
359
|
+
...parsed.options,
|
|
360
|
+
// Types are all this asks for; emitting or reporting errors is not its job.
|
|
361
|
+
noEmit: true,
|
|
362
|
+
skipLibCheck: true,
|
|
363
|
+
jsx: parsed.options.jsx ?? ts.JsxEmit.ReactJSX,
|
|
364
|
+
};
|
|
365
|
+
const host: ts.LanguageServiceHost = {
|
|
366
|
+
getScriptFileNames: () => this.rootFiles,
|
|
367
|
+
getScriptVersion: (fileName) => this.versionOf(fileName),
|
|
368
|
+
getScriptSnapshot: (fileName) => {
|
|
369
|
+
const text = ts.sys.readFile(fileName);
|
|
370
|
+
return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text);
|
|
371
|
+
},
|
|
372
|
+
getCurrentDirectory: () => projectRoot,
|
|
373
|
+
getCompilationSettings: () => options,
|
|
374
|
+
getDefaultLibFileName: (o) => ts.getDefaultLibFilePath(o),
|
|
375
|
+
fileExists: ts.sys.fileExists,
|
|
376
|
+
readFile: ts.sys.readFile,
|
|
377
|
+
readDirectory: ts.sys.readDirectory,
|
|
378
|
+
directoryExists: ts.sys.directoryExists,
|
|
379
|
+
getDirectories: ts.sys.getDirectories,
|
|
380
|
+
...(ts.sys.realpath ? { realpath: ts.sys.realpath } : {}),
|
|
381
|
+
};
|
|
382
|
+
this.service = ts.createLanguageService(host, ts.createDocumentRegistry());
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Only project sources are re-stamped: a dependency's `.d.ts` does not
|
|
386
|
+
* change under a running editor, and stat-ing thousands of them per query
|
|
387
|
+
* would cost more than the answer is worth. */
|
|
388
|
+
private versionOf(fileName: string): string {
|
|
389
|
+
if (fileName.includes('/node_modules/')) return '1';
|
|
390
|
+
if (!fileName.startsWith(this.projectRoot)) return '1';
|
|
391
|
+
try {
|
|
392
|
+
return String(statSync(fileName).mtimeMs);
|
|
393
|
+
} catch {
|
|
394
|
+
return '0';
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Make a file known to the program even if the tsconfig did not list it. */
|
|
399
|
+
private ensureRoot(file: string): void {
|
|
400
|
+
const absolute = resolve(this.projectRoot, file);
|
|
401
|
+
if (!this.rootFiles.includes(absolute)) this.rootFiles.push(absolute);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The props `componentName` declares, as defined in `file`. Returns an empty
|
|
406
|
+
* list when the component or its props type cannot be found — never a guess.
|
|
407
|
+
*/
|
|
408
|
+
propsFor(file: string, componentName: string): ComponentPropSpec[] {
|
|
409
|
+
const context = this.context(file);
|
|
410
|
+
if (!context) return [];
|
|
411
|
+
const parameter = componentParameter(context.sf, componentName);
|
|
412
|
+
if (!parameter?.type) return [];
|
|
413
|
+
return this.specsFor(
|
|
414
|
+
context.checker.getTypeAtLocation(parameter.type),
|
|
415
|
+
packageOfDeclaration(parameter),
|
|
416
|
+
parameter,
|
|
417
|
+
context.checker,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The props behind a JSX tag USED in `callsiteFile` — `<Enemy …>` in a world
|
|
423
|
+
* whose `Enemy` is imported from anywhere. The tag identifier is resolved
|
|
424
|
+
* through the checker, so aliased imports, re-exports and barrel files all
|
|
425
|
+
* land on the real definition without a module-resolution guess here.
|
|
426
|
+
*/
|
|
427
|
+
propsForTag(callsiteFile: string, tagName: string): ComponentPropSpec[] {
|
|
428
|
+
const context = this.context(callsiteFile);
|
|
429
|
+
if (!context) return [];
|
|
430
|
+
const tag = jsxTag(context.sf, tagName);
|
|
431
|
+
if (!tag) return [];
|
|
432
|
+
const declaration = componentDeclaration(tag.tagName, context.checker);
|
|
433
|
+
if (!declaration) return [];
|
|
434
|
+
// The props a TAG accepts, asked of the element itself. This is what makes
|
|
435
|
+
// `forwardRef`/`memo` components work: `<RigidBody>` is a
|
|
436
|
+
// `ForwardRefExoticComponent` value with no parameter to read, but the JSX
|
|
437
|
+
// attributes still have a contextual type.
|
|
438
|
+
const propsType =
|
|
439
|
+
context.checker.getContextualType(tag.attributes) ??
|
|
440
|
+
contextlessPropsType(declaration, context.checker);
|
|
441
|
+
if (!propsType) return [];
|
|
442
|
+
// Defaults live in the destructuring, so they are only available when the
|
|
443
|
+
// definition is an ordinary function we can see the parameter of.
|
|
444
|
+
const parameter = componentParameterOf(declaration);
|
|
445
|
+
return this.specsFor(propsType, packageOfDeclaration(declaration), parameter, context.checker);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private context(file: string): { sf: ts.SourceFile; checker: ts.TypeChecker } | null {
|
|
449
|
+
this.ensureRoot(file);
|
|
450
|
+
const program = this.service.getProgram();
|
|
451
|
+
const sf = program?.getSourceFile(resolve(this.projectRoot, file));
|
|
452
|
+
return program && sf ? { sf, checker: program.getTypeChecker() } : null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private specsFor(
|
|
456
|
+
propsType: ts.Type,
|
|
457
|
+
homePackage: string,
|
|
458
|
+
parameter: ts.ParameterDeclaration | null,
|
|
459
|
+
checker: ts.TypeChecker,
|
|
460
|
+
): ComponentPropSpec[] {
|
|
461
|
+
const sf = parameter?.getSourceFile();
|
|
462
|
+
const { order, defaults } = parameter
|
|
463
|
+
? destructuring(parameter, parameter.getSourceFile())
|
|
464
|
+
: { order: [] as string[], defaults: new Map<string, ts.Expression>() };
|
|
465
|
+
|
|
466
|
+
const specs = new Map<string, ComponentPropSpec>();
|
|
467
|
+
for (const { symbol, declaration } of ownProps(propsType, homePackage)) {
|
|
468
|
+
specs.set(symbol.getName(), declaredSpec(symbol, declaration, checker, defaults));
|
|
469
|
+
}
|
|
470
|
+
// A prop the component DESTRUCTURES is a prop it reads, whatever package
|
|
471
|
+
// its type was declared in — so membership is settled and only the type is
|
|
472
|
+
// left, which is the checker's job (this module's own split, see the
|
|
473
|
+
// header). `ownProps`' package filter answers "which of the hundreds of
|
|
474
|
+
// FORWARDED members did this author write?"; it must not also decide
|
|
475
|
+
// whether a prop the author literally named has a knowable type.
|
|
476
|
+
//
|
|
477
|
+
// MEASURED on the racing ingest: `<Physics allowSleep broadphase
|
|
478
|
+
// defaultContactMaterial>` and `<Vehicle angularVelocity>` are all read by
|
|
479
|
+
// their component's own destructuring while being declared in
|
|
480
|
+
// `@pmndrs/cannon-worker-api` — a different package from the component's
|
|
481
|
+
// — so every one arrived typeless and the inspector fell through to
|
|
482
|
+
// guessing the widget from the attribute TEXT (`allowSleep` → `boolean` by
|
|
483
|
+
// luck; `broadphase` → free-text `string` where the declaration is a
|
|
484
|
+
// two-value union; `angularVelocity` → `string` where it is a Triplet).
|
|
485
|
+
//
|
|
486
|
+
// `getTypeOfSymbol`, not `getTypeOfSymbolAtLocation`: a `Pick<>`-mapped
|
|
487
|
+
// member has NO declaration to pass as the location (measured — `Vehicle`'s
|
|
488
|
+
// `angularVelocity` reports `declarations.length === 0`), which is also why
|
|
489
|
+
// `declaredSpec` cannot be reused here. A prop with no reachable type stays
|
|
490
|
+
// `null`, and the inspector's loud text-guess fallback still covers it.
|
|
491
|
+
for (const name of order) {
|
|
492
|
+
if (specs.has(name)) continue;
|
|
493
|
+
const symbol = propsType.getProperty(name);
|
|
494
|
+
if (!symbol) {
|
|
495
|
+
specs.set(name, { name, type: null, optional: defaults.has(name) });
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
const widget = widgetForType(checker.getTypeOfSymbol(symbol), checker);
|
|
499
|
+
const doc = docOf(symbol, checker);
|
|
500
|
+
specs.set(name, {
|
|
501
|
+
name,
|
|
502
|
+
type: widget.type,
|
|
503
|
+
...(widget.options ? { options: widget.options } : {}),
|
|
504
|
+
optional: Boolean(symbol.flags & ts.SymbolFlags.Optional) || defaults.has(name),
|
|
505
|
+
...(doc ? { doc } : {}),
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
if (sf) {
|
|
509
|
+
for (const [name, spec] of specs) specs.set(name, withDefault(spec, defaults.get(name), sf));
|
|
510
|
+
}
|
|
511
|
+
return orderedByDestructuring(specs, order);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
dispose(): void {
|
|
515
|
+
this.service.dispose();
|
|
516
|
+
}
|
|
517
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ensureNamedImport` — make a named import available in a module, idempotently.
|
|
3
|
+
*
|
|
4
|
+
* Why this is not in `writer.ts`: that file is deliberately parser-free ("pure
|
|
5
|
+
* string ops", see its header), and an import list is the one region of a source
|
|
6
|
+
* file where a scan-based guess is genuinely unsafe — a string, a comment, or a
|
|
7
|
+
* type-only clause all look like the thing being matched. This is a real parse.
|
|
8
|
+
*
|
|
9
|
+
* The one caller is the `create`/`create-sibling` struct op
|
|
10
|
+
* (`plan-source-edit.ts`): an inserted element whose tag is a COMPONENT is broken
|
|
11
|
+
* source until its import exists, so the import rides the same plan (and therefore
|
|
12
|
+
* the same single history transaction) as the insert itself.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as ts from 'typescript';
|
|
16
|
+
import { parseAuthoringTsx } from './ts-ast';
|
|
17
|
+
import type { StructEditResult } from './writer';
|
|
18
|
+
|
|
19
|
+
/** Insert `name` into the module's named imports from `moduleSpecifier`, adding
|
|
20
|
+
* the whole import statement when the module is not imported yet. Already
|
|
21
|
+
* present ⇒ `changed: false` with the code untouched (the idempotent case every
|
|
22
|
+
* drop after the first takes). */
|
|
23
|
+
export function ensureNamedImport(
|
|
24
|
+
code: string,
|
|
25
|
+
name: string,
|
|
26
|
+
moduleSpecifier: string,
|
|
27
|
+
): StructEditResult {
|
|
28
|
+
const sf = parseAuthoringTsx('module.tsx', code);
|
|
29
|
+
const imports = sf.statements.filter(ts.isImportDeclaration);
|
|
30
|
+
|
|
31
|
+
for (const declaration of imports) {
|
|
32
|
+
if (!ts.isStringLiteral(declaration.moduleSpecifier)) continue;
|
|
33
|
+
if (declaration.moduleSpecifier.text !== moduleSpecifier) continue;
|
|
34
|
+
// A type-only import of the same module cannot host a VALUE import; leave it
|
|
35
|
+
// alone and let the new-statement path below add a separate one.
|
|
36
|
+
if (declaration.importClause?.isTypeOnly) continue;
|
|
37
|
+
const bindings = declaration.importClause?.namedBindings;
|
|
38
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
39
|
+
if (bindings.elements.some((element) => element.name.text === name)) {
|
|
40
|
+
return { code, changed: false };
|
|
41
|
+
}
|
|
42
|
+
// Keep the clause alphabetical — the order every formatter in this repo's
|
|
43
|
+
// toolchain writes, so an inserted name does not read as a stray.
|
|
44
|
+
const after = bindings.elements.find((element) => element.name.text > name);
|
|
45
|
+
if (after) {
|
|
46
|
+
const at = after.getStart(sf);
|
|
47
|
+
return { code: `${code.slice(0, at)}${name}, ${code.slice(at)}`, changed: true };
|
|
48
|
+
}
|
|
49
|
+
const last = bindings.elements[bindings.elements.length - 1];
|
|
50
|
+
const at = last ? last.getEnd() : bindings.getEnd() - 1;
|
|
51
|
+
const separator = last ? ', ' : '';
|
|
52
|
+
return { code: `${code.slice(0, at)}${separator}${name}${code.slice(at)}`, changed: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const statement = `import { ${name} } from ${quoteOf(sf, code)}${moduleSpecifier}${quoteOf(sf, code)}${semicolonOf(imports, code)}`;
|
|
56
|
+
const lastImport = imports[imports.length - 1];
|
|
57
|
+
if (!lastImport) return { code: `${statement}\n${code}`, changed: true };
|
|
58
|
+
const at = lastImport.getEnd();
|
|
59
|
+
return { code: `${code.slice(0, at)}\n${statement}${code.slice(at)}`, changed: true };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Ensure either ecosystem-native import spelling. Component discovery knows
|
|
63
|
+
* whether a project component is a default or named export; preserving that
|
|
64
|
+
* fact avoids inventing a registry or wrapper merely to place it. */
|
|
65
|
+
export function ensureValueImport(
|
|
66
|
+
code: string,
|
|
67
|
+
name: string,
|
|
68
|
+
moduleSpecifier: string,
|
|
69
|
+
kind: 'default' | 'named' = 'named',
|
|
70
|
+
): StructEditResult {
|
|
71
|
+
if (kind === 'named') return ensureNamedImport(code, name, moduleSpecifier);
|
|
72
|
+
const sf = parseAuthoringTsx('module.tsx', code);
|
|
73
|
+
const imports = sf.statements.filter(ts.isImportDeclaration);
|
|
74
|
+
for (const declaration of imports) {
|
|
75
|
+
if (!importsFrom(declaration, moduleSpecifier)) continue;
|
|
76
|
+
const defaultBinding = defaultBindingStatus(declaration.importClause, name);
|
|
77
|
+
if (defaultBinding === 'same') return { code, changed: false };
|
|
78
|
+
// A module may have only one default binding per import declaration. If
|
|
79
|
+
// this file already chose another local name, import the same ecosystem
|
|
80
|
+
// value through standard ESM's named-default spelling instead of emitting
|
|
81
|
+
// a second default clause.
|
|
82
|
+
if (defaultBinding === 'different') {
|
|
83
|
+
const statement = `import { default as ${name} } from ${quoteOf(sf, code)}${moduleSpecifier}${quoteOf(sf, code)}${semicolonOf(imports, code)}`;
|
|
84
|
+
const at = declaration.getEnd();
|
|
85
|
+
return { code: `${code.slice(0, at)}\n${statement}${code.slice(at)}`, changed: true };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const statement = `import ${name} from ${quoteOf(sf, code)}${moduleSpecifier}${quoteOf(sf, code)}${semicolonOf(imports, code)}`;
|
|
89
|
+
const lastImport = imports[imports.length - 1];
|
|
90
|
+
if (!lastImport) return { code: `${statement}\n${code}`, changed: true };
|
|
91
|
+
const at = lastImport.getEnd();
|
|
92
|
+
return { code: `${code.slice(0, at)}\n${statement}${code.slice(at)}`, changed: true };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function importsFrom(declaration: ts.ImportDeclaration, moduleSpecifier: string): boolean {
|
|
96
|
+
return (
|
|
97
|
+
ts.isStringLiteral(declaration.moduleSpecifier) &&
|
|
98
|
+
declaration.moduleSpecifier.text === moduleSpecifier
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function defaultBindingStatus(
|
|
103
|
+
clause: ts.ImportClause | undefined,
|
|
104
|
+
name: string,
|
|
105
|
+
): 'same' | 'different' | 'absent' {
|
|
106
|
+
if (!clause || clause.isTypeOnly) return 'absent';
|
|
107
|
+
if (clause.name?.text === name) return 'same';
|
|
108
|
+
const named = clause.namedBindings;
|
|
109
|
+
if (
|
|
110
|
+
named &&
|
|
111
|
+
ts.isNamedImports(named) &&
|
|
112
|
+
named.elements.some(
|
|
113
|
+
(element) => element.propertyName?.text === 'default' && element.name.text === name,
|
|
114
|
+
)
|
|
115
|
+
) {
|
|
116
|
+
return 'same';
|
|
117
|
+
}
|
|
118
|
+
return clause.name ? 'different' : 'absent';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Match the file's own import spelling rather than imposing one. */
|
|
122
|
+
function quoteOf(sf: ts.SourceFile, code: string): string {
|
|
123
|
+
const first = sf.statements.find(ts.isImportDeclaration);
|
|
124
|
+
if (!first) return "'";
|
|
125
|
+
return code[first.moduleSpecifier.getStart(sf)] === '"' ? '"' : "'";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function semicolonOf(imports: readonly ts.ImportDeclaration[], code: string): string {
|
|
129
|
+
const last = imports[imports.length - 1];
|
|
130
|
+
if (!last) return ';';
|
|
131
|
+
return code.slice(0, last.getEnd()).trimEnd().endsWith(';') ? ';' : '';
|
|
132
|
+
}
|