@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.
Files changed (33) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +4 -0
  3. package/contributions/component-contracts.service.ts +18 -0
  4. package/dist-node/component-prop-worker.mjs +335 -0
  5. package/dist-node/serving.mjs +5842 -0
  6. package/package.json +51 -0
  7. package/serving/component-prop-worker.ts +72 -0
  8. package/serving/index.ts +21 -0
  9. package/serving/ui-oid-plugin.ts +1844 -0
  10. package/src/source/component-prop-types.ts +517 -0
  11. package/src/source/ensure-import.ts +132 -0
  12. package/src/source/oid-transform.ts +1667 -0
  13. package/src/source/plan-csf-story.ts +196 -0
  14. package/src/source/plan-extract-component.ts +421 -0
  15. package/src/source/plan-fork-component.ts +621 -0
  16. package/src/source/plan-named-style.ts +95 -0
  17. package/src/source/plan-source-edit.ts +353 -0
  18. package/src/source/r3f-contract-resolver.ts +327 -0
  19. package/src/source/r3f-diagnostic-index.ts +165 -0
  20. package/src/source/r3f-environment-binding.ts +106 -0
  21. package/src/source/r3f-joint-binding.ts +228 -0
  22. package/src/source/r3f-lod-binding.ts +81 -0
  23. package/src/source/r3f-particle-binding.ts +213 -0
  24. package/src/source/r3f-physics-binding.ts +454 -0
  25. package/src/source/r3f-project-contracts.ts +444 -0
  26. package/src/source/relative-import-specifier.ts +34 -0
  27. package/src/source/reparent-guard.ts +531 -0
  28. package/src/source/source-edit-request.ts +22 -0
  29. package/src/source/syntactic-prop-specs.ts +296 -0
  30. package/src/source/ts-ast.ts +171 -0
  31. package/src/source/utility-class-support.ts +169 -0
  32. package/src/source/write-component-default.ts +133 -0
  33. 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
+ }