@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,531 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R3F hierarchy reparent hardening — the PURE plan for "move this JSX element into
|
|
3
|
+
* that one", covering R1 (scope safety), R2 (world-transform preservation) and R3
|
|
4
|
+
* (destination legality) before a single byte is written.
|
|
5
|
+
*
|
|
6
|
+
* Why here: `plan-source-edit.ts` is the ONE place both tiers (dev server and hosted
|
|
7
|
+
* browser) route a source edit through, and its `reparent` case used to be a bare
|
|
8
|
+
* `reparentElement` — a textual line-block move that is sound only between two parents
|
|
9
|
+
* in the same lexical scope with identical accumulated transforms. The moment the
|
|
10
|
+
* hierarchy invites arbitrary drags it is unsound three ways, all of them SILENT:
|
|
11
|
+
*
|
|
12
|
+
* 1. Lexical capture — an element authored inside a component function may reference
|
|
13
|
+
* that function's own bindings (`color={tint}`, a `ref`, a mapped variable).
|
|
14
|
+
* Textually moving it elsewhere produces code that does not compile, or compiles
|
|
15
|
+
* against a DIFFERENT binding with the same name.
|
|
16
|
+
* 2. Transform re-basing — a verbatim move preserves the authored `position` tuple,
|
|
17
|
+
* not the world position. Between identity parents those are the same thing;
|
|
18
|
+
* between transformed parents the object teleports on the next mount.
|
|
19
|
+
* 3. Boundary blindness — a self-closing destination (which is what nearly every R3F
|
|
20
|
+
* component callsite is) made `reparentElement` insert a PRECEDING SIBLING instead
|
|
21
|
+
* of a child, and a destination rendered N times would author the element once and
|
|
22
|
+
* render it N times.
|
|
23
|
+
*
|
|
24
|
+
* The response to all three is the same and is the anti-shim rule: REFUSE, naming the
|
|
25
|
+
* thing that made the move unsafe. This module never rewrites an expression, never
|
|
26
|
+
* renames a binding, and never writes a computed value over an authored one — the
|
|
27
|
+
* literal-vs-dynamic guard is as sacred here as at the gizmo.
|
|
28
|
+
*
|
|
29
|
+
* R4 (one transaction) falls out of living here: every rule is decided before the first
|
|
30
|
+
* edit, a refusal returns the input string identity-equal, and an accepted move returns
|
|
31
|
+
* ONE new whole-file string (tuple rewrites + slot opening + the move), which the
|
|
32
|
+
* caller's existing whole-file snapshot inverse turns into a single undo step.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import ts from 'typescript';
|
|
36
|
+
import { parseAuthoringTsx } from './ts-ast';
|
|
37
|
+
import {
|
|
38
|
+
analyzeJsxAttributes,
|
|
39
|
+
findTagEnd,
|
|
40
|
+
isNumberTupleLiteral,
|
|
41
|
+
openChildrenSlot,
|
|
42
|
+
reparentElement,
|
|
43
|
+
type StructEditResult,
|
|
44
|
+
writePropChange,
|
|
45
|
+
} from './writer';
|
|
46
|
+
|
|
47
|
+
import type { ReparentChannel, ReparentRebase } from '@volter/editor-sdk/source-authoring';
|
|
48
|
+
export type { ReparentChannel, ReparentRebase } from '@volter/editor-sdk/source-authoring';
|
|
49
|
+
|
|
50
|
+
const CHANNELS: readonly ReparentChannel[] = ['position', 'rotation', 'scale'];
|
|
51
|
+
const NUMBER_RE = /^-?\d+(\.\d+)?$/;
|
|
52
|
+
const EPS = 1e-6;
|
|
53
|
+
|
|
54
|
+
export interface ReparentOptions {
|
|
55
|
+
readonly rebase?: ReparentRebase | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* How many live objects the DESTINATION's oid resolves to. More than one means the
|
|
58
|
+
* destination is authored once and rendered N times (a `.map`ped callsite, or a
|
|
59
|
+
* component definition with N instances), so the moved element would be authored
|
|
60
|
+
* once and rendered N times too — R3 refuses that with the count.
|
|
61
|
+
*/
|
|
62
|
+
readonly destinationInstances?: number | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* The same count for the element being MOVED. More than one is the mirrored case and
|
|
65
|
+
* is ALLOWED (it is exactly the de-duplication refactor this feature exists for), so
|
|
66
|
+
* it produces a warning rather than a refusal.
|
|
67
|
+
*/
|
|
68
|
+
readonly sourceInstances?: number | undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ReparentPlan extends StructEditResult {
|
|
72
|
+
/** The named reason the move was refused. Present iff `changed` is false and the
|
|
73
|
+
* input was left byte-identical because a rule said no. */
|
|
74
|
+
readonly error?: string;
|
|
75
|
+
/** Applied, but with something the author should know (R3's mirrored case). */
|
|
76
|
+
readonly warning?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------- source lookup
|
|
80
|
+
|
|
81
|
+
export type JsxElementNode = ts.JsxElement | ts.JsxSelfClosingElement;
|
|
82
|
+
|
|
83
|
+
/** The JSX element whose OPENING tag starts exactly at `offset` — the position basis
|
|
84
|
+
* every `OidEntry` uses. */
|
|
85
|
+
export function jsxElementAt(sf: ts.SourceFile, offset: number): JsxElementNode | null {
|
|
86
|
+
let found: JsxElementNode | null = null;
|
|
87
|
+
const visit = (node: ts.Node): void => {
|
|
88
|
+
if (found) return;
|
|
89
|
+
// A node that ends at or before `offset`, or begins after it, cannot contain an
|
|
90
|
+
// element STARTING at `offset` — prune both directions.
|
|
91
|
+
if (node.getEnd() <= offset || node.getStart(sf) > offset) return;
|
|
92
|
+
if (ts.isJsxSelfClosingElement(node) && node.getStart(sf) === offset) {
|
|
93
|
+
found = node;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (ts.isJsxElement(node) && node.openingElement.getStart(sf) === offset) {
|
|
97
|
+
found = node;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
ts.forEachChild(node, visit);
|
|
101
|
+
};
|
|
102
|
+
visit(sf);
|
|
103
|
+
return found;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function openingTagOf(node: JsxElementNode): ts.JsxOpeningElement | ts.JsxSelfClosingElement {
|
|
107
|
+
return ts.isJsxElement(node) ? node.openingElement : node;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function tagTextOf(node: JsxElementNode, sf: ts.SourceFile): string {
|
|
111
|
+
return openingTagOf(node).tagName.getText(sf);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function contains(outer: ts.Node, sf: ts.SourceFile, offset: number): boolean {
|
|
115
|
+
return outer.getStart(sf) <= offset && offset < outer.getEnd();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ------------------------------------------------------------- R1: scope safety
|
|
119
|
+
|
|
120
|
+
/** Every name a declaration binds, flattened through destructuring patterns. */
|
|
121
|
+
function boundNames(name: ts.BindingName, into: Set<string>): void {
|
|
122
|
+
if (ts.isIdentifier(name)) {
|
|
123
|
+
into.add(name.text);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
for (const element of name.elements) {
|
|
127
|
+
if (ts.isOmittedExpression(element)) continue;
|
|
128
|
+
boundNames(element.name, into);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function declaredByStatement(statement: ts.Statement, into: Set<string>): void {
|
|
133
|
+
if (ts.isVariableStatement(statement)) {
|
|
134
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
135
|
+
boundNames(declaration.name, into);
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (ts.isFunctionDeclaration(statement) && statement.name) into.add(statement.name.text);
|
|
140
|
+
else if (ts.isClassDeclaration(statement) && statement.name) into.add(statement.name.text);
|
|
141
|
+
else if (ts.isImportDeclaration(statement) && statement.importClause) {
|
|
142
|
+
const clause = statement.importClause;
|
|
143
|
+
if (clause.name) into.add(clause.name.text);
|
|
144
|
+
const bindings = clause.namedBindings;
|
|
145
|
+
if (bindings && ts.isNamespaceImport(bindings)) into.add(bindings.name.text);
|
|
146
|
+
else if (bindings && ts.isNamedImports(bindings)) {
|
|
147
|
+
for (const element of bindings.elements) into.add(element.name.text);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The names a single scope-introducing node binds, or null when it is not a scope. */
|
|
153
|
+
function scopeBindings(node: ts.Node): Set<string> | null {
|
|
154
|
+
const names = new Set<string>();
|
|
155
|
+
if (ts.isSourceFile(node)) {
|
|
156
|
+
for (const statement of node.statements) declaredByStatement(statement, names);
|
|
157
|
+
return names;
|
|
158
|
+
}
|
|
159
|
+
if (ts.isBlock(node) || ts.isModuleBlock(node) || ts.isCaseBlock(node)) {
|
|
160
|
+
const statements = ts.isCaseBlock(node)
|
|
161
|
+
? node.clauses.flatMap((clause) => [...clause.statements])
|
|
162
|
+
: [...node.statements];
|
|
163
|
+
for (const statement of statements) declaredByStatement(statement, names);
|
|
164
|
+
return names;
|
|
165
|
+
}
|
|
166
|
+
if (
|
|
167
|
+
ts.isFunctionDeclaration(node) ||
|
|
168
|
+
ts.isFunctionExpression(node) ||
|
|
169
|
+
ts.isArrowFunction(node) ||
|
|
170
|
+
ts.isMethodDeclaration(node) ||
|
|
171
|
+
ts.isConstructorDeclaration(node) ||
|
|
172
|
+
ts.isGetAccessor(node) ||
|
|
173
|
+
ts.isSetAccessor(node)
|
|
174
|
+
) {
|
|
175
|
+
for (const parameter of node.parameters) boundNames(parameter.name, names);
|
|
176
|
+
if ((ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) && node.name) {
|
|
177
|
+
names.add(node.name.text);
|
|
178
|
+
}
|
|
179
|
+
return names;
|
|
180
|
+
}
|
|
181
|
+
if (ts.isForStatement(node) || ts.isForOfStatement(node) || ts.isForInStatement(node)) {
|
|
182
|
+
const initializer = node.initializer;
|
|
183
|
+
if (initializer && ts.isVariableDeclarationList(initializer)) {
|
|
184
|
+
for (const declaration of initializer.declarations) boundNames(declaration.name, names);
|
|
185
|
+
}
|
|
186
|
+
return names;
|
|
187
|
+
}
|
|
188
|
+
if (ts.isCatchClause(node)) {
|
|
189
|
+
if (node.variableDeclaration) boundNames(node.variableDeclaration.name, names);
|
|
190
|
+
return names;
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The innermost enclosing scope of `from` that binds `name`, or null when nothing
|
|
196
|
+
* in the lexical chain does (a global, or an unresolved reference — either way not a
|
|
197
|
+
* capture this move can break). */
|
|
198
|
+
export function bindingScopeOf(name: string, from: ts.Node): ts.Node | null {
|
|
199
|
+
let cursor: ts.Node | undefined = from;
|
|
200
|
+
while (cursor) {
|
|
201
|
+
const bindings = scopeBindings(cursor);
|
|
202
|
+
if (bindings?.has(name)) return cursor;
|
|
203
|
+
cursor = cursor.parent;
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The nearest named function around `node` — what a refusal calls the owner. */
|
|
209
|
+
export function owningFunctionName(node: ts.Node, sf: ts.SourceFile): string {
|
|
210
|
+
let cursor: ts.Node | undefined = node;
|
|
211
|
+
while (cursor) {
|
|
212
|
+
if (ts.isFunctionDeclaration(cursor) && cursor.name) return cursor.name.text;
|
|
213
|
+
if (
|
|
214
|
+
(ts.isArrowFunction(cursor) || ts.isFunctionExpression(cursor)) &&
|
|
215
|
+
cursor.parent &&
|
|
216
|
+
ts.isVariableDeclaration(cursor.parent) &&
|
|
217
|
+
ts.isIdentifier(cursor.parent.name)
|
|
218
|
+
) {
|
|
219
|
+
return cursor.parent.name.text;
|
|
220
|
+
}
|
|
221
|
+
cursor = cursor.parent;
|
|
222
|
+
}
|
|
223
|
+
return ts.isSourceFile(node) ? 'this module' : `this scope (line ${lineOf(node, sf)})`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function lineOf(node: ts.Node, sf: ts.SourceFile): number {
|
|
227
|
+
return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The identifiers the element's JSX subtree REFERENCES from outside itself — the R1
|
|
232
|
+
* input. Same machinery as the contract analyzer (a plain `ts` walk over the TSX AST).
|
|
233
|
+
*
|
|
234
|
+
* It counts an identifier it is unsure about, so an unrecognized shape refuses rather
|
|
235
|
+
* than moves. The one deliberate subtraction is names the subtree binds FOR ITSELF —
|
|
236
|
+
* without it, a `.map((angle) => …)` inside the moved element would read as a capture
|
|
237
|
+
* of the outer scope and nothing containing a mapped list could ever move. Stated
|
|
238
|
+
* plainly, because it is not free: a name bound inside the subtree in one place and
|
|
239
|
+
* captured from outside it in another is subtracted too, and that capture is missed.
|
|
240
|
+
* A tighter answer needs a real binder, not a name set; this is the honest limit of
|
|
241
|
+
* the cheap one.
|
|
242
|
+
*/
|
|
243
|
+
export function referencedIdentifiers(node: JsxElementNode): Map<string, ts.Node> {
|
|
244
|
+
const referenced = new Map<string, ts.Node>();
|
|
245
|
+
const localBindings = new Set<string>();
|
|
246
|
+
|
|
247
|
+
const visit = (current: ts.Node): void => {
|
|
248
|
+
// Types name types, not values — a moved element's `as THREE.Group` is not a
|
|
249
|
+
// runtime capture.
|
|
250
|
+
if (ts.isTypeNode(current) || ts.isTypeParameterDeclaration(current)) return;
|
|
251
|
+
|
|
252
|
+
const nested = scopeBindings(current);
|
|
253
|
+
if (nested && current !== node) for (const name of nested) localBindings.add(name);
|
|
254
|
+
|
|
255
|
+
if (ts.isPropertyAccessExpression(current)) {
|
|
256
|
+
visit(current.expression); // `a.b.c` references `a` only
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (ts.isJsxAttribute(current)) {
|
|
260
|
+
if (current.initializer) visit(current.initializer);
|
|
261
|
+
return; // the attribute NAME is not an identifier reference
|
|
262
|
+
}
|
|
263
|
+
if (ts.isPropertyAssignment(current)) {
|
|
264
|
+
if (ts.isComputedPropertyName(current.name)) visit(current.name);
|
|
265
|
+
visit(current.initializer);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (ts.isJsxOpeningElement(current) || ts.isJsxSelfClosingElement(current)) {
|
|
269
|
+
// A lowercase host tag (`<mesh>`) is an intrinsic element, not a binding. A
|
|
270
|
+
// capitalized or dotted one resolves through the lexical scope like any value.
|
|
271
|
+
const tag = current.tagName;
|
|
272
|
+
const root = ts.isPropertyAccessExpression(tag) ? leftmostIdentifier(tag) : tag;
|
|
273
|
+
if (ts.isIdentifier(root) && /^[A-Z_$]/.test(root.text) && !referenced.has(root.text)) {
|
|
274
|
+
referenced.set(root.text, root);
|
|
275
|
+
}
|
|
276
|
+
for (const property of current.attributes.properties) visit(property);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (ts.isJsxClosingElement(current)) return;
|
|
280
|
+
if (ts.isIdentifier(current)) {
|
|
281
|
+
if (!referenced.has(current.text)) referenced.set(current.text, current);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
ts.forEachChild(current, visit);
|
|
285
|
+
};
|
|
286
|
+
visit(node);
|
|
287
|
+
|
|
288
|
+
for (const name of localBindings) referenced.delete(name);
|
|
289
|
+
return referenced;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function leftmostIdentifier(node: ts.PropertyAccessExpression): ts.Node {
|
|
293
|
+
let cursor: ts.Node = node;
|
|
294
|
+
while (ts.isPropertyAccessExpression(cursor)) cursor = cursor.expression;
|
|
295
|
+
return cursor;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// -------------------------------------------------- R3: destination legality
|
|
299
|
+
|
|
300
|
+
type ChildrenSlot = 'paired' | 'expandable';
|
|
301
|
+
|
|
302
|
+
/** Does a component DEFINED IN THIS FILE render whatever children a callsite gives it?
|
|
303
|
+
* Proven, never guessed: it either names `children` (destructured or `props.children`)
|
|
304
|
+
* or spreads its props object onto a host element. */
|
|
305
|
+
function componentForwardsChildren(sf: ts.SourceFile, name: string): boolean | 'undefined' {
|
|
306
|
+
const definition = findComponentDefinition(sf, name);
|
|
307
|
+
if (!definition) return 'undefined';
|
|
308
|
+
const parameter = definition.parameters[0];
|
|
309
|
+
const spreadable = new Set<string>();
|
|
310
|
+
let bindsChildren = false;
|
|
311
|
+
if (parameter) {
|
|
312
|
+
if (ts.isIdentifier(parameter.name)) spreadable.add(parameter.name.text);
|
|
313
|
+
else if (ts.isObjectBindingPattern(parameter.name)) {
|
|
314
|
+
for (const element of parameter.name.elements) {
|
|
315
|
+
const propertyName = (element.propertyName ?? element.name).getText(sf);
|
|
316
|
+
if (propertyName === 'children') bindsChildren = true;
|
|
317
|
+
if (element.dotDotDotToken && ts.isIdentifier(element.name)) {
|
|
318
|
+
spreadable.add(element.name.text);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (bindsChildren) return true;
|
|
324
|
+
|
|
325
|
+
let forwards = false;
|
|
326
|
+
const visit = (node: ts.Node): void => {
|
|
327
|
+
if (forwards) return;
|
|
328
|
+
if (ts.isJsxSpreadAttribute(node)) {
|
|
329
|
+
const expression = node.expression;
|
|
330
|
+
if (ts.isIdentifier(expression) && spreadable.has(expression.text)) forwards = true;
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (ts.isPropertyAccessExpression(node) && node.name.text === 'children') {
|
|
334
|
+
if (ts.isIdentifier(node.expression) && spreadable.has(node.expression.text)) {
|
|
335
|
+
forwards = true;
|
|
336
|
+
}
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
ts.forEachChild(node, visit);
|
|
340
|
+
};
|
|
341
|
+
if (definition.body) visit(definition.body);
|
|
342
|
+
return forwards;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function findComponentDefinition(
|
|
346
|
+
sf: ts.SourceFile,
|
|
347
|
+
name: string,
|
|
348
|
+
): ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression | null {
|
|
349
|
+
let found: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression | null = null;
|
|
350
|
+
const visit = (node: ts.Node): void => {
|
|
351
|
+
if (found) return;
|
|
352
|
+
if (ts.isFunctionDeclaration(node) && node.name?.text === name) {
|
|
353
|
+
found = node;
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (
|
|
357
|
+
ts.isVariableDeclaration(node) &&
|
|
358
|
+
ts.isIdentifier(node.name) &&
|
|
359
|
+
node.name.text === name &&
|
|
360
|
+
node.initializer &&
|
|
361
|
+
(ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))
|
|
362
|
+
) {
|
|
363
|
+
found = node.initializer;
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
ts.forEachChild(node, visit);
|
|
367
|
+
};
|
|
368
|
+
visit(sf);
|
|
369
|
+
return found;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ------------------------------------------------------------------- the plan
|
|
373
|
+
|
|
374
|
+
function fmt(value: number): string {
|
|
375
|
+
const rounded = Math.round(value * 1e4) / 1e4;
|
|
376
|
+
return Object.is(rounded, -0) ? '0' : String(rounded);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function tupleText(values: readonly number[]): string {
|
|
380
|
+
return `[${values.map(fmt).join(', ')}]`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Plan a same-file reparent. `elementStart` and `parentStart` are the `<` offsets the
|
|
385
|
+
* OID index resolves to; `options` carries what only the live scene knows.
|
|
386
|
+
*
|
|
387
|
+
* Returns the input string unchanged (identity, so a caller comparing bytes sees no
|
|
388
|
+
* diff) plus a named `error` for every refusal.
|
|
389
|
+
*/
|
|
390
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one gate per numbered rule, in the order the spec states them; splitting it would hide the "plan everything before writing anything" invariant this function exists to hold.
|
|
391
|
+
export function planReparent(
|
|
392
|
+
code: string,
|
|
393
|
+
elementStart: number,
|
|
394
|
+
parentStart: number,
|
|
395
|
+
options: ReparentOptions = {},
|
|
396
|
+
): ReparentPlan {
|
|
397
|
+
const refuse = (error: string): ReparentPlan => ({ code, changed: false, error });
|
|
398
|
+
const sf = parseAuthoringTsx('reparent.tsx', code);
|
|
399
|
+
const element = jsxElementAt(sf, elementStart);
|
|
400
|
+
const destination = jsxElementAt(sf, parentStart);
|
|
401
|
+
if (!element) return refuse('the moved element is no longer at its indexed source position.');
|
|
402
|
+
if (!destination) {
|
|
403
|
+
return refuse('the destination is no longer at its indexed source position.');
|
|
404
|
+
}
|
|
405
|
+
const elementTag = tagTextOf(element, sf);
|
|
406
|
+
const destinationTag = tagTextOf(destination, sf);
|
|
407
|
+
if (element === destination) return refuse(`<${elementTag}> cannot be its own parent.`);
|
|
408
|
+
if (contains(element, sf, parentStart)) {
|
|
409
|
+
return refuse(`<${destinationTag}> is inside <${elementTag}> — a move there would be a cycle.`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// --- R3: destination legality ------------------------------------------------
|
|
413
|
+
const destinationInstances = options.destinationInstances;
|
|
414
|
+
if (destinationInstances !== undefined && destinationInstances > 1) {
|
|
415
|
+
return refuse(
|
|
416
|
+
`${destinationTag} renders ${destinationInstances} times; moving this into it would ` +
|
|
417
|
+
`author it once and render it ${destinationInstances} times.`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
const slot: ChildrenSlot = ts.isJsxElement(destination) ? 'paired' : 'expandable';
|
|
421
|
+
if (/^[A-Z_$]/.test(destinationTag) || destinationTag.includes('.')) {
|
|
422
|
+
const forwards = componentForwardsChildren(sf, destinationTag);
|
|
423
|
+
if (forwards === 'undefined') {
|
|
424
|
+
return refuse(
|
|
425
|
+
`${destinationTag} is not defined in this file — the editor cannot prove it renders ` +
|
|
426
|
+
'its children into the world, so it refuses to move anything inside it.',
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
if (!forwards) {
|
|
430
|
+
return refuse(
|
|
431
|
+
`${destinationTag} does not render children — nothing can be authored inside it.`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// --- R1: scope safety --------------------------------------------------------
|
|
437
|
+
for (const [name, reference] of referencedIdentifiers(element)) {
|
|
438
|
+
const scope = bindingScopeOf(name, reference);
|
|
439
|
+
if (!scope || contains(scope, sf, parentStart)) continue;
|
|
440
|
+
return refuse(
|
|
441
|
+
`<${elementTag}> references '${name}', which only exists inside ` +
|
|
442
|
+
`${owningFunctionName(scope, sf)}.`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// --- R2: world-transform preservation ---------------------------------------
|
|
447
|
+
const rebase = options.rebase;
|
|
448
|
+
const tagEnd = findTagEnd(code, elementStart);
|
|
449
|
+
if (tagEnd < 0) return refuse(`<${elementTag}>'s opening tag could not be read.`);
|
|
450
|
+
const attrs = analyzeJsxAttributes(code, elementStart, tagEnd);
|
|
451
|
+
const pending: Array<{ channel: ReparentChannel; values: readonly number[]; scalar: boolean }> =
|
|
452
|
+
[];
|
|
453
|
+
for (const channel of CHANNELS) {
|
|
454
|
+
const values = rebase?.[channel];
|
|
455
|
+
if (!values) continue;
|
|
456
|
+
const attr = attrs.find((candidate) => candidate.name === channel);
|
|
457
|
+
if (attr) {
|
|
458
|
+
const scalar =
|
|
459
|
+
(attr.isExpression && NUMBER_RE.test(attr.rawValue)) ||
|
|
460
|
+
rebase?.scalarChannels?.includes(channel) === true;
|
|
461
|
+
const tuple = attr.isExpression && isNumberTupleLiteral(attr.rawValue);
|
|
462
|
+
if (!scalar && !tuple) {
|
|
463
|
+
return refuse(
|
|
464
|
+
`preserving <${elementTag}>'s world transform needs a new ${channel}, but ` +
|
|
465
|
+
`${channel} is bound to the expression {${attr.rawValue}} — the editor will not ` +
|
|
466
|
+
'write a tuple over an expression.',
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
pending.push({ channel, values, scalar });
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
pending.push({
|
|
473
|
+
channel,
|
|
474
|
+
values,
|
|
475
|
+
scalar: rebase?.scalarChannels?.includes(channel) === true,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// --- R4: everything above decided; only now does anything get written --------
|
|
480
|
+
let next = code;
|
|
481
|
+
for (const { channel, values, scalar } of pending) {
|
|
482
|
+
const uniform =
|
|
483
|
+
values.length === 3 &&
|
|
484
|
+
Math.abs((values[0] ?? 0) - (values[1] ?? 0)) < EPS &&
|
|
485
|
+
Math.abs((values[1] ?? 0) - (values[2] ?? 0)) < EPS;
|
|
486
|
+
const text =
|
|
487
|
+
scalar && (values.length === 1 || uniform) ? fmt(values[0] ?? 0) : tupleText(values);
|
|
488
|
+
const written = writePropChange(next, elementStart, channel, text, {
|
|
489
|
+
addIfMissing: true,
|
|
490
|
+
allowShapeUpgrade: scalar,
|
|
491
|
+
});
|
|
492
|
+
if (written.dynamic) {
|
|
493
|
+
return refuse(
|
|
494
|
+
`preserving <${elementTag}>'s world transform needs a new ${channel}, but ${channel} ` +
|
|
495
|
+
'is expression-bound.',
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
next = written.code;
|
|
499
|
+
}
|
|
500
|
+
// Every tuple rewrite lands strictly inside the moved element's own opening tag, and
|
|
501
|
+
// the destination cannot be inside that element (the cycle gate above), so a
|
|
502
|
+
// destination LATER in the file shifts by exactly the bytes those rewrites added.
|
|
503
|
+
let movedStart = elementStart;
|
|
504
|
+
const destinationStart =
|
|
505
|
+
parentStart > elementStart ? parentStart + (next.length - code.length) : parentStart;
|
|
506
|
+
|
|
507
|
+
if (slot === 'expandable') {
|
|
508
|
+
const opened = openChildrenSlot(next, destinationStart);
|
|
509
|
+
if (!opened.changed) {
|
|
510
|
+
return refuse(`<${destinationTag} /> could not be opened to receive a child.`);
|
|
511
|
+
}
|
|
512
|
+
const grew = opened.code.length - next.length;
|
|
513
|
+
next = opened.code;
|
|
514
|
+
if (movedStart > destinationStart) movedStart += grew;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const moved = reparentElement(next, movedStart, destinationStart);
|
|
518
|
+
if (!moved.changed) return refuse(`<${elementTag}> could not be moved into <${destinationTag}>.`);
|
|
519
|
+
|
|
520
|
+
const sourceInstances = options.sourceInstances;
|
|
521
|
+
const warning =
|
|
522
|
+
sourceInstances !== undefined && sourceInstances > 1
|
|
523
|
+
? `${elementTag} was authored once and rendered ${sourceInstances} times; moving it out ` +
|
|
524
|
+
`leaves ${sourceInstances - 1} of those renders without it.`
|
|
525
|
+
: undefined;
|
|
526
|
+
return {
|
|
527
|
+
code: moved.code,
|
|
528
|
+
changed: true,
|
|
529
|
+
...(warning ? { warning } : {}),
|
|
530
|
+
};
|
|
531
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SOURCE-EDIT REQUEST — the one wire shape a source edit is stated in, and
|
|
3
|
+
* deliberately its own file.
|
|
4
|
+
*
|
|
5
|
+
* It sits between two programs that must NOT share a type graph: the browser
|
|
6
|
+
* editor (`ui-source/source-write-backend.ts`, which posts it) and the NODE
|
|
7
|
+
* dev server (`vite-plugin-ui-oid.ts`'s middleware and `ui-source/plan-source-edit.ts`,
|
|
8
|
+
* which plan and apply it). Until 2026-09-20 both sides imported the type from
|
|
9
|
+
* `source-write-backend.ts` — and that module value-imports `files/project-files.ts`,
|
|
10
|
+
* which reaches `storage/` and from there the editor SHELL STORE, so
|
|
11
|
+
* `tsconfig.server.json` compiled 231 browser modules including 36 React ones to
|
|
12
|
+
* typecheck a Node program (the fourth blind review's "the Node server's type graph
|
|
13
|
+
* now reaching the browser app"; the config's own header recorded the 310 errors
|
|
14
|
+
* that forced its `paths` and `jsx` settings).
|
|
15
|
+
*
|
|
16
|
+
* So: the REQUEST lives here, importing nothing but its two sibling AST types, and
|
|
17
|
+
* `source-write-backend.ts` re-exports it for the browser callers that already name
|
|
18
|
+
* it. Anything added to this file must stay free of browser and Node imports alike —
|
|
19
|
+
* it is read by both.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type { PreparedSourceEdit, SourceEditRequest, StructReparentContext } from '@volter/editor-sdk/source-authoring';
|