@astryxdesign/cli 0.4.5-canary.d990fb5 → 0.4.5-canary.ee6d68d

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.
@@ -12,6 +12,7 @@
12
12
  * @property {any[]} [components]
13
13
  * @property {{description?: string}} [usage]
14
14
  * @property {any} [theming]
15
+ * @property {string} [import] set when the doc states its own import specifier
15
16
  */
16
17
  /**
17
18
  * Options object for `loadDocs`, matching its declared parameter shape (used
@@ -29,6 +30,12 @@
29
30
  * @property {string|undefined} issuesUrl
30
31
  * @property {import('../../foundation/integrations/integrations.mjs').LoadedIntegration|null} integration
31
32
  */
33
+ /**
34
+ * What ownership shaping needs from an owner. The core and legacy-external
35
+ * paths synthesize a bare `{package, sourcePath}` rather than resolving a full
36
+ * {@link ComponentOwner}, so everything past those two is optional here.
37
+ * @typedef {Partial<ComponentOwner> & {package: string, sourcePath: string|null}} OwnershipSubject
38
+ */
32
39
  /**
33
40
  * A back-compat external package discovered via `pkg.astryx.docs`.
34
41
  * @typedef {{name: string, category: string, docsDir: string}} ExternalPackageRef
@@ -152,15 +159,12 @@ export function extractProps(docs: LoadedComponentDoc): any[];
152
159
  * swizzleable source file exists for the owner). Existing doc fields (name,
153
160
  * usage, props, …) are preserved.
154
161
  * @param {LoadedComponentDoc} docs
155
- * @param {{package: string, sourcePath: string|null}} owner
162
+ * @param {OwnershipSubject} owner
156
163
  * @param {string} componentName
157
164
  * @param {string} coreDir
158
165
  * @returns {import('./component.type.mjs').ComponentDetailResponse['data']}
159
166
  */
160
- export function withOwnership(docs: LoadedComponentDoc, owner: {
161
- package: string;
162
- sourcePath: string | null;
163
- }, componentName: string, coreDir: string): import("./component.type.mjs").ComponentDetailResponse["data"];
167
+ export function withOwnership(docs: LoadedComponentDoc, owner: OwnershipSubject, componentName: string, coreDir: string): import("./component.type.mjs").ComponentDetailResponse["data"];
164
168
  /**
165
169
  * When the caller asked for "Code" but the resolved doc is for "CodeBlock"
166
170
  * (parent), scope the response to just the matching sub-component. Returns the
@@ -193,6 +197,10 @@ export type LoadedComponentDoc = {
193
197
  description?: string;
194
198
  } | undefined;
195
199
  theming?: any;
200
+ /**
201
+ * set when the doc states its own import specifier
202
+ */
203
+ import?: string | undefined;
196
204
  };
197
205
  /**
198
206
  * Options object for `loadDocs`, matching its declared parameter shape (used
@@ -215,6 +223,15 @@ export type ComponentOwner = {
215
223
  issuesUrl: string | undefined;
216
224
  integration: import("../../foundation/integrations/integrations.mjs").LoadedIntegration | null;
217
225
  };
226
+ /**
227
+ * What ownership shaping needs from an owner. The core and legacy-external
228
+ * paths synthesize a bare `{package, sourcePath}` rather than resolving a full
229
+ * {@link ComponentOwner}, so everything past those two is optional here.
230
+ */
231
+ export type OwnershipSubject = Partial<ComponentOwner> & {
232
+ package: string;
233
+ sourcePath: string | null;
234
+ };
218
235
  /**
219
236
  * A back-compat external package discovered via `pkg.astryx.docs`.
220
237
  */
@@ -18,6 +18,8 @@
18
18
  * deduped, so each leaf stays a thin projection.
19
19
  */
20
20
 
21
+ import * as fs from 'node:fs';
22
+ import * as path from 'node:path';
21
23
  import {ERROR_CODES} from '../../foundation/response/error-codes.mjs';
22
24
  import {findCoreDir, discoverExternalPackages} from '../../foundation/fs/paths.mjs';
