@astryxdesign/cli 0.7.2-canary.d65096c → 0.7.2-canary.e2909cf
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/component/component.test.mjs +80 -0
- package/api/component/list/list.mjs +42 -4
- package/api/integration/pack-check.mjs +10 -10
- package/api/search/search.mjs +8 -3
- package/api/search/search.test.mjs +36 -0
- package/api/template/template.test.mjs +120 -3
- package/api/theme/build/build.family.test.mjs +16 -0
- package/api/theme/build/build.mjs +77 -18
- package/api/theme/themeBuild.doc.mjs +3 -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/assets/docs/working-with-ai.doc.mjs +43 -4
- package/clients/cli/commands/build-theme.path-safety.test.mjs +84 -0
- package/clients/cli/commands/build-theme.registry.test.mjs +5 -2
- package/clients/cli/commands/theme-build.doc.mjs +2 -2
- package/foundation/discovery/component-discovery.d.mts +12 -0
- package/foundation/discovery/component-discovery.mjs +211 -0
- package/foundation/discovery/component-discovery.test.mjs +96 -0
- package/foundation/discovery/template-adapter.mjs +74 -5
- package/foundation/doc-compiler/doc-loads.test.mjs +5 -1
- package/foundation/doc-compiler/inputs.mjs +23 -0
- package/foundation/doc-compiler/inputs.test.mjs +0 -1
- package/foundation/xle/registry.mjs +4 -0
- package/foundation/xle/registry.test.mjs +28 -0
- package/package.json +9 -9
|
@@ -5,12 +5,15 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import * as fs from 'node:fs';
|
|
8
|
+
import {createRequire} from 'node:module';
|
|
8
9
|
import * as path from 'node:path';
|
|
9
10
|
import {listDir, listDirEntries, pathExists} from '../fs/listing-cache.mjs';
|
|
10
11
|
import {existsCaseExact} from '../fs/paths.mjs';
|
|
11
12
|
import {CORE_PROVIDER_ID} from '../identity/providers.mjs';
|
|
12
13
|
import {loadComponentDoc} from './component-loader.mjs';
|
|
13
14
|
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
|
|
14
17
|
const SKIP_DIRS = new Set(['hooks', 'utils', '__tests__', 'node_modules']);
|
|
15
18
|
|
|
16
19
|
/** The owner package name for built-in (core) components. */
|
|
@@ -109,8 +112,193 @@ function readDocMeta(docPath) {
|
|
|
109
112
|
}
|
|
110
113
|
}
|
|
111
114
|
|
|
115
|
+
/** @type {typeof import('@babel/parser') | undefined} */
|
|
116
|
+
let babelParser;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A property of an object literal, by its plain key.
|
|
120
|
+
* @param {any} object - an ObjectExpression node
|
|
121
|
+
* @param {string} key
|
|
122
|
+
* @returns {any}
|
|
123
|
+
*/
|
|
124
|
+
function propertyValue(object, key) {
|
|
125
|
+
for (const property of object.properties) {
|
|
126
|
+
if (property.type !== 'ObjectProperty' || property.computed) continue;
|
|
127
|
+
const name =
|
|
128
|
+
property.key.type === 'Identifier'
|
|
129
|
+
? property.key.name
|
|
130
|
+
: property.key.type === 'StringLiteral'
|
|
131
|
+
? property.key.value
|
|
132
|
+
: null;
|
|
133
|
+
if (name === key) return property.value;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The names a component doc lists as `components` members, read from its
|
|
140
|
+
* source without running it. Only a static `{name: '<Name>'}` entry in the doc
|
|
141
|
+
* object the loader reads (the `default` export, else `docs`) counts: a name in
|
|
142
|
+
* prose, an example, or `relatedComponents` is not a member.
|
|
143
|
+
* @param {string} source - the doc file's text
|
|
144
|
+
* @returns {string[]}
|
|
145
|
+
*/
|
|
146
|
+
function docMemberNames(source) {
|
|
147
|
+
/** @type {any} */
|
|
148
|
+
let ast;
|
|
149
|
+
try {
|
|
150
|
+
babelParser ??= require('@babel/parser');
|
|
151
|
+
ast = /** @type {typeof import('@babel/parser')} */ (babelParser).parse(source, {
|
|
152
|
+
sourceType: 'module',
|
|
153
|
+
});
|
|
154
|
+
} catch {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
/** @type {Map<string, any>} */
|
|
158
|
+
const constants = new Map();
|
|
159
|
+
let defaultExport = null;
|
|
160
|
+
let docsExport = null;
|
|
161
|
+
for (const node of ast.program.body) {
|
|
162
|
+
const declaration =
|
|
163
|
+
node.type === 'ExportNamedDeclaration' ? node.declaration : node;
|
|
164
|
+
if (declaration?.type === 'VariableDeclaration') {
|
|
165
|
+
for (const declarator of declaration.declarations) {
|
|
166
|
+
if (declarator.id.type !== 'Identifier' || !declarator.init) continue;
|
|
167
|
+
constants.set(declarator.id.name, declarator.init);
|
|
168
|
+
if (node.type === 'ExportNamedDeclaration' && declarator.id.name === 'docs') {
|
|
169
|
+
docsExport = declarator.init;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (node.type === 'ExportDefaultDeclaration') defaultExport = node.declaration;
|
|
174
|
+
}
|
|
175
|
+
let doc = defaultExport ?? docsExport;
|
|
176
|
+
if (doc?.type === 'Identifier') doc = constants.get(doc.name);
|
|
177
|
+
if (doc?.type !== 'ObjectExpression') return [];
|
|
178
|
+
const members = propertyValue(doc, 'components');
|
|
179
|
+
if (members?.type !== 'ArrayExpression') return [];
|
|
180
|
+
return members.elements.flatMap((/** @type {any} */ element) => {
|
|
181
|
+
if (element?.type !== 'ObjectExpression') return [];
|
|
182
|
+
const name = propertyValue(element, 'name');
|
|
183
|
+
return name?.type === 'StringLiteral' ? [name.value] : [];
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Whether `name` has its own component folder in Core: `src/<Name>/<Name>.tsx`
|
|
189
|
+
* (or the `XDS`-prefixed form).
|
|
190
|
+
* @param {string} srcDir
|
|
191
|
+
* @param {string} name
|
|
192
|
+
* @returns {boolean}
|
|
193
|
+
*/
|
|
194
|
+
function hasOwnFolder(srcDir, name) {
|
|
195
|
+
return [`${name}.tsx`, `XDS${name}.tsx`].some(file =>
|
|
196
|
+
existsCaseExact(path.join(srcDir, name, file), srcDir),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Member names read in this process, by doc path, reused while the file keeps
|
|
202
|
+
* its size and mtime.
|
|
203
|
+
* @type {Map<string, {size: number, mtimeMs: number, members: string[]}>}
|
|
204
|
+
*/
|
|
205
|
+
const memberReads = new Map();
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The names a doc lists as `components` members (see docMemberNames).
|
|
209
|
+
* @param {string} docPath
|
|
210
|
+
* @returns {string[]}
|
|
211
|
+
*/
|
|
212
|
+
function readDocMembers(docPath) {
|
|
213
|
+
let stat;
|
|
214
|
+
try {
|
|
215
|
+
stat = fs.statSync(docPath);
|
|
216
|
+
} catch {
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
219
|
+
const cached = memberReads.get(docPath);
|
|
220
|
+
if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
|
|
221
|
+
return cached.members;
|
|
222
|
+
}
|
|
223
|
+
/** @type {string[]} */
|
|
224
|
+
let members;
|
|
225
|
+
try {
|
|
226
|
+
members = docMemberNames(fs.readFileSync(docPath, 'utf-8'));
|
|
227
|
+
} catch {
|
|
228
|
+
members = [];
|
|
229
|
+
}
|
|
230
|
+
memberReads.set(docPath, {size: stat.size, mtimeMs: stat.mtimeMs, members});
|
|
231
|
+
return members;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// A sibling component folder named by a relative import or re-export.
|
|
235
|
+
const SIBLING_IMPORT_RE =
|
|
236
|
+
/(?:\bfrom|\bimport\s*\()\s*['"]\.\.\/([A-Za-z0-9_-]+)(?:\/[^'"]*)?['"]/g;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The Core doc that documents `name` as one of its `components` members: the
|
|
240
|
+
* doc of a component it builds on. HStack and VStack import Stack, and Stack's
|
|
241
|
+
* doc lists them, so each has its own folder and import path but no doc of its
|
|
242
|
+
* own. Only the folders the component's own source imports from are read.
|
|
243
|
+
* @param {string} srcDir
|
|
244
|
+
* @param {string} name
|
|
245
|
+
* @returns {string | null}
|
|
246
|
+
*/
|
|
247
|
+
function findFamilyDoc(srcDir, name) {
|
|
248
|
+
const sourcePath = [`${name}.tsx`, `XDS${name}.tsx`]
|
|
249
|
+
.map(file => path.join(srcDir, name, file))
|
|
250
|
+
.find(file => existsCaseExact(file, srcDir));
|
|
251
|
+
if (!sourcePath) return null;
|
|
252
|
+
let source;
|
|
253
|
+
try {
|
|
254
|
+
source = fs.readFileSync(sourcePath, 'utf-8');
|
|
255
|
+
} catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
const families = [
|
|
259
|
+
...new Set([...source.matchAll(SIBLING_IMPORT_RE)].map(match => match[1])),
|
|
260
|
+
]
|
|
261
|
+
.filter(dir => dir !== name && !SKIP_DIRS.has(dir))
|
|
262
|
+
.sort();
|
|
263
|
+
for (const dir of families) {
|
|
264
|
+
const dirPath = path.join(srcDir, dir);
|
|
265
|
+
if (!existsCaseExact(dirPath, srcDir)) continue;
|
|
266
|
+
for (const file of [...listDir(dirPath)].sort()) {
|
|
267
|
+
if (!file.endsWith('.doc.mjs')) continue;
|
|
268
|
+
const docPath = path.join(dirPath, file);
|
|
269
|
+
const members = readDocMembers(docPath);
|
|
270
|
+
if (members.includes(name) || members.includes(`XDS${name}`)) {
|
|
271
|
+
return docPath;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The entry a family doc gives `name` in its `components`, when the doc is not
|
|
280
|
+
* the component's own (Stack's doc for VStack); null otherwise.
|
|
281
|
+
* @param {any} docs - a loaded component doc
|
|
282
|
+
* @param {string} name - bare component name
|
|
283
|
+
* @returns {any}
|
|
284
|
+
*/
|
|
285
|
+
export function familyMember(docs, name) {
|
|
286
|
+
if (!docs || docs.name === name || docs.name === `XDS${name}`) return null;
|
|
287
|
+
const members = Array.isArray(docs.components) ? docs.components : [];
|
|
288
|
+
return (
|
|
289
|
+
members.find(
|
|
290
|
+
(/** @type {any} */ member) =>
|
|
291
|
+
member?.name === name || member?.name === `XDS${name}`,
|
|
292
|
+
) ?? null
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
112
296
|
/**
|
|
113
297
|
* Auto-discover components by scanning for Astryx*.tsx files in core/src/.
|
|
298
|
+
* A component is listed when a doc documents it: a doc in its folder, or, for a
|
|
299
|
+
* component folder with no doc of its own, the doc `findComponentReadme` finds
|
|
300
|
+
* elsewhere (Heading's doc in the Text folder; HStack and VStack as members of
|
|
301
|
+
* Stack's doc).
|
|
114
302
|
*
|
|
115
303
|
* Returns an ordered Record where:
|
|
116
304
|
* - Grouped components use the group name as key: `'Buttons': ['Button', 'IconButton']`
|
|
@@ -192,6 +380,25 @@ export function discoverComponents(coreDir) {
|
|
|
192
380
|
}
|
|
193
381
|
}
|
|
194
382
|
|
|
383
|
+
// A component folder with no doc in it is still listed when a doc elsewhere
|
|
384
|
+
// documents it, the doc `component <Name>` opens: its own same-stem doc in a
|
|
385
|
+
// family folder (Text/Heading.doc.mjs), or a member entry in the family's doc
|
|
386
|
+
// (HStack and VStack in Stack's doc).
|
|
387
|
+
for (const entry of topEntries) {
|
|
388
|
+
if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) continue;
|
|
389
|
+
const name = entry.name;
|
|
390
|
+
if (componentGroups.has(name) || name.endsWith('Context')) continue;
|
|
391
|
+
if (listDir(path.join(srcDir, name)).some(file => file.endsWith('.doc.mjs'))) {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (!hasOwnFolder(srcDir, name)) continue;
|
|
395
|
+
const docPath = findComponentReadme(coreDir, name);
|
|
396
|
+
if (!docPath?.endsWith('.doc.mjs')) continue;
|
|
397
|
+
const meta = readDocMeta(docPath);
|
|
398
|
+
if (meta.hidden || meta.hiddenComponents.has(name)) continue;
|
|
399
|
+
componentGroups.set(name, meta.group);
|
|
400
|
+
}
|
|
401
|
+
|
|
195
402
|
// Build the result: group name → sorted members, or component name → [self]
|
|
196
403
|
/** @type {Map<string, string[]>} */
|
|
197
404
|
const groups = new Map();
|
|
@@ -284,6 +491,10 @@ export function findComponentReadme(coreDir, name) {
|
|
|
284
491
|
}
|
|
285
492
|
}
|
|
286
493
|
|
|
494
|
+
// A component with its own folder, documented as a member of its family's
|
|
495
|
+
// doc (HStack and VStack in Stack's doc).
|
|
496
|
+
if (hasOwnFolder(srcDir, name)) return findFamilyDoc(srcDir, name);
|
|
497
|
+
|
|
287
498
|
return null;
|
|
288
499
|
}
|
|
289
500
|
|
|
@@ -16,6 +16,7 @@ import {fileURLToPath} from 'node:url';
|
|
|
16
16
|
import {
|
|
17
17
|
CORE_PACKAGE,
|
|
18
18
|
discoverComponents,
|
|
19
|
+
familyMember,
|
|
19
20
|
findComponentReadme,
|
|
20
21
|
findComponentSource,
|
|
21
22
|
resolveImportPath,
|
|
@@ -121,6 +122,91 @@ describe('discoverComponents (real core)', () => {
|
|
|
121
122
|
expect(discoverComponents(core)).toEqual({});
|
|
122
123
|
});
|
|
123
124
|
|
|
125
|
+
it('lists HStack, VStack and Heading, whose docs live in their family folders', () => {
|
|
126
|
+
// Each has its own folder and import path; Stack's doc documents HStack and
|
|
127
|
+
// VStack as members, and Heading's own doc sits in the Text folder.
|
|
128
|
+
const comps = discoverComponents(CORE);
|
|
129
|
+
expect(comps.Layout).toEqual(expect.arrayContaining(['HStack', 'Stack', 'VStack']));
|
|
130
|
+
expect(comps.Heading).toEqual(['Heading']);
|
|
131
|
+
expect(findComponentReadme(CORE, 'VStack')).toMatch(/[\\/]Stack[\\/]Stack\.doc\.mjs$/);
|
|
132
|
+
expect(findComponentReadme(CORE, 'HStack')).toMatch(/[\\/]Stack[\\/]Stack\.doc\.mjs$/);
|
|
133
|
+
expect(findComponentReadme(CORE, 'Heading')).toMatch(/[\\/]Text[\\/]Heading\.doc\.mjs$/);
|
|
134
|
+
expect(resolveImportPath(CORE, 'VStack')).toBe('@astryxdesign/core/VStack');
|
|
135
|
+
expect(resolveImportPath(CORE, 'Heading')).toBe('@astryxdesign/core/Heading');
|
|
136
|
+
}, SLOW);
|
|
137
|
+
|
|
138
|
+
it('lists a component folder with no doc when its family doc lists it as a member', () => {
|
|
139
|
+
const core = mkTmp('as-cd-family-');
|
|
140
|
+
const write = (rel, text) => {
|
|
141
|
+
fs.mkdirSync(path.dirname(path.join(core, 'src', rel)), {recursive: true});
|
|
142
|
+
fs.writeFileSync(path.join(core, 'src', rel), text);
|
|
143
|
+
};
|
|
144
|
+
write('Stack/Stack.tsx', 'x');
|
|
145
|
+
write('VStack/VStack.tsx', "import {Stack} from '../Stack/Stack';\n");
|
|
146
|
+
write('Prose/Prose.tsx', "import {Stack} from '../Stack/Stack';\n");
|
|
147
|
+
// Listed by Stack's doc, but builds on nothing in Stack's folder.
|
|
148
|
+
write('Orphan/Orphan.tsx', "import {x} from '../utils';\n");
|
|
149
|
+
write(
|
|
150
|
+
'Stack/Stack.doc.mjs',
|
|
151
|
+
'export const docs = {\n' +
|
|
152
|
+
" name: 'Stack',\n" +
|
|
153
|
+
" group: 'Layout',\n" +
|
|
154
|
+
" relatedComponents: ['Prose'],\n" +
|
|
155
|
+
" usage: {description: 'Pair with Prose.', examples: [{code: \"<Prose label='Prose' />\"}]},\n" +
|
|
156
|
+
" components: [{name: 'Stack'}, {name: 'VStack', description: 'Vertical.'}, {name: 'Orphan'}],\n" +
|
|
157
|
+
'};\n',
|
|
158
|
+
);
|
|
159
|
+
expect(discoverComponents(core)).toEqual({Layout: ['Stack', 'VStack']});
|
|
160
|
+
expect(findComponentReadme(core, 'VStack')).toBe(
|
|
161
|
+
path.join(core, 'src', 'Stack', 'Stack.doc.mjs'),
|
|
162
|
+
);
|
|
163
|
+
// Named only in relatedComponents, prose, and an example: not documented.
|
|
164
|
+
expect(findComponentReadme(core, 'Prose')).toBeNull();
|
|
165
|
+
// A family doc is read only from the folders the component imports.
|
|
166
|
+
expect(findComponentReadme(core, 'Orphan')).toBeNull();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('lists a component folder with no doc when its own doc sits in a family folder', () => {
|
|
170
|
+
const core = mkTmp('as-cd-sibling-');
|
|
171
|
+
for (const dir of ['Text', 'Heading']) {
|
|
172
|
+
fs.mkdirSync(path.join(core, 'src', dir), {recursive: true});
|
|
173
|
+
}
|
|
174
|
+
fs.writeFileSync(path.join(core, 'src', 'Text', 'Text.tsx'), 'x');
|
|
175
|
+
fs.writeFileSync(path.join(core, 'src', 'Heading', 'Heading.tsx'), 'x');
|
|
176
|
+
fs.writeFileSync(
|
|
177
|
+
path.join(core, 'src', 'Text', 'Text.doc.mjs'),
|
|
178
|
+
"export const docs = {name: 'Text', components: [{name: 'Heading'}]};\n",
|
|
179
|
+
);
|
|
180
|
+
fs.writeFileSync(
|
|
181
|
+
path.join(core, 'src', 'Text', 'Heading.doc.mjs'),
|
|
182
|
+
"export const docs = {name: 'Heading'};\n",
|
|
183
|
+
);
|
|
184
|
+
expect(discoverComponents(core)).toEqual({Heading: ['Heading'], Text: ['Text']});
|
|
185
|
+
expect(findComponentReadme(core, 'Heading')).toBe(
|
|
186
|
+
path.join(core, 'src', 'Text', 'Heading.doc.mjs'),
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('keeps a member its family hides, and a context folder, unlisted', () => {
|
|
191
|
+
const core = mkTmp('as-cd-family-hidden-');
|
|
192
|
+
for (const dir of ['Stack', 'VStack', 'SizeContext']) {
|
|
193
|
+
fs.mkdirSync(path.join(core, 'src', dir), {recursive: true});
|
|
194
|
+
fs.writeFileSync(
|
|
195
|
+
path.join(core, 'src', dir, `${dir}.tsx`),
|
|
196
|
+
"import {Stack} from '../Stack/Stack';\n",
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
fs.writeFileSync(
|
|
200
|
+
path.join(core, 'src', 'Stack', 'Stack.doc.mjs'),
|
|
201
|
+
'export const docs = {\n' +
|
|
202
|
+
" name: 'Stack',\n" +
|
|
203
|
+
" hiddenComponents: ['VStack'],\n" +
|
|
204
|
+
" components: [{name: 'Stack'}, {name: 'VStack'}, {name: 'SizeContext'}],\n" +
|
|
205
|
+
'};\n',
|
|
206
|
+
);
|
|
207
|
+
expect(discoverComponents(core)).toEqual({Stack: ['Stack']});
|
|
208
|
+
});
|
|
209
|
+
|
|
124
210
|
it('returns {} for an empty src/ dir', () => {
|
|
125
211
|
const core = mkTmp('as-cd-emptysrc-');
|
|
126
212
|
fs.mkdirSync(path.join(core, 'src'));
|
|
@@ -133,6 +219,16 @@ describe('discoverComponents (real core)', () => {
|
|
|
133
219
|
});
|
|
134
220
|
});
|
|
135
221
|
|
|
222
|
+
describe('familyMember', () => {
|
|
223
|
+
it("returns a family doc's entry for a member, and null for a component's own doc", () => {
|
|
224
|
+
const stack = {name: 'Stack', components: [{name: 'Stack'}, {name: 'VStack', description: 'v'}]};
|
|
225
|
+
expect(familyMember(stack, 'VStack')).toEqual({name: 'VStack', description: 'v'});
|
|
226
|
+
expect(familyMember(stack, 'Stack')).toBeNull();
|
|
227
|
+
expect(familyMember({name: 'Button'}, 'Button')).toBeNull();
|
|
228
|
+
expect(familyMember(stack, 'HStack')).toBeNull();
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
136
232
|
describe('findComponentReadme / findComponentSource / resolveImportPath', () => {
|
|
137
233
|
it('finds the doc + source for a top-level component', () => {
|
|
138
234
|
expect(findComponentReadme(CORE, 'Button')).toMatch(/Button\.doc\.mjs$/);
|
|
@@ -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
|
)
|
|
@@ -246,12 +246,16 @@ const RUNNERS = {
|
|
|
246
246
|
runs: 'astryx.config, through the config parser',
|
|
247
247
|
sites: ['loadModuleWithParser(..., parseConfig)'],
|
|
248
248
|
},
|
|
249
|
+
'foundation/discovery/component-discovery.mjs': {
|
|
250
|
+
runs: '@babel/parser, on first use, to read the members a family doc lists in its components without running the doc',
|
|
251
|
+
sites: ['require(@babel/parser)'],
|
|
252
|
+
},
|
|
249
253
|
'foundation/discovery/docs-discovery.mjs': {
|
|
250
254
|
runs: 'a contributed topic, for its catalog fields: name, title, description, category, and relationships',
|
|
251
255
|
sites: ['importDocModule'],
|
|
252
256
|
},
|
|
253
257
|
'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',
|
|
258
|
+
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
259
|
sites: ['importDocModule', 'require(@babel/parser)'],
|
|
256
260
|
},
|
|
257
261
|
'foundation/discovery/theme-discovery.mjs': {
|
|
@@ -81,8 +81,31 @@ const NON_DESCRIPTOR_DIRS = new Set([
|
|
|
81
81
|
export async function collectDocInputs(project) {
|
|
82
82
|
const list = new InputList();
|
|
83
83
|
|
|
84
|
+
// A component documented as a member of its family's doc (HStack and VStack
|
|
85
|
+
// in Stack's doc) is read through the family's own entry: one descriptor,
|
|
86
|
+
// one reader. Own docs go first so the family keeps its name.
|
|
87
|
+
/** @type {Array<{name: string, package: string, docPath: string}>} */
|
|
88
|
+
const ownDocs = [];
|
|
89
|
+
/** @type {Array<{name: string, package: string, docPath: string}>} */
|
|
90
|
+
const familyDocs = [];
|
|
84
91
|
for (const record of await project.components()) {
|
|
85
92
|
if (record.docPath == null) continue;
|
|
93
|
+
const stem = path
|
|
94
|
+
.basename(record.docPath)
|
|
95
|
+
.replace(/\.doc\.[cm]?[jt]s$/u, '')
|
|
96
|
+
.replace(/^XDS/u, '');
|
|
97
|
+
const entry = {
|
|
98
|
+
name: record.name,
|
|
99
|
+
package: record.package,
|
|
100
|
+
docPath: record.docPath,
|
|
101
|
+
};
|
|
102
|
+
(stem === record.name ? ownDocs : familyDocs).push(entry);
|
|
103
|
+
}
|
|
104
|
+
for (const record of ownDocs) {
|
|
105
|
+
list.add('components', record.name, record.package, record.docPath);
|
|
106
|
+
}
|
|
107
|
+
for (const record of familyDocs) {
|
|
108
|
+
if (list.claims(record.docPath)) continue;
|
|
86
109
|
list.add('components', record.name, record.package, record.docPath);
|
|
87
110
|
}
|
|
88
111
|
|
|
@@ -20,6 +20,7 @@ import path from 'node:path';
|
|
|
20
20
|
import {findCoreDir} from '../fs/paths.mjs';
|
|
21
21
|
import {
|
|
22
22
|
discoverComponents,
|
|
23
|
+
familyMember,
|
|
23
24
|
findComponentReadme,
|
|
24
25
|
resolveImportPath,
|
|
25
26
|
} from '../discovery/component-discovery.mjs';
|
|
@@ -126,6 +127,9 @@ export async function buildRegistry({cwd = process.cwd()} = {}) {
|
|
|
126
127
|
} catch {
|
|
127
128
|
continue; // a malformed doc must not take down the whole language
|
|
128
129
|
}
|
|
130
|
+
// A member documented in its family's doc (VStack in Stack's doc) is
|
|
131
|
+
// registered by the family's own folder, with every other member.
|
|
132
|
+
if (familyMember(docs, dirName)) continue;
|
|
129
133
|
const importPath = resolveImportPath(coreDir, dirName);
|
|
130
134
|
|
|
131
135
|
const register = (/** @type {string} */ rawName, /** @type {import('./xle-ast').DocProp[]} */ props) => {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file The layout-language registry over the real @astryxdesign/core tree:
|
|
5
|
+
* the import path it emits for each component.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {describe, it, expect} from 'vitest';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import {fileURLToPath} from 'node:url';
|
|
11
|
+
import {buildRegistry} from './registry.mjs';
|
|
12
|
+
|
|
13
|
+
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..');
|
|
14
|
+
|
|
15
|
+
describe('buildRegistry import paths', () => {
|
|
16
|
+
it('keeps the Stack family on the paths it registers from Stack\'s folder', async () => {
|
|
17
|
+
// HStack and VStack are listed components whose docs live in Stack's doc.
|
|
18
|
+
// Listing them must not let their folders re-register the family under
|
|
19
|
+
// their own paths: a member's folder registers nothing, Stack's does.
|
|
20
|
+
const {components} = await buildRegistry({cwd: REPO});
|
|
21
|
+
const importOf = (/** @type {string} */ name) => components.get(name)?.importPath;
|
|
22
|
+
for (const name of ['Stack', 'StackItem', 'HStack', 'VStack']) {
|
|
23
|
+
expect(importOf(name), name).toBe('@astryxdesign/core/Stack');
|
|
24
|
+
}
|
|
25
|
+
expect(importOf('Heading')).toBe('@astryxdesign/core/Heading');
|
|
26
|
+
expect(importOf('Text')).toBe('@astryxdesign/core/Text');
|
|
27
|
+
}, 120_000);
|
|
28
|
+
});
|
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.e2909cf",
|
|
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.e2909cf",
|
|
105
|
+
"@astryxdesign/core": "0.7.2-canary.e2909cf",
|
|
106
|
+
"@astryxdesign/lab": "0.7.2-canary.e2909cf",
|
|
107
|
+
"@astryxdesign/theme-neutral": "0.7.2-canary.e2909cf"
|
|
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.e2909cf",
|
|
125
|
+
"@astryxdesign/core": "0.7.2-canary.e2909cf",
|
|
126
|
+
"@astryxdesign/lab": "0.7.2-canary.e2909cf",
|
|
127
|
+
"@astryxdesign/theme-neutral": "0.7.2-canary.e2909cf",
|
|
128
128
|
"@heroicons/react": "^2.2.0",
|
|
129
129
|
"@stylexjs/stylex": "^0.19.0",
|
|
130
130
|
"@types/babel__core": "^7.20.5",
|