@astryxdesign/cli 0.4.5-canary.4aacbbe → 0.4.5-canary.5d8ece1

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.
@@ -303,4 +303,158 @@ describe('drop-xds-prefix-imports', () => {
303
303
  expect(output).not.toContain('AstryxButton');
304
304
  expect(output).not.toContain('XDSButton');
305
305
  });
306
+
307
+ describe('JSX whitespace preservation on element-name rename', () => {
308
+ it('preserves the space between text and a following {expression} inside return (...)', async () => {
309
+ const input = [
310
+ `import {XDSText} from '@xds/core';`,
311
+ `export function App({name}) {`,
312
+ ` return (`,
313
+ ` <XDSText>hello {name} world</XDSText>`,
314
+ ` );`,
315
+ `}`,
316
+ ].join('\n');
317
+ const output = await applyTransform(input);
318
+ // Element is renamed...
319
+ expect(output).toContain('<Text>');
320
+ expect(output).toContain('</Text>');
321
+ expect(output).not.toContain('XDSText');
322
+ // ...and the whitespace around the {expression} survives (the bug
323
+ // collapsed `{name} world` to `{name}world`).
324
+ expect(output).toContain('hello {name} world');
325
+ expect(output).not.toContain('{name}world');
326
+ });
327
+
328
+ it('preserves whitespace with multiple {expressions} adjacent to text', async () => {
329
+ const input = [
330
+ `import {XDSText} from '@xds/core';`,
331
+ `export function App({a, b}) {`,
332
+ ` return (`,
333
+ ` <XDSText>involving {a} in the {b} dataset</XDSText>`,
334
+ ` );`,
335
+ `}`,
336
+ ].join('\n');
337
+ const output = await applyTransform(input);
338
+ expect(output).toContain('involving {a} in the {b} dataset');
339
+ expect(output).toContain('<Text>');
340
+ expect(output).not.toContain('XDSText');
341
+ });
342
+
343
+ it('preserves whitespace across a multi-line JSX body', async () => {
344
+ const input = [
345
+ `import {XDSText} from '@xds/core';`,
346
+ `export function App({a, b}) {`,
347
+ ` return (`,
348
+ ` <XDSText>`,
349
+ ` involving {a} in the {b} dataset`,
350
+ ` </XDSText>`,
351
+ ` );`,
352
+ `}`,
353
+ ].join('\n');
354
+ const output = await applyTransform(input);
355
+ expect(output).toContain('involving {a} in the {b} dataset');
356
+ expect(output).toContain('<Text>');
357
+ expect(output).toContain('</Text>');
358
+ expect(output).not.toContain('XDSText');
359
+ });
360
+
361
+ it('renames both the opening and closing tag of the same element', async () => {
362
+ const input = [
363
+ `import {XDSCard} from '@xds/core';`,
364
+ `export const App = ({n}) => (<XDSCard>value {n} here</XDSCard>);`,
365
+ ].join('\n');
366
+ const output = await applyTransform(input);
367
+ expect(output).toContain('<Card>');
368
+ expect(output).toContain('</Card>');
369
+ expect(output).toContain('value {n} here');
370
+ expect(output).not.toContain('XDSCard');
371
+ });
372
+
373
+ it('preserves whitespace for multiple different elements in one return', async () => {
374
+ const input = [
375
+ `import {XDSCard, XDSText, XDSButton} from '@xds/core';`,
376
+ `export const App = ({n}) => (`,
377
+ ` <XDSCard>`,
378
+ ` <XDSText>hello {n} world</XDSText>`,
379
+ ` <XDSButton>click {n} here</XDSButton>`,
380
+ ` </XDSCard>`,
381
+ `);`,
382
+ ].join('\n');
383
+ const output = await applyTransform(input);
384
+ expect(output).toContain('import {Card, Text, Button}');
385
+ expect(output).toContain('<Card>');
386
+ expect(output).toContain('hello {n} world');
387
+ expect(output).toContain('click {n} here');
388
+ expect(output).not.toContain('XDS');
389
+ });
390
+
391
+ it('renames only the mapped segment of a member-expression tag name, preserving whitespace', async () => {
392
+ const input = [
393
+ `import {XDSMenu} from '@xds/core';`,
394
+ `export const App = ({x}) => (<XDSMenu.Item>pick {x} now</XDSMenu.Item>);`,
395
+ ].join('\n');
396
+ const output = await applyTransform(input);
397
+ // Only the mapped `XDSMenu` segment is renamed; `.Item` is preserved.
398
+ expect(output).toContain('<Menu.Item>');
399
+ expect(output).toContain('</Menu.Item>');
400
+ expect(output).toContain('pick {x} now');
401
+ expect(output).not.toContain('XDSMenu');
402
+ });
403
+
404
+ it('renames a self-closing element with attributes without altering the attributes', async () => {
405
+ const input = [
406
+ `import {XDSButton} from '@xds/core';`,
407
+ `export const App = () => <XDSButton label="x" onClick={fn} />;`,
408
+ ].join('\n');
409
+ const output = await applyTransform(input);
410
+ expect(output).toContain('<Button label="x" onClick={fn} />');
411
+ expect(output).not.toContain('XDSButton');
412
+ });
413
+
414
+ it('handles a JSX body with no whitespace to preserve', async () => {
415
+ const input = [
416
+ `import {XDSButton} from '@xds/core';`,
417
+ `export const App = ({label}) => <XDSButton>{label}</XDSButton>;`,
418
+ ].join('\n');
419
+ const output = await applyTransform(input);
420
+ expect(output).toContain('<Button>{label}</Button>');
421
+ expect(output).not.toContain('XDSButton');
422
+ });
423
+
424
+ it('preserves whitespace when the bare name is aliased on a collision', async () => {
425
+ const input = [
426
+ `import {XDSCodeBlock} from '@xds/core/CodeBlock';`,
427
+ `export function CodeBlock({code}) {`,
428
+ ` return (<XDSCodeBlock>run {code} now</XDSCodeBlock>);`,
429
+ `}`,
430
+ ].join('\n');
431
+ const output = await applyTransform(input);
432
+ expect(output).toContain('CodeBlock as AstryxCodeBlock');
433
+ expect(output).toContain('<AstryxCodeBlock>');
434
+ expect(output).toContain('</AstryxCodeBlock>');
435
+ expect(output).toContain('run {code} now');
436
+ });
437
+
438
+ it('keeps a custom local alias on the element tag and preserves whitespace', async () => {
439
+ const input = [
440
+ `import {XDSText as Txt} from '@xds/core';`,
441
+ `export const App = ({n}) => (<Txt>hello {n} world</Txt>);`,
442
+ ].join('\n');
443
+ const output = await applyTransform(input);
444
+ expect(output).toContain('Text as Txt');
445
+ expect(output).toContain('<Txt>hello {n} world</Txt>');
446
+ expect(output).not.toContain('XDSText');
447
+ });
448
+
449
+ it('is idempotent: a second pass over already-migrated output is a no-op', async () => {
450
+ const input = [
451
+ `import {XDSText} from '@xds/core';`,
452
+ `export const App = ({n}) => (<XDSText>a {n} b</XDSText>);`,
453
+ ].join('\n');
454
+ const once = await applyTransform(input);
455
+ const twice = await applyTransform(once);
456
+ expect(twice).toBe(once);
457
+ expect(once).toContain('a {n} b');
458
+ });
459
+ });
306
460
  });
