@astryxdesign/cli 0.7.2-canary.ccf1a1c → 0.7.2-canary.d52db36
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/api/template/template.test.mjs +120 -3
- package/assets/codemods/transforms/next/__tests__/migrate-list-edge-compensation-to-edge.test.mjs +120 -0
- package/assets/codemods/transforms/next/index.mjs +11 -1
- package/assets/codemods/transforms/next/migrate-list-edge-compensation-to-edge.mjs +198 -0
- package/foundation/discovery/template-adapter.mjs +74 -5
- package/foundation/doc-compiler/doc-loads.test.mjs +1 -1
- package/package.json +9 -9
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
2
|
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import * as path from 'node:path';
|
|
3
6
|
import {describe, it, expect} from 'vitest';
|
|
4
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
stripTemplateAssetRefs,
|
|
9
|
+
template,
|
|
10
|
+
extractComponents,
|
|
11
|
+
} from './template.mjs';
|
|
5
12
|
|
|
6
13
|
describe('stripTemplateAssetRefs', () => {
|
|
7
14
|
it('replaces a /template-assets image path with an inline data URI', () => {
|
|
@@ -31,8 +38,7 @@ describe('stripTemplateAssetRefs', () => {
|
|
|
31
38
|
});
|
|
32
39
|
|
|
33
40
|
it('preserves surrounding source structure', () => {
|
|
34
|
-
const src =
|
|
35
|
-
"const data = [{src: '/template-assets/x.png', alt: 'X'}];";
|
|
41
|
+
const src = "const data = [{src: '/template-assets/x.png', alt: 'X'}];";
|
|
36
42
|
const out = stripTemplateAssetRefs(src);
|
|
37
43
|
expect(out).toContain("alt: 'X'");
|
|
38
44
|
expect(out).toContain('const data = [{src:');
|
|
@@ -102,4 +108,115 @@ describe('template --skeleton component extraction (prefix-agnostic)', () => {
|
|
|
102
108
|
|
|
103
109
|
expect(result.data.skeleton).toContain('columns={{minWidth: 200}}');
|
|
104
110
|
});
|
|
111
|
+
|
|
112
|
+
it('filters out local helper components declared in the same file', () => {
|
|
113
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-test-'));
|
|
114
|
+
const pagePath = path.join(tmpDir, 'page.tsx');
|
|
115
|
+
fs.writeFileSync(
|
|
116
|
+
pagePath,
|
|
117
|
+
`
|
|
118
|
+
import {Card} from '@astryxdesign/core/Layout';
|
|
119
|
+
|
|
120
|
+
function TimelineSection() {
|
|
121
|
+
return <div />;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export default function Page() {
|
|
125
|
+
return (
|
|
126
|
+
<Card>
|
|
127
|
+
<TimelineSection />
|
|
128
|
+
</Card>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
`,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const components = extractComponents(pagePath);
|
|
136
|
+
expect(components).toContain('Card');
|
|
137
|
+
expect(components).not.toContain('Timeline');
|
|
138
|
+
} finally {
|
|
139
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('filters out a type-annotated local helper', () => {
|
|
144
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-test-'));
|
|
145
|
+
const pagePath = path.join(tmpDir, 'page.tsx');
|
|
146
|
+
fs.writeFileSync(
|
|
147
|
+
pagePath,
|
|
148
|
+
`
|
|
149
|
+
import type {FC} from 'react';
|
|
150
|
+
import {Card} from '@astryxdesign/core/Layout';
|
|
151
|
+
|
|
152
|
+
const TimelineSection: FC = () => <div />;
|
|
153
|
+
|
|
154
|
+
export default function Page() {
|
|
155
|
+
return (
|
|
156
|
+
<Card>
|
|
157
|
+
<TimelineSection />
|
|
158
|
+
</Card>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
`,
|
|
162
|
+
);
|
|
163
|
+
try {
|
|
164
|
+
const components = extractComponents(pagePath);
|
|
165
|
+
expect(components).toContain('Card');
|
|
166
|
+
expect(components).not.toContain('Timeline');
|
|
167
|
+
} finally {
|
|
168
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('keeps an imported component when declaration-like text appears in a comment or string', () => {
|
|
173
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-test-'));
|
|
174
|
+
const pagePath = path.join(tmpDir, 'page.tsx');
|
|
175
|
+
fs.writeFileSync(
|
|
176
|
+
pagePath,
|
|
177
|
+
`
|
|
178
|
+
import {Card} from '@astryxdesign/core/Layout';
|
|
179
|
+
|
|
180
|
+
// const Card = () => null;
|
|
181
|
+
const note = 'function Card() {}';
|
|
182
|
+
|
|
183
|
+
export default function Page() {
|
|
184
|
+
return <Card>{note}</Card>;
|
|
185
|
+
}
|
|
186
|
+
`,
|
|
187
|
+
);
|
|
188
|
+
try {
|
|
189
|
+
expect(extractComponents(pagePath)).toContain('Card');
|
|
190
|
+
} finally {
|
|
191
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('keeps a bare imported component whose name is itself a stripped suffix', () => {
|
|
196
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-test-'));
|
|
197
|
+
const pagePath = path.join(tmpDir, 'page.tsx');
|
|
198
|
+
fs.writeFileSync(
|
|
199
|
+
pagePath,
|
|
200
|
+
`
|
|
201
|
+
import {Section} from '@astryxdesign/core/Section';
|
|
202
|
+
|
|
203
|
+
function SectionHeading() {
|
|
204
|
+
return <div />;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export default function Page() {
|
|
208
|
+
return (
|
|
209
|
+
<Section>
|
|
210
|
+
<SectionHeading />
|
|
211
|
+
</Section>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
`,
|
|
215
|
+
);
|
|
216
|
+
try {
|
|
217
|
+
expect(extractComponents(pagePath)).toContain('Section');
|
|
218
|
+
} finally {
|
|
219
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
220
|
+
}
|
|
221
|
+
});
|
|
105
222
|
});
|
package/assets/codemods/transforms/next/__tests__/migrate-list-edge-compensation-to-edge.test.mjs
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
import {describe, expect, it} from 'vitest';
|
|
4
|
+
import transform from '../migrate-list-edge-compensation-to-edge.mjs';
|
|
5
|
+
|
|
6
|
+
async function applyTransform(source) {
|
|
7
|
+
const jscodeshift = (await import('jscodeshift')).default;
|
|
8
|
+
const j = jscodeshift.withParser('tsx');
|
|
9
|
+
const api = {jscodeshift: j, stats: () => {}, report: () => {}};
|
|
10
|
+
return transform({source, path: 'test.tsx'}, api) ?? source;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('migrate-list-edge-compensation-to-edge', () => {
|
|
14
|
+
it("renames edgeCompensation='inline' to edge='outset'", async () => {
|
|
15
|
+
const output =
|
|
16
|
+
await applyTransform(`import {List, ListItem} from '@astryxdesign/core/List';
|
|
17
|
+
const a = <List edgeCompensation="inline"><ListItem label="a" /></List>;
|
|
18
|
+
const b = <List hasDividers edgeCompensation={'inline'} />;`);
|
|
19
|
+
|
|
20
|
+
expect(output).not.toContain('edgeCompensation');
|
|
21
|
+
expect(output.match(/edge='outset'/g)).toHaveLength(2);
|
|
22
|
+
expect(output).toContain('hasDividers');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('follows aliases and the package root import', async () => {
|
|
26
|
+
const output =
|
|
27
|
+
await applyTransform(`import {List as Rows} from '@astryxdesign/core';
|
|
28
|
+
const a = <Rows edgeCompensation="inline" />;`);
|
|
29
|
+
|
|
30
|
+
expect(output).toContain("<Rows edge='outset' />");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('drops edgeCompensation beside an edge string, whatever its value', async () => {
|
|
34
|
+
const output =
|
|
35
|
+
await applyTransform(`import {List} from '@astryxdesign/core/List';
|
|
36
|
+
const a = <List edge="bleed" edgeCompensation="inline" />;
|
|
37
|
+
const b = <List edge={'flush'} edgeCompensation={mode} />;
|
|
38
|
+
const c = <List {...rest} edge="padded" edgeCompensation="inline" />;`);
|
|
39
|
+
|
|
40
|
+
// c: the spread comes before edge, so it cannot override it.
|
|
41
|
+
expect(output).toContain('edge="bleed"');
|
|
42
|
+
expect(output).toContain("edge={'flush'}");
|
|
43
|
+
expect(output).toContain('edge="padded"');
|
|
44
|
+
expect(output).not.toContain('edgeCompensation');
|
|
45
|
+
expect(output).not.toContain('TODO');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('leaves a TODO where edge may be undefined at runtime', async () => {
|
|
49
|
+
const output =
|
|
50
|
+
await applyTransform(`import {List} from '@astryxdesign/core/List';
|
|
51
|
+
const a = <List edge={edge} edgeCompensation="inline" />;
|
|
52
|
+
const b = <List edge="flush" {...rest} edgeCompensation="inline" />;
|
|
53
|
+
const c = <List edge={props.edge} edgeCompensation={mode} />;`);
|
|
54
|
+
|
|
55
|
+
// a and c: a computed edge falls back to the old prop when undefined.
|
|
56
|
+
// b: the spread after edge may set it to undefined.
|
|
57
|
+
expect(output.match(/TODO\(astryx upgrade\)/g)).toHaveLength(3);
|
|
58
|
+
expect(output.match(/edgeCompensation=/g)).toHaveLength(3);
|
|
59
|
+
expect(output).toContain('edge={edge}');
|
|
60
|
+
expect(output).toContain('edge={props.edge}');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('leaves a TODO on a dynamic value and keeps it working', async () => {
|
|
64
|
+
const output =
|
|
65
|
+
await applyTransform(`import {List} from '@astryxdesign/core/List';
|
|
66
|
+
const a = <List edgeCompensation={mode} />;
|
|
67
|
+
const b = <List edgeCompensation={flag ? 'inline' : undefined} />;`);
|
|
68
|
+
|
|
69
|
+
expect(output.match(/TODO\(astryx upgrade\)/g)).toHaveLength(2);
|
|
70
|
+
expect(output).toContain('edgeCompensation={mode}');
|
|
71
|
+
expect(output).toContain("edgeCompensation={flag ? 'inline' : undefined}");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('keeps edgeCompensation with a TODO when the List also spreads props', async () => {
|
|
75
|
+
const output =
|
|
76
|
+
await applyTransform(`import {List} from '@astryxdesign/core/List';
|
|
77
|
+
const a = <List {...rest} edgeCompensation="inline" />;
|
|
78
|
+
const b = <List edgeCompensation="inline" {...rest} />;
|
|
79
|
+
const c = <List {...rest} edgeCompensation={mode} />;
|
|
80
|
+
const d = <List {...props} />;
|
|
81
|
+
const e = <List {...rest} edge="padded" />;`);
|
|
82
|
+
|
|
83
|
+
// a: an edge in the spread wins today, and a new edge would override
|
|
84
|
+
// it. b: the spread may override either prop. c: dynamic value and a
|
|
85
|
+
// spread, one TODO. d: no evidence of the old prop, so unchanged; its
|
|
86
|
+
// @deprecated type reports a typed use where the props are written.
|
|
87
|
+
// e: no old prop.
|
|
88
|
+
expect(output.match(/TODO\(astryx upgrade\)/g)).toHaveLength(3);
|
|
89
|
+
const code = output.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
90
|
+
expect(code).not.toContain("edge='outset'");
|
|
91
|
+
expect(code.match(/edgeCompensation="inline"/g)).toHaveLength(2);
|
|
92
|
+
expect(output).toContain('edgeCompensation={mode}');
|
|
93
|
+
expect(output).toContain('const d = <List {...props} />;');
|
|
94
|
+
expect(output).toContain('edge="padded"');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('leaves other components, other packages, and unrelated spreads alone', async () => {
|
|
98
|
+
const source = `import {List} from '@astryxdesign/core/List';
|
|
99
|
+
import {TabList} from '@astryxdesign/core/TabList';
|
|
100
|
+
import {List as Other} from 'some-other-package';
|
|
101
|
+
const a = <TabList edgeCompensation="inline" {...props} />;
|
|
102
|
+
const b = <Other edgeCompensation="inline" {...props} />;
|
|
103
|
+
const c = <div {...props} />;`;
|
|
104
|
+
expect(await applyTransform(source)).toBe(source);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('changes nothing on a second run', async () => {
|
|
108
|
+
const once =
|
|
109
|
+
await applyTransform(`import {List} from '@astryxdesign/core/List';
|
|
110
|
+
const a = <List edgeCompensation="inline" />;
|
|
111
|
+
const b = <List edgeCompensation={mode} />;
|
|
112
|
+
const c = <List {...props} edgeCompensation="inline" />;
|
|
113
|
+
const d = <List edge="flush" edgeCompensation="inline" />;
|
|
114
|
+
const e = <List edge={edge} edgeCompensation="inline" />;`);
|
|
115
|
+
const twice = await applyTransform(once);
|
|
116
|
+
|
|
117
|
+
expect(twice).toBe(once);
|
|
118
|
+
expect(twice.match(/TODO\(astryx upgrade\)/g)).toHaveLength(3);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -7,4 +7,14 @@
|
|
|
7
7
|
* this file into the resolved version folder.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
import migrateListEdgeCompensationToEdge, {
|
|
11
|
+
meta as migrateListEdgeCompensationToEdgeMeta,
|
|
12
|
+
} from './migrate-list-edge-compensation-to-edge.mjs';
|
|
13
|
+
|
|
14
|
+
export default [
|
|
15
|
+
{
|
|
16
|
+
name: 'migrate-list-edge-compensation-to-edge',
|
|
17
|
+
transform: migrateListEdgeCompensationToEdge,
|
|
18
|
+
meta: migrateListEdgeCompensationToEdgeMeta,
|
|
19
|
+
},
|
|
20
|
+
];
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Codemod: migrate List edgeCompensation to edge
|
|
5
|
+
*
|
|
6
|
+
* `edgeCompensation="inline"` on an imported `List` becomes `edge="outset"`,
|
|
7
|
+
* the value it already resolves to. List resolves
|
|
8
|
+
* `edge ?? (edgeCompensation === 'inline' ? 'outset' : 'padded')`, so `edge`
|
|
9
|
+
* wins only when it has a value. The deprecated prop is removed beside an
|
|
10
|
+
* `edge` string that no later spread can override; beside a computed `edge`,
|
|
11
|
+
* which may be undefined at runtime, it gets a TODO instead.
|
|
12
|
+
*
|
|
13
|
+
* Built on the shared `transformProp` helper, so it does not follow
|
|
14
|
+
* variables, helpers, wrappers, or types. Where a List writes
|
|
15
|
+
* `edgeCompensation` but the codemod cannot prove the result, it keeps the
|
|
16
|
+
* old prop and leaves a TODO instead of guessing: on a dynamic value, and
|
|
17
|
+
* wherever the List also spreads props. A spread before the old prop may
|
|
18
|
+
* carry an `edge` that wins today and that a new `edge` would override; one
|
|
19
|
+
* after it may override either prop. A spread onto a List that never writes
|
|
20
|
+
* `edgeCompensation` is not evidence of use, so it stays unchanged
|
|
21
|
+
* (`spec:AST-040` FR1): the deprecated prop keeps working through it, and
|
|
22
|
+
* its `@deprecated` type reports a typed use where the props are written.
|
|
23
|
+
* Comments are the codemod's only report channel (`api.report` is a stub),
|
|
24
|
+
* and each is added once, so a second run changes nothing.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import {transformProp} from '../../transform-prop.mjs';
|
|
28
|
+
|
|
29
|
+
export const meta = {
|
|
30
|
+
title: 'Migrate List edgeCompensation to edge',
|
|
31
|
+
description:
|
|
32
|
+
'Renames `edgeCompensation="inline"` to `edge="outset"` on List, and ' +
|
|
33
|
+
'drops `edgeCompensation` beside an `edge` string. Keeps the old prop ' +
|
|
34
|
+
'with a TODO where `edge` is computed or a later spread may override ' +
|
|
35
|
+
'it, on a dynamic value, and where the List also spreads props; the ' +
|
|
36
|
+
'deprecated prop still works there.',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const OLD_NAME = 'edgeCompensation';
|
|
40
|
+
const NEW_NAME = 'edge';
|
|
41
|
+
const CORE_IMPORT_RE = /^@(astryxdesign|xds)\/core(?:\/|$)/;
|
|
42
|
+
|
|
43
|
+
const COMPONENT_PROPS = new Map([['List', new Set([OLD_NAME])]]);
|
|
44
|
+
|
|
45
|
+
const DYNAMIC_COMMENT =
|
|
46
|
+
' TODO(astryx upgrade): edgeCompensation is deprecated. Replace ' +
|
|
47
|
+
'edgeCompensation="inline" with edge="outset" by hand; this value is ' +
|
|
48
|
+
'computed. ';
|
|
49
|
+
const BOTH_SET_COMMENT =
|
|
50
|
+
' TODO(astryx upgrade): edgeCompensation is deprecated. edge is also set ' +
|
|
51
|
+
'here, but it may be undefined, and then edgeCompensation still applies. ' +
|
|
52
|
+
'Make edge default to "outset" where edgeCompensation is "inline", then ' +
|
|
53
|
+
'remove edgeCompensation. ';
|
|
54
|
+
const SPREAD_COMMENT =
|
|
55
|
+
' TODO(astryx upgrade): edgeCompensation is deprecated, but this List ' +
|
|
56
|
+
'also spreads props that may set edge, so it was left unchanged. Replace ' +
|
|
57
|
+
'edgeCompensation="inline" with edge="outset" by hand, after checking ' +
|
|
58
|
+
'which edge the spread passes. ';
|
|
59
|
+
|
|
60
|
+
const TYPE_WRAPPERS = new Set([
|
|
61
|
+
'TSAsExpression',
|
|
62
|
+
'TSSatisfiesExpression',
|
|
63
|
+
'TSTypeAssertion',
|
|
64
|
+
'TSNonNullExpression',
|
|
65
|
+
'TypeCastExpression',
|
|
66
|
+
'ParenthesizedExpression',
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/** @param {any} node */
|
|
70
|
+
function unwrap(node) {
|
|
71
|
+
let current = node;
|
|
72
|
+
while (current && TYPE_WRAPPERS.has(current.type)) {
|
|
73
|
+
current = current.expression;
|
|
74
|
+
}
|
|
75
|
+
return current;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @param {any} node */
|
|
79
|
+
function isStringLiteral(node) {
|
|
80
|
+
const value = unwrap(node);
|
|
81
|
+
return (
|
|
82
|
+
value != null &&
|
|
83
|
+
(value.type === 'StringLiteral' || value.type === 'Literal') &&
|
|
84
|
+
typeof value.value === 'string'
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The value of a JSX attribute: the string of `edge="flush"`, or the
|
|
90
|
+
* expression inside `edge={...}`.
|
|
91
|
+
* @param {any} attribute
|
|
92
|
+
*/
|
|
93
|
+
function attributeValue(attribute) {
|
|
94
|
+
return attribute.value?.type === 'JSXExpressionContainer'
|
|
95
|
+
? attribute.value.expression
|
|
96
|
+
: attribute.value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @param {any} node */
|
|
100
|
+
function isInlineLiteral(node) {
|
|
101
|
+
const value = unwrap(node);
|
|
102
|
+
return (
|
|
103
|
+
value != null &&
|
|
104
|
+
(value.type === 'StringLiteral' || value.type === 'Literal') &&
|
|
105
|
+
value.value === 'inline'
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Attach a comment to a node once, so re-running the codemod adds nothing.
|
|
111
|
+
* @param {any} j @param {any} node @param {string} text
|
|
112
|
+
*/
|
|
113
|
+
function addComment(j, node, text) {
|
|
114
|
+
node.comments ??= [];
|
|
115
|
+
if (node.comments.some((/** @type {any} */ c) => c.value === text)) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
node.comments.push(j.commentBlock(text, true, false));
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** @param {any} attributes */
|
|
123
|
+
function firstSpread(attributes) {
|
|
124
|
+
return attributes.find(
|
|
125
|
+
(/** @type {any} */ attribute) => attribute.type === 'JSXSpreadAttribute',
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {import('../../../../authoring/codemod/type').AstryxCodemodFile} file
|
|
131
|
+
* @param {import('../../../../authoring/codemod/type').CodemodTransformApi} api
|
|
132
|
+
* @returns {string | null | undefined}
|
|
133
|
+
*/
|
|
134
|
+
export default function transformer(file, api) {
|
|
135
|
+
const j = api.jscodeshift;
|
|
136
|
+
const root = j(file.source);
|
|
137
|
+
let hasChanges = false;
|
|
138
|
+
|
|
139
|
+
transformProp(
|
|
140
|
+
root,
|
|
141
|
+
j,
|
|
142
|
+
{
|
|
143
|
+
matchesImport: source => CORE_IMPORT_RE.test(source),
|
|
144
|
+
components: COMPONENT_PROPS,
|
|
145
|
+
},
|
|
146
|
+
propPath => {
|
|
147
|
+
const opening = propPath.parent?.node;
|
|
148
|
+
const attributes = opening?.attributes ?? [];
|
|
149
|
+
const prop = propPath.node;
|
|
150
|
+
const edgeIndex = attributes.findIndex(
|
|
151
|
+
(/** @type {any} */ attribute) =>
|
|
152
|
+
attribute.type === 'JSXAttribute' &&
|
|
153
|
+
attribute.name?.name === NEW_NAME,
|
|
154
|
+
);
|
|
155
|
+
if (edgeIndex !== -1) {
|
|
156
|
+
// `edge` wins only when it has a value. A string that no later
|
|
157
|
+
// spread can override always does, so the old prop is inert and
|
|
158
|
+
// removing it changes nothing. A computed `edge` may be undefined,
|
|
159
|
+
// and then the old prop still decides.
|
|
160
|
+
const isProven =
|
|
161
|
+
isStringLiteral(attributeValue(attributes[edgeIndex])) &&
|
|
162
|
+
!attributes
|
|
163
|
+
.slice(edgeIndex + 1)
|
|
164
|
+
.some(
|
|
165
|
+
(/** @type {any} */ attribute) =>
|
|
166
|
+
attribute.type === 'JSXSpreadAttribute',
|
|
167
|
+
);
|
|
168
|
+
if (isProven) {
|
|
169
|
+
opening.attributes = attributes.filter(
|
|
170
|
+
(/** @type {any} */ attribute) => attribute !== prop,
|
|
171
|
+
);
|
|
172
|
+
hasChanges = true;
|
|
173
|
+
} else {
|
|
174
|
+
hasChanges = addComment(j, prop, BOTH_SET_COMMENT) || hasChanges;
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// With a spread on the element the rename cannot be proven: a spread
|
|
179
|
+
// before the old prop may carry an `edge` that wins today and that a
|
|
180
|
+
// new `edge` would override, and one after it may override either
|
|
181
|
+
// prop. The old prop keeps working, so it stays, with a TODO.
|
|
182
|
+
if (firstSpread(attributes)) {
|
|
183
|
+
hasChanges = addComment(j, prop, SPREAD_COMMENT) || hasChanges;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (isInlineLiteral(attributeValue(prop))) {
|
|
187
|
+
prop.name.name = NEW_NAME;
|
|
188
|
+
prop.value = j.stringLiteral('outset');
|
|
189
|
+
hasChanges = true;
|
|
190
|
+
} else {
|
|
191
|
+
hasChanges = addComment(j, prop, DYNAMIC_COMMENT) || hasChanges;
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
if (!hasChanges) return undefined;
|
|
197
|
+
return root.toSource({quote: 'single'});
|
|
198
|
+
}
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* needs it: `Project` assembles templates from it. While this sat in api/, that
|
|
16
16
|
* made a cycle across the layer boundary — Project reached up for discovery and
|
|
17
17
|
* this module imported Project back. Nothing here depends on api/; every import
|
|
18
|
-
* is node:*, jiti, foundation/*, or authoring
|
|
18
|
+
* is node:*, jiti, foundation/*, or authoring/*, plus a lazily required
|
|
19
|
+
* @babel/parser for extractComponents' local-declaration scan.
|
|
19
20
|
*
|
|
20
21
|
* @position packages/cli/foundation/discovery — shared template discovery/IO;
|
|
21
22
|
* consumed by Project and by the api/template leaves.
|
|
@@ -512,6 +513,19 @@ function expressionUse(source, open, close) {
|
|
|
512
513
|
return null;
|
|
513
514
|
}
|
|
514
515
|
|
|
516
|
+
/** @type {typeof import('@babel/parser') | undefined} */
|
|
517
|
+
let babelParser;
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* The template source parser, loaded on first use so listing templates
|
|
521
|
+
* never pays for it.
|
|
522
|
+
* @returns {typeof import('@babel/parser')}
|
|
523
|
+
*/
|
|
524
|
+
function templateSourceParser() {
|
|
525
|
+
babelParser ??= require('@babel/parser');
|
|
526
|
+
return /** @type {typeof import('@babel/parser')} */ (babelParser);
|
|
527
|
+
}
|
|
528
|
+
|
|
515
529
|
/**
|
|
516
530
|
* The [start, end) range of every comment in `source`, or none when it does
|
|
517
531
|
* not parse as TSX.
|
|
@@ -520,8 +534,7 @@ function expressionUse(source, open, close) {
|
|
|
520
534
|
*/
|
|
521
535
|
function commentRanges(source) {
|
|
522
536
|
try {
|
|
523
|
-
const {
|
|
524
|
-
const {comments} = parse(source, {
|
|
537
|
+
const {comments} = templateSourceParser().parse(source, {
|
|
525
538
|
sourceType: 'module',
|
|
526
539
|
plugins: ['jsx', 'typescript'],
|
|
527
540
|
});
|
|
@@ -1267,7 +1280,8 @@ export async function discoverIntegrationTemplatesForOne(integration) {
|
|
|
1267
1280
|
template: id,
|
|
1268
1281
|
message: `Template "${id}" is missing a "type" of "page" or "block". Stamp the default export with type: 'page' or type: 'block'.`,
|
|
1269
1282
|
});
|
|
1270
|
-
const declared =
|
|
1283
|
+
const declared =
|
|
1284
|
+
doc?.replaces ?? (await declaredReplacement(docPath, id));
|
|
1271
1285
|
if (declared != null) {
|
|
1272
1286
|
errors.push({
|
|
1273
1287
|
code: 'invalid_template_replacement',
|
|
@@ -1423,6 +1437,51 @@ const UBIQUITOUS = new Set([
|
|
|
1423
1437
|
'Icon',
|
|
1424
1438
|
]);
|
|
1425
1439
|
|
|
1440
|
+
/**
|
|
1441
|
+
* Names bound by top-level `function`, `class`, `const`, `let` or `var`
|
|
1442
|
+
* declarations (exported or not). Returns an empty set when the source does
|
|
1443
|
+
* not parse, so a parse failure never drops a real component.
|
|
1444
|
+
*
|
|
1445
|
+
* @param {string} src
|
|
1446
|
+
* @returns {Set<string>}
|
|
1447
|
+
*/
|
|
1448
|
+
function topLevelBindingNames(src) {
|
|
1449
|
+
/** @type {Set<string>} */
|
|
1450
|
+
const names = new Set();
|
|
1451
|
+
let program;
|
|
1452
|
+
try {
|
|
1453
|
+
program = templateSourceParser().parse(src, {
|
|
1454
|
+
sourceType: 'module',
|
|
1455
|
+
plugins: ['jsx', 'typescript'],
|
|
1456
|
+
errorRecovery: true,
|
|
1457
|
+
}).program;
|
|
1458
|
+
} catch {
|
|
1459
|
+
return names;
|
|
1460
|
+
}
|
|
1461
|
+
for (const statement of program.body) {
|
|
1462
|
+
const node =
|
|
1463
|
+
statement.type === 'ExportNamedDeclaration' ||
|
|
1464
|
+
statement.type === 'ExportDefaultDeclaration'
|
|
1465
|
+
? statement.declaration
|
|
1466
|
+
: statement;
|
|
1467
|
+
if (node == null) continue;
|
|
1468
|
+
if (
|
|
1469
|
+
(node.type === 'FunctionDeclaration' ||
|
|
1470
|
+
node.type === 'ClassDeclaration') &&
|
|
1471
|
+
node.id
|
|
1472
|
+
) {
|
|
1473
|
+
names.add(node.id.name);
|
|
1474
|
+
} else if (node.type === 'VariableDeclaration') {
|
|
1475
|
+
for (const declarator of node.declarations) {
|
|
1476
|
+
if (declarator.id.type === 'Identifier') {
|
|
1477
|
+
names.add(declarator.id.name);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
return names;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1426
1485
|
/**
|
|
1427
1486
|
* @param {string} pagePath
|
|
1428
1487
|
* @returns {string[]}
|
|
@@ -1449,14 +1508,24 @@ export function extractComponents(pagePath) {
|
|
|
1449
1508
|
while ((m = tagRegex.exec(src)) !== null) {
|
|
1450
1509
|
matches.push(m[2]);
|
|
1451
1510
|
}
|
|
1511
|
+
|
|
1512
|
+
// Exclude names bound by a top-level declaration in the same file: those are
|
|
1513
|
+
// page-local helpers (e.g. `function TimelineSection()`), not components a
|
|
1514
|
+
// builder can import. Read real bindings from the syntax tree so comments,
|
|
1515
|
+
// strings and type annotations cannot add or hide a name.
|
|
1516
|
+
const localDeclNames = topLevelBindingNames(src);
|
|
1517
|
+
|
|
1452
1518
|
return [
|
|
1453
1519
|
...new Set(
|
|
1454
1520
|
matches
|
|
1521
|
+
.filter(n => !localDeclNames.has(n))
|
|
1455
1522
|
.filter(n => !['Theme', 'ThemeProvider'].includes(n))
|
|
1456
1523
|
.filter(n => !UBIQUITOUS.has(n))
|
|
1457
1524
|
.map(n =>
|
|
1458
1525
|
n.replace(
|
|
1459
|
-
|
|
1526
|
+
// Strip only when a stem remains, so a bare `<Section>` keeps its
|
|
1527
|
+
// own name instead of emptying out.
|
|
1528
|
+
/(?<=.)(Item|Section|Header|Content|Footer|Panel|Heading|CollapseButton|Column|Sortable|Selection|Group|Source)$/,
|
|
1460
1529
|
'',
|
|
1461
1530
|
),
|
|
1462
1531
|
)
|
|
@@ -251,7 +251,7 @@ const RUNNERS = {
|
|
|
251
251
|
sites: ['importDocModule'],
|
|
252
252
|
},
|
|
253
253
|
'foundation/discovery/template-adapter.mjs': {
|
|
254
|
-
runs: '@babel/parser, to find the fixture references in template source; and a template doc that does not validate, to read the replacement it declares',
|
|
254
|
+
runs: '@babel/parser, to find the fixture references and page-local bindings in template source; and a template doc that does not validate, to read the replacement it declares',
|
|
255
255
|
sites: ['importDocModule', 'require(@babel/parser)'],
|
|
256
256
|
},
|
|
257
257
|
'foundation/discovery/theme-discovery.mjs': {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/cli",
|
|
3
|
-
"version": "0.7.2-canary.
|
|
3
|
+
"version": "0.7.2-canary.d52db36",
|
|
4
4
|
"displayName": "CLI",
|
|
5
5
|
"description": "Scaffold projects, browse templates, generate themes, and get agent-ready docs from the command line.",
|
|
6
6
|
"author": "Meta Open Source",
|
|
@@ -101,10 +101,10 @@
|
|
|
101
101
|
"zod": "^4.4.3"
|
|
102
102
|
},
|
|
103
103
|
"peerDependencies": {
|
|
104
|
-
"@astryxdesign/charts": "0.7.2-canary.
|
|
105
|
-
"@astryxdesign/core": "0.7.2-canary.
|
|
106
|
-
"@astryxdesign/lab": "0.7.2-canary.
|
|
107
|
-
"@astryxdesign/theme-neutral": "0.7.2-canary.
|
|
104
|
+
"@astryxdesign/charts": "0.7.2-canary.d52db36",
|
|
105
|
+
"@astryxdesign/core": "0.7.2-canary.d52db36",
|
|
106
|
+
"@astryxdesign/lab": "0.7.2-canary.d52db36",
|
|
107
|
+
"@astryxdesign/theme-neutral": "0.7.2-canary.d52db36"
|
|
108
108
|
},
|
|
109
109
|
"peerDependenciesMeta": {
|
|
110
110
|
"@astryxdesign/charts": {
|
|
@@ -121,10 +121,10 @@
|
|
|
121
121
|
}
|
|
122
122
|
},
|
|
123
123
|
"devDependencies": {
|
|
124
|
-
"@astryxdesign/charts": "0.7.2-canary.
|
|
125
|
-
"@astryxdesign/core": "0.7.2-canary.
|
|
126
|
-
"@astryxdesign/lab": "0.7.2-canary.
|
|
127
|
-
"@astryxdesign/theme-neutral": "0.7.2-canary.
|
|
124
|
+
"@astryxdesign/charts": "0.7.2-canary.d52db36",
|
|
125
|
+
"@astryxdesign/core": "0.7.2-canary.d52db36",
|
|
126
|
+
"@astryxdesign/lab": "0.7.2-canary.d52db36",
|
|
127
|
+
"@astryxdesign/theme-neutral": "0.7.2-canary.d52db36",
|
|
128
128
|
"@heroicons/react": "^2.2.0",
|
|
129
129
|
"@stylexjs/stylex": "^0.19.0",
|
|
130
130
|
"@types/babel__core": "^7.20.5",
|