@volter/editor-react 0.5.66 → 0.5.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/editor-react",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "AGPL-3.0-only",
5
- "version": "0.5.66",
5
+ "version": "0.5.68",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
@@ -38,8 +38,8 @@
38
38
  "serving": "./dist-node/serving.mjs"
39
39
  },
40
40
  "dependencies": {
41
- "@volter/editor-sdk": "0.5.66",
42
- "@volter/editor-project": "0.5.66",
41
+ "@volter/editor-sdk": "0.5.68",
42
+ "@volter/editor-project": "0.5.68",
43
43
  "typescript": "5.9.3"
44
44
  },
45
45
  "peerDependencies": {
@@ -97,6 +97,7 @@ import {
97
97
  transformSource,
98
98
  } from '../src/source/oid-transform';
99
99
  import { planDeleteStory, planRenameStory, planSaveStory } from '../src/source/plan-csf-story';
100
+ import { writePropMember } from '../src/source/prop-member-writer';
100
101
  import { planComponentExtraction } from '../src/source/plan-extract-component';
101
102
  import { planComponentFork } from '../src/source/plan-fork-component';
102
103
  import { planCreateClassRule } from '../src/source/plan-named-style';
@@ -1004,6 +1005,13 @@ function handleProp(
1004
1005
  if (!entry) return { body: { changed: false, error: 'unknown oid' } };
1005
1006
  const src = readSettledSource(entry.file);
1006
1007
  const off = lineColToOffset(src, entry.line, entry.col);
1008
+ // A dotted prop is one member of an object-literal prop (`params.threshold`): the index names
1009
+ // members that way (`authoredProps`), and the member writer rewrites that literal alone.
1010
+ if (prop.includes('.')) {
1011
+ const member = writePropMember(src, off, prop.split('.'), value, { addIfMissing: body['addIfMissing'] === true });
1012
+ if (member.changed) writeEditableSource(entry.file, member.code);
1013
+ return { body: { changed: member.changed, dynamic: member.dynamic, file: entry.file } };
1014
+ }
1007
1015
  // `value: null` is the REMOVAL sentinel, the same shape `/__ui-source/write`
1008
1016
  // uses for `removeStyle` (D-A3). Removing a prop attribute is how the
1009
1017
  // inspector reverts a prop to the component's declared default.
@@ -1530,7 +1538,7 @@ export function uiOidPlugin(
1530
1538
  // Captured by `configureServer` below, read by `transform` — the dev
1531
1539
  // server's own `ModuleGraph` is what lets `declaredSurfaceForProjectFileViaGraph`
1532
1540
  // reach a root entry's surface through a NON-entry file's importer chain
1533
- // (a child component with no direct fiber/world3d-react import of its
1541
+ // (a child component with no direct fiber import of its
1534
1542
  // own). `undefined` in a build/SSR context without `configureServer`
1535
1543
  // (e.g. a one-off transform in a test) degrades to the direct-entry-only
1536
1544
  // answer, same as before this existed.
@@ -11,6 +11,7 @@
11
11
  * an unrelated line keeps existing ids stable. The `data-oid` lives ONLY in the
12
12
  * transformed output, never on disk (the writer edits the original source instead).
13
13
  */
14
+ import { isMemberLiteral, memberKey } from './prop-member-writer';
14
15
  import ts from 'typescript';
15
16
  import {
16
17
  environmentBindingsByElement,
@@ -44,24 +45,28 @@ export type {
44
45
  } from './r3f-particle-binding';
45
46
  export type { PhysicsChannel, R3fPhysicsBinding } from './r3f-physics-binding';
46
47
 
48
+ /** A number as the writer reads one: `3`, `-3`, `+0.5` (the writer's `NUMBER_LITERAL_SOURCE`). */
49
+ function signedNumericLiteral(expression: ts.Expression): boolean {
50
+ return (
51
+ ts.isNumericLiteral(expression) ||
52
+ (ts.isPrefixUnaryExpression(expression) &&
53
+ (expression.operator === ts.SyntaxKind.MinusToken || expression.operator === ts.SyntaxKind.PlusToken) &&
54
+ ts.isNumericLiteral(expression.operand))
55
+ );
56
+ }
57
+
47
58
  function literalJsxExpression(expression: ts.Expression | undefined): boolean {
48
59
  if (!expression) return false;
49
60
  if (
50
61
  ts.isStringLiteral(expression) ||
51
- ts.isNumericLiteral(expression) ||
62
+ signedNumericLiteral(expression) ||
52
63
  expression.kind === ts.SyntaxKind.TrueKeyword ||
53
64
  expression.kind === ts.SyntaxKind.FalseKeyword
54
65
  ) {
55
66
  return true;
56
67
  }
57
68
  if (!ts.isArrayLiteralExpression(expression)) return false;
58
- return expression.elements.every(
59
- (element) =>
60
- ts.isNumericLiteral(element) ||
61
- (ts.isPrefixUnaryExpression(element) &&
62
- element.operator === ts.SyntaxKind.MinusToken &&
63
- ts.isNumericLiteral(element.operand)),
64
- );
69
+ return expression.elements.every(signedNumericLiteral);
65
70
  }
66
71
 
67
72
  function authoredPropsOf(
@@ -83,10 +88,49 @@ function authoredPropsOf(
83
88
  valueText: initializer.expression?.getText(sourceFile) ?? '',
84
89
  literal: literalJsxExpression(initializer.expression),
85
90
  },
91
+ ...(initializer.expression ? objectMembersOf(name, initializer.expression, sourceFile) : []),
86
92
  ];
87
93
  });
88
94
  }
89
95
 
96
+ /**
97
+ * The members of an object-literal prop, each as its own authored prop under a dotted name
98
+ * (`params.threshold`, `params.bands.0.gain` through an array inside the object), with its own
99
+ * `literal`: `params={{ bank: BANK, program: 48 }}` is computed as a whole, and its `program` is
100
+ * still a literal a gesture may rewrite (`prop-member-writer.ts`). A top-level array stays one
101
+ * prop (a position tuple is written whole).
102
+ */
103
+ function objectMembersOf(
104
+ prefix: string,
105
+ expression: ts.Expression,
106
+ sourceFile: ts.SourceFile,
107
+ ): NonNullable<OidEntry['authoredProps']> {
108
+ let node = expression;
109
+ while (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) node = node.expression;
110
+ const entries: NonNullable<OidEntry['authoredProps']> = [];
111
+ const visit = (name: string, value: ts.Expression): void => {
112
+ let inner = value;
113
+ while (ts.isParenthesizedExpression(inner) || ts.isAsExpression(inner) || ts.isSatisfiesExpression(inner)) inner = inner.expression;
114
+ if (ts.isObjectLiteralExpression(inner) || ts.isArrayLiteralExpression(inner)) {
115
+ walk(name, inner);
116
+ return;
117
+ }
118
+ entries.push({ name, valueText: value.getText(sourceFile), literal: isMemberLiteral(value) });
119
+ };
120
+ const walk = (name: string, container: ts.ObjectLiteralExpression | ts.ArrayLiteralExpression): void => {
121
+ if (ts.isArrayLiteralExpression(container)) {
122
+ container.elements.forEach((element, index) => visit(`${name}.${index}`, element));
123
+ return;
124
+ }
125
+ for (const property of container.properties) {
126
+ const key = memberKey(property);
127
+ if (key !== null && ts.isPropertyAssignment(property)) visit(`${name}.${key}`, property.initializer);
128
+ }
129
+ };
130
+ if (ts.isObjectLiteralExpression(node)) walk(prefix, node);
131
+ return entries;
132
+ }
133
+
90
134
  /** Persistent oid store: signature -> oid (kept stable across re-transforms). */
91
135
  export class OidStore {
92
136
  private bySig = new Map<string, string>();
@@ -991,8 +1035,7 @@ export function sourceDialectEvidence(code: string): SourceDialectEvidence {
991
1035
  const scanned = stripCommentsForScan(code);
992
1036
  return {
993
1037
  reconcilerImport:
994
- scanned.includes('@react-three/fiber') ||
995
- scanned.includes('@vgai/game-runtime/world3d-react'),
1038
+ scanned.includes('@react-three/fiber'),
996
1039
  r3fOnlyTags: distinctTags(scanned, R3F_ONLY_INTRINSIC_RE),
997
1040
  domOnlyTags: distinctTags(scanned, DOM_ONLY_INTRINSIC_RE),
998
1041
  };
@@ -1013,8 +1056,8 @@ export function sourceProvesR3f(evidence: SourceDialectEvidence): boolean {
1013
1056
  * reaches it. `'three'` stamps `userData-oid`; `'canvas'`/`'dom'` stamp
1014
1057
  * `data-oid`. It is resolved from OUTSIDE the file, exactly where the fact
1015
1058
  * lives, which is what makes it right for a world whose entry
1016
- * default-exports a component and imports NEITHER `@react-three/fiber` nor
1017
- * `@vgai/game-runtime/world3d-react` (legal: R3F's global JSX-intrinsics augmentation,
1059
+ * default-exports a component and does not import `@react-three/fiber`
1060
+ * (legal: R3F's global JSX-intrinsics augmentation,
1018
1061
  * or an entry composed entirely of already-typed child components, needs no
1019
1062
  * import in THIS file). Every call site threads it from a context that
1020
1063
  * resolved it per file: `binding-resolver.ts`'s per-surface resolvers, the
@@ -1326,6 +1369,21 @@ function reactFragmentNames(sf: ts.SourceFile): Set<string> {
1326
1369
  * into game DOM. That preserves per-instance source identity without inserting
1327
1370
  * a wrapper into the native tree. Returns the transformed code + index entries.
1328
1371
  */
1372
+ /** Whether any JSX (an element, a self-closing element or a fragment) appears under `node`. */
1373
+ function containsJsx(node: ts.Node): boolean {
1374
+ let found = false;
1375
+ const visit = (child: ts.Node): void => {
1376
+ if (found) return;
1377
+ if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child) || ts.isJsxFragment(child)) {
1378
+ found = true;
1379
+ return;
1380
+ }
1381
+ ts.forEachChild(child, visit);
1382
+ };
1383
+ ts.forEachChild(node, visit);
1384
+ return found;
1385
+ }
1386
+
1329
1387
  export function transformSource(
1330
1388
  code: string,
1331
1389
  file: string,
@@ -1420,8 +1478,14 @@ export function transformSource(
1420
1478
  const pushComponent = (
1421
1479
  name: string | undefined,
1422
1480
  parameters: ts.NodeArray<ts.ParameterDeclaration> | undefined,
1481
+ body: ts.Node,
1423
1482
  ): boolean => {
1424
1483
  if (!name || !/^[A-Z]/.test(name) || !parameters) return false;
1484
+ // A capitalised function that renders no JSX is not a component, whatever its name: a table
1485
+ // helper like `const PATCH = (key) => ({ key, … })` had its parameter rewritten into props
1486
+ // destructuring and received an object of its string's characters. Nothing in it is stamped,
1487
+ // and it consumes no transport props, so it is left as written.
1488
+ if (!containsJsx(body)) return false;
1425
1489
  // Component membership participates in the stable OID signature on every
1426
1490
  // JSX dialect. Three/Canvas callsites inject the transport props, while
1427
1491
  // every project component consumes them: a component declared on a DOM
@@ -1461,14 +1525,14 @@ export function transformSource(
1461
1525
  const visit = (node: ts.Node): void => {
1462
1526
  let pushed = false;
1463
1527
  if (ts.isFunctionDeclaration(node) && node.name)
1464
- pushed = pushComponent(node.name.text, node.parameters);
1528
+ pushed = pushComponent(node.name.text, node.parameters, node);
1465
1529
  else if (
1466
1530
  ts.isVariableDeclaration(node) &&
1467
1531
  ts.isIdentifier(node.name) &&
1468
1532
  node.initializer &&
1469
1533
  (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))
1470
1534
  ) {
1471
- pushed = pushComponent(node.name.text, node.initializer.parameters);
1535
+ pushed = pushComponent(node.name.text, node.initializer.parameters, node.initializer);
1472
1536
  }
1473
1537
 
1474
1538
  if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
@@ -349,5 +349,8 @@ export function planSourceEdit(
349
349
  request.ensureImport.kind,
350
350
  ).code
351
351
  : result.code;
352
- return planned(entry.file, src, code, { changed: result.changed });
352
+ return planned(entry.file, src, code, {
353
+ changed: result.changed,
354
+ ...(result.error ? { error: result.error } : {}),
355
+ });
353
356
  }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * ONE MEMBER OF AN OBJECT-LITERAL PROP, written at the call site: `params.threshold` in
3
+ * `<Device params={{ threshold: -20, ratio: 1.5 }} />`, `params.bands.0.gain` through an array.
4
+ * The same rule every other prop write keeps: only a literal is rewritten (a member bound to an
5
+ * expression answers `dynamic`), and nothing else in the file moves. A missing member is added
6
+ * to its object when asked (`addIfMissing`); `null` takes the member out.
7
+ *
8
+ * `writer.ts` scans strings so the page can run it; this one parses the expression with
9
+ * TypeScript and runs only in the serving process.
10
+ */
11
+
12
+ import ts from 'typescript';
13
+ import { analyzeJsxAttributes, findTagEnd } from './writer';
14
+
15
+ export interface MemberEditResult {
16
+ readonly code: string;
17
+ readonly changed: boolean;
18
+ readonly dynamic: boolean;
19
+ }
20
+
21
+ const NUMBER = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
22
+
23
+ /** A literal written into an object: numbers and booleans bare, anything else a quoted string. */
24
+ function formatMember(value: string): string {
25
+ if (NUMBER.test(value.trim()) || value === 'true' || value === 'false') return value.trim();
26
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
27
+ }
28
+
29
+ /** Whether an expression is a literal a gesture may replace: string, number (signed), boolean. */
30
+ export function isMemberLiteral(node: ts.Expression): boolean {
31
+ return (
32
+ ts.isStringLiteral(node) ||
33
+ ts.isNoSubstitutionTemplateLiteral(node) ||
34
+ ts.isNumericLiteral(node) ||
35
+ node.kind === ts.SyntaxKind.TrueKeyword ||
36
+ node.kind === ts.SyntaxKind.FalseKeyword ||
37
+ (ts.isPrefixUnaryExpression(node) &&
38
+ (node.operator === ts.SyntaxKind.MinusToken || node.operator === ts.SyntaxKind.PlusToken) &&
39
+ ts.isNumericLiteral(node.operand))
40
+ );
41
+ }
42
+
43
+ /** A property's key as written (`threshold`, `'high-pass'`), or `null` when it is computed. */
44
+ export function memberKey(property: ts.ObjectLiteralElementLike): string | null {
45
+ if (!ts.isPropertyAssignment(property)) return null;
46
+ const name = property.name;
47
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
48
+ return null;
49
+ }
50
+
51
+ function unwrap(node: ts.Expression): ts.Expression {
52
+ let current = node;
53
+ while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isSatisfiesExpression(current)) {
54
+ current = current.expression;
55
+ }
56
+ return current;
57
+ }
58
+
59
+ export function writePropMember(
60
+ code: string,
61
+ elementStart: number,
62
+ path: readonly string[],
63
+ newValue: string | null,
64
+ opts?: { addIfMissing?: boolean },
65
+ ): MemberEditResult {
66
+ const unchanged = { code, changed: false, dynamic: false };
67
+ const [propName, ...members] = path;
68
+ if (!propName || members.length === 0 || code[elementStart] !== '<') return unchanged;
69
+ const tagEnd = findTagEnd(code, elementStart);
70
+ if (tagEnd < 0) return unchanged;
71
+ const attr = analyzeJsxAttributes(code, elementStart, tagEnd).find((candidate) => candidate.name === propName);
72
+ if (!attr || !attr.isExpression) return { code, changed: false, dynamic: attr !== undefined };
73
+ // Parse the expression alone, in parentheses (a bare `{ … }` is a block): an offset in it,
74
+ // less one, is an offset from `attr.valueStart`.
75
+ const text = `(${code.slice(attr.valueStart, attr.valueEnd)})`;
76
+ const file = ts.createSourceFile('member.tsx', text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
77
+ const statement = file.statements[0];
78
+ if (!statement || !ts.isExpressionStatement(statement)) return { code, changed: false, dynamic: true };
79
+ let container = unwrap(statement.expression);
80
+ const splice = (start: number, end: number, insert: string): MemberEditResult => {
81
+ const next = code.slice(0, attr.valueStart + start - 1) + insert + code.slice(attr.valueStart + end - 1);
82
+ return { code: next, changed: next !== code, dynamic: false };
83
+ };
84
+ for (let depth = 0; depth < members.length; depth++) {
85
+ const key = members[depth]!;
86
+ const last = depth === members.length - 1;
87
+ if (ts.isArrayLiteralExpression(container)) {
88
+ const element = /^\d+$/.test(key) ? container.elements[Number(key)] : undefined;
89
+ if (!element) return { code, changed: false, dynamic: false };
90
+ if (!last) {
91
+ container = unwrap(element);
92
+ continue;
93
+ }
94
+ if (newValue === null || !isMemberLiteral(element)) return { code, changed: false, dynamic: !isMemberLiteral(element) };
95
+ return splice(element.getStart(file), element.end, formatMember(newValue));
96
+ }
97
+ if (!ts.isObjectLiteralExpression(container)) return { code, changed: false, dynamic: true };
98
+ const properties = container.properties;
99
+ const property = properties.find((candidate) => memberKey(candidate) === key);
100
+ // A spread or computed key could supply this member: the source does not say which value wins.
101
+ const opaque = properties.some((candidate) => ts.isSpreadAssignment(candidate) || (ts.isPropertyAssignment(candidate) && memberKey(candidate) === null));
102
+ if (!property || !ts.isPropertyAssignment(property)) {
103
+ if (!last || newValue === null || !opts?.addIfMissing) return unchanged;
104
+ if (opaque) return { code, changed: false, dynamic: true };
105
+ const entry = `${/^[A-Za-z_$][\w$]*$/.test(key) ? key : `'${key}'`}: ${formatMember(newValue)}`;
106
+ const lastProperty = properties[properties.length - 1];
107
+ if (!lastProperty) return splice(container.getStart(file) + 1, container.end - 1, ` ${entry} `);
108
+ return splice(lastProperty.end, lastProperty.end, `, ${entry}`);
109
+ }
110
+ const value = unwrap(property.initializer);
111
+ if (!last) {
112
+ container = value;
113
+ continue;
114
+ }
115
+ if (!isMemberLiteral(property.initializer)) return { code, changed: false, dynamic: true };
116
+ if (newValue !== null) return splice(property.initializer.getStart(file), property.initializer.end, formatMember(newValue));
117
+ // Take the member out with the comma that separates it from its neighbour.
118
+ const index = properties.indexOf(property);
119
+ const next = properties[index + 1];
120
+ const previous = properties[index - 1];
121
+ if (next) return splice(property.getStart(file), next.getStart(file), '');
122
+ if (previous) return splice(previous.end, properties.hasTrailingComma ? property.end + 1 : property.end, '');
123
+ return splice(container.getStart(file) + 1, container.end - 1, '');
124
+ }
125
+ return unchanged;
126
+ }
@@ -135,7 +135,7 @@ export function visibleR3fContracts(code: string, file: string): Map<string, R3f
135
135
  * `export default World` or `export default function World() {…}`.
136
136
  *
137
137
  * A three root's entry module default-exports its component (D26,
138
- * `r3fRootFactory`) — the same contract dom roots use. That component IS the
138
+ * the editor's `roots/r3f-root.tsx`) — the same contract dom roots use. That component IS the
139
139
  * adapter root, so the single-spatial-root rule must not fire on it: a world
140
140
  * legitimately returns a fragment of lights, scenery and players.
141
141
  */
@@ -1189,6 +1189,24 @@ const NUMBER_LITERAL_RE = new RegExp(`^${NUMBER_LITERAL_SOURCE}$`);
1189
1189
  * plain signed number literal (`[x, 1, 0]`, `[f(), 0]`) is DYNAMIC and stays
1190
1190
  * behind the literal-vs-dynamic guard.
1191
1191
  */
1192
+ /**
1193
+ * A NUMBER-POINT literal (`{ x: 1.2, y: 0.8 }`): an object whose properties are all plain
1194
+ * identifiers with number values — the `PointData` a 2D scene's `scale` or `pivot` takes, and the
1195
+ * one object shape this writer treats as a literal (a Pixi tag's non-uniform `scale`).
1196
+ */
1197
+ export function isNumberPointLiteral(raw: string): boolean {
1198
+ const trimmed = raw.trim();
1199
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return false;
1200
+ const inner = trimmed.slice(1, -1).trim();
1201
+ if (inner === '') return false;
1202
+ const parts = inner.split(',').map((p) => p.trim());
1203
+ if (parts.length > 1 && parts[parts.length - 1] === '') parts.pop();
1204
+ return parts.every((part) => {
1205
+ const match = /^([A-Za-z_$][\w$]*)\s*:\s*(.+)$/.exec(part);
1206
+ return match !== null && NUMBER_LITERAL_RE.test(match[2]!.trim());
1207
+ });
1208
+ }
1209
+
1192
1210
  export function isNumberTupleLiteral(raw: string): boolean {
1193
1211
  const trimmed = raw.trim();
1194
1212
  if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return false;
@@ -1294,7 +1312,10 @@ function makeAttr(
1294
1312
  ): JsxAttrInfo {
1295
1313
  const rawValue = code.slice(parsed.valueStart, parsed.valueEnd).trim();
1296
1314
  const isLiteral =
1297
- !parsed.isExpression || LITERAL_EXPR_RE.test(rawValue) || isNumberTupleLiteral(rawValue);
1315
+ !parsed.isExpression ||
1316
+ LITERAL_EXPR_RE.test(rawValue) ||
1317
+ isNumberTupleLiteral(rawValue) ||
1318
+ isNumberPointLiteral(rawValue);
1298
1319
  return {
1299
1320
  name,
1300
1321
  valueStart: parsed.valueStart,
@@ -1351,7 +1372,7 @@ function formatPropReplacement(
1351
1372
  if (!attr.isExpression) return newValue; // string attribute — content between the quotes
1352
1373
  const raw = attr.rawValue;
1353
1374
  if (NUMBER_LITERAL_RE.test(raw)) {
1354
- if (isNumberTupleLiteral(newValue)) {
1375
+ if (isNumberTupleLiteral(newValue) || isNumberPointLiteral(newValue)) {
1355
1376
  // R2: a tuple replacing a plain number is a SHAPE CHANGE — allowed only
1356
1377
  // under the explicit opt-in; otherwise refused outright (never the
1357
1378
  // quoted-string fallback below, which would clobber `scale={1.5}` with
@@ -1369,6 +1390,12 @@ function formatPropReplacement(
1369
1390
  if (isNumberTupleLiteral(raw)) {
1370
1391
  return isNumberTupleLiteral(newValue) ? newValue.trim() : null;
1371
1392
  }
1393
+ // A number-point literal is replaced by another point, or by a plain number (a scale made
1394
+ // uniform again); anything else is a shape it cannot take.
1395
+ if (isNumberPointLiteral(raw)) {
1396
+ if (isNumberPointLiteral(newValue)) return newValue.trim();
1397
+ return NUMBER_LITERAL_RE.test(newValue.trim()) ? newValue.trim() : null;
1398
+ }
1372
1399
  if (/^"[^"]*"$/.test(raw)) return `"${newValue.replace(/"/g, '\\"')}"`;
1373
1400
  return `'${newValue.replace(/'/g, "\\'")}'`;
1374
1401
  }
@@ -1381,6 +1408,7 @@ function formatPropReplacement(
1381
1408
  function formatNewAttr(propName: string, newValue: string): string {
1382
1409
  if (
1383
1410
  isNumberTupleLiteral(newValue) ||
1411
+ isNumberPointLiteral(newValue) ||
1384
1412
  NUMBER_LITERAL_RE.test(newValue) ||
1385
1413
  newValue === 'true' ||
1386
1414
  newValue === 'false'
@@ -1537,6 +1565,8 @@ export function removePropAttribute(
1537
1565
  export interface StructEditResult {
1538
1566
  code: string;
1539
1567
  changed: boolean;
1568
+ /** Why an op that could not apply refused, when it can say. */
1569
+ error?: string;
1540
1570
  }
1541
1571
 
1542
1572
  /** Expand a JSX element at `elementStart` to its full LINE block (leading whitespace on the
@@ -1721,13 +1751,11 @@ export function insertChildElement(
1721
1751
  tag = 'div',
1722
1752
  snippet?: string,
1723
1753
  ): StructEditResult {
1724
- const closingTagStart = closingTagStartOf(code, parentStart);
1725
- if (closingTagStart == null) return { code, changed: false };
1754
+ const tagEnd = findTagEnd(code, parentStart);
1755
+ if (tagEnd < 0) return { code, changed: false };
1726
1756
  let parentLineStart = parentStart;
1727
1757
  while (parentLineStart > 0 && code[parentLineStart - 1] !== '\n') parentLineStart--;
1728
1758
  const parentIndent = code.slice(parentLineStart, parentStart).match(/^(\s*)/)?.[1] ?? '';
1729
- let insertPos = closingTagStart;
1730
- while (insertPos > 0 && code[insertPos - 1] !== '\n') insertPos--;
1731
1759
  const childIndent = `${parentIndent} `;
1732
1760
  let child: string;
1733
1761
  if (snippet !== undefined) {
@@ -1740,6 +1768,26 @@ export function insertChildElement(
1740
1768
  } else {
1741
1769
  child = `${childIndent}<${tag} />\n`;
1742
1770
  }
1771
+ // A self-closing parent (`<Transport tempo={92} />`) has no children yet: it opens, takes the
1772
+ // child, and closes on its own line. Walking back to a `<` from its end would find the parent's
1773
+ // OWN tag and put the child beside it.
1774
+ if (code[tagEnd - 1] === '/') {
1775
+ const name = code.slice(parentStart + 1, tagEnd).match(/^([A-Za-z0-9_.$:-]+)/)?.[1];
1776
+ if (!name) return { code, changed: false };
1777
+ let open = tagEnd - 1;
1778
+ while (open > parentStart && /\s/.test(code[open - 1] ?? '')) open--;
1779
+ const opened = `${code.slice(0, open)}>\n${child}${parentIndent}</${name}>`;
1780
+ return { code: opened + code.slice(tagEnd + 1), changed: true };
1781
+ }
1782
+ const closingTagStart = closingTagStartOf(code, parentStart);
1783
+ if (closingTagStart == null) return { code, changed: false };
1784
+ // The child goes on its own line before the closing tag: at that line's start when the closing
1785
+ // tag begins its line, else (a one-line parent, `<Clip>…</Clip>`) broken onto a new line there.
1786
+ let insertPos = closingTagStart;
1787
+ while (insertPos > 0 && (code[insertPos - 1] === ' ' || code[insertPos - 1] === '\t')) insertPos--;
1788
+ if (insertPos > 0 && code[insertPos - 1] !== '\n') {
1789
+ return { code: `${code.slice(0, closingTagStart)}\n${child}${parentIndent}${code.slice(closingTagStart)}`, changed: true };
1790
+ }
1743
1791
  return { code: code.slice(0, insertPos) + child + code.slice(insertPos), changed: true };
1744
1792
  }
1745
1793
 
@@ -1763,6 +1811,19 @@ export function insertSiblingElement(
1763
1811
  ): StructEditResult {
1764
1812
  const trimmed = snippet.trim();
1765
1813
  if (!trimmed.startsWith('<')) return { code, changed: false };
1814
+ // A sibling is legal only beside a JSX CHILD: an element whose text is preceded by a tag's `>`
1815
+ // (not an arrow's `=>`) or an expression child's `}`. The lone element a component returns has no JSX parent, and a
1816
+ // second element beside it is a syntax error, never an insert.
1817
+ let before = elementStart - 1;
1818
+ while (before >= 0 && /\s/.test(code[before]!)) before--;
1819
+ const arrowBody = code[before] === '>' && code[before - 1] === '=';
1820
+ if (before < 0 || arrowBody || (code[before] !== '>' && code[before] !== '}')) {
1821
+ return {
1822
+ code,
1823
+ changed: false,
1824
+ error: 'this element is not a JSX child (a lone returned element), so it can have no sibling.',
1825
+ };
1826
+ }
1766
1827
  const r = lineBlockRange(code, elementStart);
1767
1828
  if (!r) return { code, changed: false };
1768
1829
  const indent = code.slice(r.lineStart, elementStart).match(/^(\s*)/)?.[1] ?? '';