@@ -346,18 +346,27 @@ export default function transformer(file, api) {
346
346
  }
347
347
  // Don't double-rewrite the import specifier we already handled.
348
348
  if (parent.type === 'ImportSpecifier') return;
349
+ // JSX element tag names are handled by a source-string splice after
350
+ // `toSource()` (see renameJsxElementNamesInSource). Mutating them here
351
+ // would dirty the enclosing JSXElement and make recast collapse whitespace
352
+ // around adjacent `{expressions}`. Under the tsx parser a JSX tag-name
353
+ // JSXIdentifier is a subtype of Identifier, so it surfaces in this
354
+ // `find(j.Identifier)` pass and must be explicitly skipped.
355
+ if (isJsxElementNameIdentifier(path)) return;
349
356
  path.node.name = newName;
350
357
  hasChanges = true;
351
358
  });
352
359
 
353
- // JSX element names: <XDSButton> -> <Button>
354
- root.find(j.JSXIdentifier).forEach((/** @type {any} */ path) => {
355
- const newName = localRenames.get(path.node.name);
356
- if (newName) {
357
- path.node.name = newName;
358
- hasChanges = true;
359
- }
360
- });
360
+ // JSX element names (`<XDSButton>` -> `<Button>`) are intentionally NOT
361
+ // renamed via AST mutation here. Mutating a JSXIdentifier in place dirties the
362
+ // enclosing JSXElement; when that element is the argument of a parenthesized
363
+ // `return ( <JSX/> )` (i.e. every React component body), recast declines a
364
+ // surgical child reprint and generically re-prints the whole return argument.
365
+ // Its generic JSXElement printer drops significant whitespace from JSXText
366
+ // that sits next to a `{expression}` -- so `hello {name} world` collapses to
367
+ // `hello {name}world`. Instead we splice the element-name renames directly
368
+ // into the emitted source string (below), which never re-prints the subtree.
369
+ // See renameJsxElementNamesInSource for the mechanism.
361
370
 