23
25
  import {
@@ -48,6 +50,7 @@ export {CORE_PACKAGE};
48
50
  * @property {any[]} [components]
49
51
  * @property {{description?: string}} [usage]
50
52
  * @property {any} [theming]
53
+ * @property {string} [import] set when the doc states its own import specifier
51
54
  */
52
55
 
53
56
  /**
@@ -68,6 +71,13 @@ export {CORE_PACKAGE};
68
71
  * @property {import('../../foundation/integrations/integrations.mjs').LoadedIntegration|null} integration
69
72
  */
70
73
 
74
+ /**
75
+ * What ownership shaping needs from an owner. The core and legacy-external
76
+ * paths synthesize a bare `{package, sourcePath}` rather than resolving a full
77
+ * {@link ComponentOwner}, so everything past those two is optional here.
78
+ * @typedef {Partial<ComponentOwner> & {package: string, sourcePath: string|null}} OwnershipSubject
79
+ */
80
+
71
81
  /**
72
82
  * A back-compat external package discovered via `pkg.astryx.docs`.
73
83
  * @typedef {{name: string, category: string, docsDir: string}} ExternalPackageRef
@@ -357,7 +367,7 @@ export function extractProps(docs) {
357
367
  * swizzleable source file exists for the owner). Existing doc fields (name,
358
368
  * usage, props, …) are preserved.
359
369
  * @param {LoadedComponentDoc} docs
360
- * @param {{package: string, sourcePath: string|null}} owner
370
+ * @param {OwnershipSubject} owner
361
371
  * @param {string} componentName
362
372
  * @param {string} coreDir
363
373
  * @returns {import('./component.type.mjs').ComponentDetailResponse['data']}
@@ -366,15 +376,53 @@ export function withOwnership(docs, owner, componentName, coreDir) {
366
376
  const importSpec =
367
377
  owner.package === CORE_PACKAGE
368
378
  ? resolveImportPath(coreDir, componentName)
369
- : `${owner.package}/${componentName}`;
379
+ : resolveIntegrationImportPath(owner, componentName);
370
380
  return /** @type {any} */ ({
371
381
  ...docs,
372
382
  package: owner.package,
373
- import: importSpec,
383
+ // A doc file may state its own specifier, e.g. when one entry point exports
384
+ // several components. Only fall back to a resolved one when it does not.
385
+ import: docs.import ?? importSpec,
374
386
  sourceAvailable: owner.sourcePath != null,
375
387
  });
376
388
  }
377
389
 
390
+ /**
391
+ * Resolve the specifier an integration component is imported from, against the
392
+ * owning package's `exports` map.
393
+ *
394
+ * A component lives in a directory that need not share its name — several
395
+ * components can be exported from one entry point — so the specifier has to
396
+ * come from the directory the doc file sits in, checked against `exports`,
397
+ * rather than from the component name. Falls back to the package root when the
398
+ * directory is not an exported subpath, matching what a consumer would have to
399
+ * write by hand.
400
+ *
401
+ * @param {OwnershipSubject} owner
402
+ * @param {string} componentName
403
+ * @returns {string}
404
+ */
405
+ function resolveIntegrationImportPath(owner, componentName) {
406
+ const packageDir = owner.integration?.__packageDir;
407
+ const directory = owner.docPath
408
+ ? path.basename(path.dirname(owner.docPath))
409
+ : componentName;
410
+ if (!packageDir) {
411
+ return owner.package;
412
+ }
413
+ try {
414
+ const manifest = JSON.parse(
415
+ fs.readFileSync(path.join(packageDir, 'package.json'), 'utf-8'),
416
+ );
417
+ if (manifest.exports?.[`./${directory}`]) {
418
+ return `${owner.package}/${directory}`;
419
+ }
420
+ } catch {
421
+ // An unreadable or malformed manifest is not worth failing a lookup over.
422
+ }
423
+ return owner.package;
424
+ }
425
+
378
426
  /**
379
427
  * When the caller asked for "Code" but the resolved doc is for "CodeBlock"
380
428
  * (parent), scope the response to just the matching sub-component. Returns the
@@ -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
  }