@astryxdesign/cli 0.4.4 → 0.4.5-canary.013e728
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/CHANGELOG.md +5 -0
- package/api/component/_adapter.d.mts +22 -5
- package/api/component/_adapter.mjs +51 -3
- package/assets/codemods/transforms/v0.1.0/__tests__/drop-xds-prefix-imports.test.mjs +245 -0
- package/assets/codemods/transforms/v0.1.0/drop-xds-prefix-imports.mjs +139 -13
- package/assets/docs/layout.doc.dense.mjs +275 -30
- package/assets/docs/layout.doc.mjs +412 -78
- package/assets/docs/theme.doc.mjs +3 -3
- package/assets/templates/blocks/components/BottomSheet/BottomSheetSnapPoints.doc.mjs +23 -0
- package/assets/templates/blocks/components/BottomSheet/BottomSheetSnapPoints.tsx +155 -0
- package/assets/templates/blocks/components/Selector/SelectorOptionDescriptions.doc.mjs +14 -0
- package/assets/templates/blocks/components/Selector/SelectorOptionDescriptions.tsx +76 -0
- package/assets/templates/pages/dashboard-cohort-funnel/page.tsx +914 -0
- package/assets/templates/pages/dashboard-cohort-funnel/template.doc.mjs +12 -0
- package/assets/templates/pages/dashboard-data/page.tsx +894 -0
- package/assets/templates/pages/dashboard-data/template.doc.mjs +13 -0
- package/assets/templates/pages/dashboard-executive-summary/page.tsx +874 -0
- package/assets/templates/pages/dashboard-executive-summary/template.doc.mjs +13 -0
- package/assets/templates/pages/dashboard-project-status/page.tsx +1139 -0
- package/assets/templates/pages/dashboard-project-status/template.doc.mjs +13 -0
- package/assets/templates/pages/dashboard-service-monitoring/page.tsx +1590 -0
- package/assets/templates/pages/dashboard-service-monitoring/template.doc.mjs +12 -0
- package/authoring/doctypes/template/type.ts +2 -0
- package/clients/cli/commands/component-ownership.test.mjs +63 -3
- package/clients/cli/commands/theme-build.doc.mjs +1 -1
- package/package.json +9 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# @xds/cli
|
|
2
2
|
|
|
3
|
+
# 0.4.5
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
3
7
|
# 0.4.4
|
|
4
8
|
|
|
5
9
|
#### New Components
|
|
@@ -16,6 +20,7 @@
|
|
|
16
20
|
Three more lessons came out of building a real app on it. The page now `<link>`s the theme's webfont from Google Fonts, because the theme _names_ Figtree and never loads it, so every viewer silently got the fallback stack (#5015 again). It imports the theme OBJECT and wraps in `<Theme theme={neutralTheme} mode="system">`, so light and dark follow the OS — the `data-astryx-theme` attribute alone scopes the stylesheet but cannot switch modes. And `#root:empty` carries a "Loading…" state, because ESM-from-CDN has real latency and a blank page reads as broken. Markup is `htm`, with a comment saying it is optional and `createElement` is the dependency-free alternative.
|
|
17
21
|
|
|
18
22
|
A recipe that is only read is a recipe that is only assumed to work, so CI renders it: `.github/scripts/cdn-template-smoke-test.mjs` scaffolds the page with the real CLI and opens it in headless Chromium, failing on any console error, page error or failed request, and on a page that loads without rendering.
|
|
23
|
+
|
|
19
24
|
- `astryx theme build` takes any number of theme files — `astryx theme build themes/*.ts` compiles them all in one process, so an app with several themes no longer hand-rolls a loop that re-enters the CLI once per theme. Outputs are byte-identical to the serial invocations; the run stops at the first failure and names the theme that failed. The CLI's Node floor (>=22.13) is now declared in `engines`, so a package manager can enforce it at install instead of the build failing later (#5121).
|
|
20
25
|
- `defineTheme`: `color.accent` accepts a `[light, dark]` tuple (#2279)
|
|
21
26
|
`ColorScaleConfig.accent` now takes either a single hex or a `[light, dark]` tuple, matching `TokenValue`. With a tuple, `expandColorScale` derives the light half of every generated `light-dark()` pair from the light seed's palettes and the dark half from the dark seed's, so each scheme gets a consistent derived palette (muted, on-accent, neutrals) instead of the `tokens['--color-accent']` workaround that skips scale generation. Single-string configs are unchanged, token for token. Also documents the precedence between `color` and `tokens` for accent-derived values: `tokens` entries win token by token, the `var(--color-accent)` reference tokens follow a `--color-accent` override at runtime, and the baked `--color-on-accent` stays derived from the `color.accent` seed.
|
|
@@ -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 {
|
|
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 {
|
|
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
|
-
:
|
|
379
|
+
: resolveIntegrationImportPath(owner, componentName);
|
|
370
380
|
return /** @type {any} */ ({
|
|
371
381
|
...docs,
|
|
372
382
|
package: owner.package,
|
|
373
|
-
|
|
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
|
|
@@ -212,4 +212,249 @@ describe('drop-xds-prefix-imports', () => {
|
|
|
212
212
|
const output = await applyTransform(input);
|
|
213
213
|
expect(output).toBe(input);
|
|
214
214
|
});
|
|
215
|
+
|
|
216
|
+
it('aliases a component wrapper collision in place (XDS -> Astryx), leaving the local fn untouched', async () => {
|
|
217
|
+
const input = [
|
|
218
|
+
`import {XDSLinkProvider} from '@xds/core/Link';`,
|
|
219
|
+
`export default function LinkProvider({children}) {`,
|
|
220
|
+
` return <XDSLinkProvider component={NextLink}>{children}</XDSLinkProvider>;`,
|
|
221
|
+
`}`,
|
|
222
|
+
].join('\n');
|
|
223
|
+
const output = await applyTransform(input);
|
|
224
|
+
// Import aliased in place, local wrapper + its name untouched.
|
|
225
|
+
expect(output).toContain('import {LinkProvider as AstryxLinkProvider}');
|
|
226
|
+
expect(output).toContain('function LinkProvider({children})');
|
|
227
|
+
// JSX rewritten to the alias -> no self-recursion.
|
|
228
|
+
expect(output).toContain('<AstryxLinkProvider component={NextLink}>');
|
|
229
|
+
expect(output).not.toContain('XDSLinkProvider');
|
|
230
|
+
// No duplicate bare LinkProvider import.
|
|
231
|
+
expect(output).not.toMatch(/import \{LinkProvider\}/);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('aliases a hook collision as use<Astryx>Name (not <Astryx>use)', async () => {
|
|
235
|
+
const input = [
|
|
236
|
+
`import {useXDSToast} from '@xds/core';`,
|
|
237
|
+
`function useToast() {`,
|
|
238
|
+
` return useXDSToast();`,
|
|
239
|
+
`}`,
|
|
240
|
+
].join('\n');
|
|
241
|
+
const output = await applyTransform(input);
|
|
242
|
+
expect(output).toContain('import {useToast as useAstryxToast}');
|
|
243
|
+
expect(output).toContain('function useToast()');
|
|
244
|
+
expect(output).toContain('return useAstryxToast();');
|
|
245
|
+
expect(output).not.toContain('useXDSToast');
|
|
246
|
+
// Must not produce the malformed `Astryxuse...` form.
|
|
247
|
+
expect(output).not.toContain('AstryxuseToast');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('aliases on collision with a local default import binding', async () => {
|
|
251
|
+
const input = [
|
|
252
|
+
`import Link from 'next/link';`,
|
|
253
|
+
`import {XDSLink} from '@xds/core';`,
|
|
254
|
+
`export const a = <XDSLink href="/" />;`,
|
|
255
|
+
`export const b = <Link href="/" />;`,
|
|
256
|
+
].join('\n');
|
|
257
|
+
const output = await applyTransform(input);
|
|
258
|
+
expect(output).toContain('import {Link as AstryxLink}');
|
|
259
|
+
expect(output).toContain(`import Link from 'next/link';`);
|
|
260
|
+
expect(output).toContain('<AstryxLink href="/" />');
|
|
261
|
+
expect(output).toContain('<Link href="/" />');
|
|
262
|
+
expect(output).not.toContain('XDSLink');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('aliases on collision with a local type alias', async () => {
|
|
266
|
+
const input = [
|
|
267
|
+
`import type {XDSTab} from '@xds/core';`,
|
|
268
|
+
`type Tab = {id: string};`,
|
|
269
|
+
`const active: XDSTab = null as any;`,
|
|
270
|
+
`const local: Tab = {id: '1'};`,
|
|
271
|
+
].join('\n');
|
|
272
|
+
const output = await applyTransform(input);
|
|
273
|
+
expect(output).toContain('Tab as AstryxTab');
|
|
274
|
+
expect(output).toContain('type Tab = {id: string};');
|
|
275
|
+
expect(output).toContain('const active: AstryxTab');
|
|
276
|
+
expect(output).toContain('const local: Tab');
|
|
277
|
+
expect(output).not.toContain('XDSTab');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('aliases on collision with a local interface', async () => {
|
|
281
|
+
const input = [
|
|
282
|
+
`import type {XDSTheme} from '@xds/core';`,
|
|
283
|
+
`interface Theme {}`,
|
|
284
|
+
`const t: XDSTheme = null as any;`,
|
|
285
|
+
`const local: Theme = {};`,
|
|
286
|
+
].join('\n');
|
|
287
|
+
const output = await applyTransform(input);
|
|
288
|
+
expect(output).toContain('Theme as AstryxTheme');
|
|
289
|
+
expect(output).toContain('interface Theme {}');
|
|
290
|
+
expect(output).toContain('const t: AstryxTheme');
|
|
291
|
+
expect(output).not.toContain('XDSTheme');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('still bare-renames when there is NO colliding local binding', async () => {
|
|
295
|
+
const input = [
|
|
296
|
+
`import {XDSButton} from '@xds/core';`,
|
|
297
|
+
`export const App = () => <XDSButton label="Hi" />;`,
|
|
298
|
+
].join('\n');
|
|
299
|
+
const output = await applyTransform(input);
|
|
300
|
+
// No local `Button` binding -> existing blind bare-rename behavior kept.
|
|
301
|
+
expect(output).toContain(`import {Button} from '@xds/core';`);
|
|
302
|
+
expect(output).toContain('<Button label="Hi" />');
|
|
303
|
+
expect(output).not.toContain('AstryxButton');
|
|
304
|
+
expect(output).not.toContain('XDSButton');
|
|
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
|
+
});
|
|
215
460
|
});
|
|
@@ -64,6 +64,22 @@ export const meta = {
|
|
|
64
64
|
|
|
65
65
|
const XDS_CORE_SOURCE = /^@xds\/core(\/.*)?$/;
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Compute a collision alias for an XDS-prefixed identifier by replacing the
|
|
69
|
+
* `XDS` segment IN PLACE with `Astryx` (as opposed to prefixing the bare name).
|
|
70
|
+
* This keeps hook names well-formed:
|
|
71
|
+
*
|
|
72
|
+
* XDSButton -> AstryxButton
|
|
73
|
+
* XDSLinkProvider -> AstryxLinkProvider
|
|
74
|
+
* useXDSToast -> useAstryxToast (NOT AstryxuseToast)
|
|
75
|
+
*
|
|
76
|
+
* Only the first `XDS` occurrence is replaced, matching how `bareName` strips a
|
|
77
|
+
* single leading prefix.
|
|
78
|
+
*/
|
|
79
|
+
function aliasName(/** @type {any} */ name) {
|
|
80
|
+
return name.replace('XDS', 'Astryx');
|
|
81
|
+
}
|
|
82
|
+
|
|
67
83
|
/**
|
|
68
84
|
* Compute the bare (unprefixed) name for an XDS-prefixed identifier.
|
|
69
85
|
* Returns null if the name is not XDS-prefixed in a renameable way.
|
|
@@ -190,7 +206,12 @@ export default function transformer(file, api) {
|
|
|
190
206
|
if (!node) return;
|
|
191
207
|
if (
|
|
192
208
|
(node.type === 'FunctionDeclaration' ||
|
|
193
|
-
node.type === 'ClassDeclaration'
|
|
209
|
+
node.type === 'ClassDeclaration' ||
|
|
210
|
+
// Type-only bindings share the module namespace for our purposes: a
|
|
211
|
+
// `type Tab`/`interface Theme` collides with an un-prefixed `XDSTab`/
|
|
212
|
+
// `XDSTheme` type import and would create a duplicate declaration.
|
|
213
|
+
node.type === 'TSTypeAliasDeclaration' ||
|
|
214
|
+
node.type === 'TSInterfaceDeclaration') &&
|
|
194
215
|
node.id
|
|
195
216
|
) {
|
|
196
217
|
existingBindings.add(node.id.name);
|
|
@@ -239,10 +260,13 @@ export default function transformer(file, api) {
|
|
|
239
260
|
if (bare !== importedName && existingBindings.has(bare)) {
|
|
240
261
|
// COLLISION: the bare name is already a top-level binding in this file
|
|
241
262
|
// (e.g. a local `export function CodeBlock` alongside imported
|
|
242
|
-
// `XDSCodeBlock`). Un-prefixing
|
|
243
|
-
//
|
|
263
|
+
// `XDSCodeBlock`, or a `type Tab` alongside `XDSTab`). Un-prefixing
|
|
264
|
+
// directly would create a duplicate declaration (TS2451), so alias the
|
|
265
|
+
// import by replacing `XDS` with `Astryx` in place and rename the
|
|
244
266
|
// import's references to that alias, leaving the local binding intact.
|
|
245
|
-
|
|
267
|
+
// In-place replacement keeps hooks well-formed: `useXDSToast` becomes
|
|
268
|
+
// `useAstryxToast`, not `AstryxuseToast`.
|
|
269
|
+
const alias = aliasName(importedName);
|
|
246
270
|
localRenames.set(importedName, alias);
|
|
247
271
|
existingBindings.add(alias);
|
|
248
272
|
hasChanges = true;
|
|
@@ -322,18 +346,27 @@ export default function transformer(file, api) {
|
|
|
322
346
|
}
|
|
323
347
|
// Don't double-rewrite the import specifier we already handled.
|
|
324
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;
|
|
325
356
|
path.node.name = newName;
|
|
326
357
|
hasChanges = true;
|
|
327
358
|
});
|
|
328
359
|
|
|
329
|
-
// JSX element names
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
}
|
|
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.
|
|
337
370
|
|
|
338
371
|
// 5. Rename TS type references in generic type-argument positions.
|
|
339
372
|
//
|
|
@@ -389,5 +422,98 @@ export default function transformer(file, api) {
|
|
|
389
422
|
};
|
|
390
423
|
renameTypeReferences(root.get().node);
|
|
391
424
|
|
|
392
|
-
|
|
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;
|
|
393
519
|
}
|