362
371
  // 5. Rename TS type references in generic type-argument positions.
363
372
  //
@@ -413,5 +422,98 @@ export default function transformer(file, api) {
413
422
  };
414
423
  renameTypeReferences(root.get().node);
415
424
 
416
- return hasChanges ? root.toSource() : undefined;
425
+ if (!hasChanges) return undefined;
426
+
427
+ // Emit the AST-based renames, then splice the JSX element-name renames onto
428
+ // the emitted string (see the note in the JSX section above for why element
429
+ // names cannot be renamed through the AST without collapsing whitespace).
430
+ const emitted = root.toSource();
431
+ return renameJsxElementNamesInSource(j, emitted, localRenames);
432
+ }
433
+
434
+ /**
435
+ * Rename JSX element tag names (`<XDSButton>` / `</XDSButton>`) by splicing the
436
+ * new names directly into the already-emitted source, so recast never re-prints
437
+ * the surrounding JSX subtree (which would strip whitespace next to
438
+ * `{expressions}`).
439
+ *
440
+ * Spans are computed by re-parsing `source` itself, so the offsets are always
441
+ * valid against the string we splice into -- no dependency on the original
442
+ * pre-transform offsets. Splices are applied right-to-left (descending start
443
+ * offset) so earlier offsets stay valid as we go.
444
+ *
445
+ * Only the identifier that IS the element's tag name is renamed:
446
+ * - `<Foo attr />` -> the `Foo` opening-name only (not `attr`)
447
+ * - `<Ns.Foo />` / `<Foo.Bar>`-> the specific member-expression identifier that
448
+ * maps in `renames`; the other part of the member name is preserved
449
+ *
450
+ * @param {any} j jscodeshift instance (already bound to the tsx parser)
451
+ * @param {string} source emitted source to rewrite
452
+ * @param {Map<string, string>} renames localName -> newLocalName
453
+ * @returns {string}
454
+ */
455
+ function renameJsxElementNamesInSource(j, source, renames) {
456
+ if (renames.size === 0) return source;
457
+
458
+ const root = j(source);
459
+
460
+ /** @type {Array<{start: number, end: number, name: string}>} */
461
+ const edits = [];
462
+
463
+ root.find(j.JSXIdentifier).forEach((/** @type {any} */ path) => {
464
+ const node = path.node;
465
+ const newName = renames.get(node.name);
466
+ if (!newName || newName === node.name) return;
467
+
468
+ // The identifier must BE an element tag name -- the `.name` of a
469
+ // JSXOpeningElement/JSXClosingElement, or the matching part of a
470
+ // JSXMemberExpression tag name. Attribute names, attribute values, and the
471
+ // non-matching half of a member-expression name must be left alone.
472
+ if (!isJsxElementNameIdentifier(path)) return;
473
+
474
+ if (typeof node.start !== 'number' || typeof node.end !== 'number') return;
475
+ edits.push({start: node.start, end: node.end, name: newName});
476
+ });
477
+
478
+ if (edits.length === 0) return source;
479
+
480
+ edits.sort((a, b) => b.start - a.start);
481
+
482
+ let out = source;
483
+ for (const {start, end, name} of edits) {
484
+ out = out.slice(0, start) + name + out.slice(end);
485
+ }
486
+ return out;
487
+ }
488
+
489
+ /**
490
+ * Is this JSXIdentifier path the tag name of its element (opening or closing),
491
+ * as opposed to an attribute name or a namespace/member-expression segment we
492
+ * should not rename? For member-expression tag names (`<Foo.Bar>`) we accept
493
+ * the identifier regardless of which segment it is -- the caller only reaches
494
+ * here for identifiers whose name is in the rename map, and each renamed
495
+ * identifier maps independently, so `<Foo.Bar>` renames only the segment(s)
496
+ * that actually map.
497
+ *
498
+ * @param {any} path jscodeshift path to a JSXIdentifier
499
+ * @returns {boolean}
500
+ */
501
+ function isJsxElementNameIdentifier(path) {
502
+ const parent = path.parent && path.parent.node;
503
+ if (!parent) return false;
504
+
505
+ if (
506
+ parent.type === 'JSXOpeningElement' ||
507
+ parent.type === 'JSXClosingElement'
508
+ ) {
509
+ return parent.name === path.node;
510
+ }
511
+
512
+ // `<Foo.Bar>` / `<A.B.C>`: the tag name is a JSXMemberExpression whose
513
+ // segments are JSXIdentifiers. Rename the specific matching segment only.
514
+ if (parent.type === 'JSXMemberExpression') {
515
+ return parent.object === path.node || parent.property === path.node;
516
+ }
517
+
518
+ return false;
417
519
  }