@astryxdesign/cli 0.7.2-canary.db3a2e7 → 0.7.2-canary.ebcc404
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/_adapter.d.mts +27 -1
- package/api/component/_adapter.mjs +45 -7
- package/api/component/component.mjs +12 -3
- package/api/component/component.test.mjs +420 -167
- package/api/component/integration-export-name.test.mjs +419 -0
- package/api/component/list/list.mjs +42 -4
- package/api/gap-report/gap-report.mjs +66 -4
- package/api/integration/pack-check.mjs +13 -104
- package/api/integration/pack-check.test.mjs +112 -0
- package/api/search/search.d.mts +9 -0
- package/api/search/search.mjs +90 -10
- package/api/search/search.test.mjs +36 -0
- package/api/swizzle/copy/copy.mjs +28 -14
- 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/tree/component-imports.doc.mjs +8 -0
- package/assets/docs/working-with-ai.doc.mjs +43 -4
- package/assets/templates/blocks/components/Drawer/DrawerForm.doc.mjs +24 -0
- package/assets/templates/blocks/components/Drawer/DrawerForm.tsx +62 -0
- package/assets/templates/blocks/components/Drawer/DrawerScrim.doc.mjs +22 -0
- package/assets/templates/blocks/components/Drawer/DrawerScrim.tsx +68 -0
- package/assets/templates/blocks/components/Drawer/DrawerShowcase.doc.mjs +22 -0
- package/assets/templates/blocks/components/Drawer/DrawerShowcase.tsx +41 -0
- package/assets/templates/blocks/components/Drawer/DrawerStart.doc.mjs +21 -0
- package/assets/templates/blocks/components/Drawer/DrawerStart.tsx +41 -0
- package/assets/templates/blocks/components/Drawer/DrawerWidths.doc.mjs +22 -0
- package/assets/templates/blocks/components/Drawer/DrawerWidths.tsx +54 -0
- package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderShowcase.doc.mjs +15 -0
- package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderShowcase.tsx +73 -0
- package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderSubtitleEndContent.doc.mjs +22 -0
- package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderSubtitleEndContent.tsx +49 -0
- package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderWithCloseButton.doc.mjs +21 -0
- package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderWithCloseButton.tsx +42 -0
- package/assets/templates/pages/detail-page/page.tsx +3 -3
- package/assets/templates/pages/table-inbox/page.tsx +10 -8
- package/authoring/doctypes/base/type.ts +6 -0
- package/authoring/doctypes/component/component.doc.mjs +3 -3
- package/authoring/doctypes/component/type.ts +6 -3
- 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/clients/cli/lib/component-format.mjs +5 -1
- package/foundation/discovery/component-discovery.d.mts +95 -6
- package/foundation/discovery/component-discovery.export-names.test.mjs +293 -0
- package/foundation/discovery/component-discovery.mjs +524 -49
- package/foundation/discovery/component-discovery.test.mjs +96 -0
- package/foundation/discovery/component-replacement.mjs +18 -0
- package/foundation/discovery/component-replacement.test.mjs +20 -0
- package/foundation/discovery/module-exports.d.mts +21 -0
- package/foundation/discovery/module-exports.mjs +343 -31
- package/foundation/discovery/module-exports.test.mjs +172 -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 +10 -10
|
@@ -5,11 +5,16 @@
|
|
|
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';
|
|
10
|
+
import {DOC_EXPORTS} from '../doc-compiler/read.mjs';
|
|
9
11
|
import {listDir, listDirEntries, pathExists} from '../fs/listing-cache.mjs';
|
|
10
12
|
import {existsCaseExact} from '../fs/paths.mjs';
|
|
11
13
|
import {CORE_PROVIDER_ID} from '../identity/providers.mjs';
|
|
12
14
|
import {loadComponentDoc} from './component-loader.mjs';
|
|
15
|
+
import {moduleExportsName, staticExportObject} from './module-exports.mjs';
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
13
18
|
|
|
14
19
|
const SKIP_DIRS = new Set(['hooks', 'utils', '__tests__', 'node_modules']);
|
|
15
20
|
|
|
@@ -79,12 +84,35 @@ function componentNameFromFile(fileName) {
|
|
|
79
84
|
const GROUP_RE = /(?:^|\n) {0,2}group:\s*['"]([^'"]+)['"]/;
|
|
80
85
|
const HIDDEN_COMPONENTS_RE = /(?:^|\n) {0,2}hiddenComponents:\s*\[([^\]]*)\]/;
|
|
81
86
|
const HIDDEN_RE = /(?:^|\n) {0,2}hidden:\s*true/;
|
|
87
|
+
// Every top-level `name: '...'` (or `"name": "..."`) in the file: a quick check
|
|
88
|
+
// that a doc is named after its file before anything parses it.
|
|
89
|
+
const NAME_FIELD_RE = /(?:^|\n) {0,2}['"]?name['"]?\s*:\s*['"]([^'"\n]+)['"]/g;
|
|
90
|
+
// A top-level `replaces: '...'`: the Core name a doc claims for its package.
|
|
91
|
+
const REPLACES_FIELD_RE =
|
|
92
|
+
/(?:^|\n) {0,2}['"]?replaces['"]?\s*:\s*['"]([^'"\n]+)['"]/;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* What a quick scan of a doc's text finds before anything parses it: the
|
|
96
|
+
* top-level `name` values it writes (none when a spread could set the name)
|
|
97
|
+
* and the Core name it replaces.
|
|
98
|
+
* @param {string} text
|
|
99
|
+
* @returns {{names: string[], replaces: string | null}}
|
|
100
|
+
*/
|
|
101
|
+
function scanDocText(text) {
|
|
102
|
+
return {
|
|
103
|
+
names: text.includes('...')
|
|
104
|
+
? []
|
|
105
|
+
: [...text.matchAll(NAME_FIELD_RE)].map(match => match[1]),
|
|
106
|
+
replaces: REPLACES_FIELD_RE.exec(text)?.[1] ?? null,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
82
109
|
|
|
83
110
|
/**
|
|
84
111
|
* Read the `group`, `hiddenComponents`, and `hidden` fields from a
|
|
85
|
-
* component's .doc.mjs file (synchronous)
|
|
112
|
+
* component's .doc.mjs file (synchronous), with what {@link scanDocText}
|
|
113
|
+
* finds in it.
|
|
86
114
|
* @param {string} docPath
|
|
87
|
-
* @returns {{group: string | null, hiddenComponents: Set<string>, hidden: boolean}}
|
|
115
|
+
* @returns {{group: string | null, hiddenComponents: Set<string>, hidden: boolean, names: string[], replaces: string | null}}
|
|
88
116
|
*/
|
|
89
117
|
function readDocMeta(docPath) {
|
|
90
118
|
try {
|
|
@@ -103,14 +131,206 @@ function readDocMeta(docPath) {
|
|
|
103
131
|
group: groupMatch ? groupMatch[1] : null,
|
|
104
132
|
hiddenComponents: hiddenSet,
|
|
105
133
|
hidden,
|
|
134
|
+
...scanDocText(content),
|
|
106
135
|
};
|
|
107
136
|
} catch {
|
|
108
|
-
return {
|
|
137
|
+
return {
|
|
138
|
+
group: null,
|
|
139
|
+
hiddenComponents: new Set(),
|
|
140
|
+
hidden: false,
|
|
141
|
+
names: [],
|
|
142
|
+
replaces: null,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** @type {typeof import('@babel/parser') | undefined} */
|
|
148
|
+
let babelParser;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A property of an object literal, by its plain key.
|
|
152
|
+
* @param {any} object - an ObjectExpression node
|
|
153
|
+
* @param {string} key
|
|
154
|
+
* @returns {any}
|
|
155
|
+
*/
|
|
156
|
+
function propertyValue(object, key) {
|
|
157
|
+
for (const property of object.properties) {
|
|
158
|
+
if (property.type !== 'ObjectProperty' || property.computed) continue;
|
|
159
|
+
const name =
|
|
160
|
+
property.key.type === 'Identifier'
|
|
161
|
+
? property.key.name
|
|
162
|
+
: property.key.type === 'StringLiteral'
|
|
163
|
+
? property.key.value
|
|
164
|
+
: null;
|
|
165
|
+
if (name === key) return property.value;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The names a component doc lists as `components` members, read from its
|
|
172
|
+
* source without running it. Only a static `{name: '<Name>'}` entry in the doc
|
|
173
|
+
* object the loader reads (the `default` export, else `docs`) counts: a name in
|
|
174
|
+
* prose, an example, or `relatedComponents` is not a member.
|
|
175
|
+
* @param {string} source - the doc file's text
|
|
176
|
+
* @returns {string[]}
|
|
177
|
+
*/
|
|
178
|
+
function docMemberNames(source) {
|
|
179
|
+
/** @type {any} */
|
|
180
|
+
let ast;
|
|
181
|
+
try {
|
|
182
|
+
babelParser ??= require('@babel/parser');
|
|
183
|
+
ast = /** @type {typeof import('@babel/parser')} */ (babelParser).parse(source, {
|
|
184
|
+
sourceType: 'module',
|
|
185
|
+
});
|
|
186
|
+
} catch {
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
/** @type {Map<string, any>} */
|
|
190
|
+
const constants = new Map();
|
|
191
|
+
let defaultExport = null;
|
|
192
|
+
let docsExport = null;
|
|
193
|
+
for (const node of ast.program.body) {
|
|
194
|
+
const declaration =
|
|
195
|
+
node.type === 'ExportNamedDeclaration' ? node.declaration : node;
|
|
196
|
+
if (declaration?.type === 'VariableDeclaration') {
|
|
197
|
+
for (const declarator of declaration.declarations) {
|
|
198
|
+
if (declarator.id.type !== 'Identifier' || !declarator.init) continue;
|
|
199
|
+
constants.set(declarator.id.name, declarator.init);
|
|
200
|
+
if (node.type === 'ExportNamedDeclaration' && declarator.id.name === 'docs') {
|
|
201
|
+
docsExport = declarator.init;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (node.type === 'ExportDefaultDeclaration') defaultExport = node.declaration;
|
|
109
206
|
}
|
|
207
|
+
let doc = defaultExport ?? docsExport;
|
|
208
|
+
if (doc?.type === 'Identifier') doc = constants.get(doc.name);
|
|
209
|
+
if (doc?.type !== 'ObjectExpression') return [];
|
|
210
|
+
const members = propertyValue(doc, 'components');
|
|
211
|
+
if (members?.type !== 'ArrayExpression') return [];
|
|
212
|
+
return members.elements.flatMap((/** @type {any} */ element) => {
|
|
213
|
+
if (element?.type !== 'ObjectExpression') return [];
|
|
214
|
+
const name = propertyValue(element, 'name');
|
|
215
|
+
return name?.type === 'StringLiteral' ? [name.value] : [];
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Whether `name` has its own component folder in Core: `src/<Name>/<Name>.tsx`
|
|
221
|
+
* (or the `XDS`-prefixed form).
|
|
222
|
+
* @param {string} srcDir
|
|
223
|
+
* @param {string} name
|
|
224
|
+
* @returns {boolean}
|
|
225
|
+
*/
|
|
226
|
+
function hasOwnFolder(srcDir, name) {
|
|
227
|
+
return [`${name}.tsx`, `XDS${name}.tsx`].some(file =>
|
|
228
|
+
existsCaseExact(path.join(srcDir, name, file), srcDir),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Member names read in this process, by doc path, reused while the file keeps
|
|
234
|
+
* its size and mtime.
|
|
235
|
+
* @type {Map<string, {size: number, mtimeMs: number, members: string[]}>}
|
|
236
|
+
*/
|
|
237
|
+
const memberReads = new Map();
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The names a doc lists as `components` members (see docMemberNames).
|
|
241
|
+
* @param {string} docPath
|
|
242
|
+
* @returns {string[]}
|
|
243
|
+
*/
|
|
244
|
+
function readDocMembers(docPath) {
|
|
245
|
+
let stat;
|
|
246
|
+
try {
|
|
247
|
+
stat = fs.statSync(docPath);
|
|
248
|
+
} catch {
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
const cached = memberReads.get(docPath);
|
|
252
|
+
if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
|
|
253
|
+
return cached.members;
|
|
254
|
+
}
|
|
255
|
+
/** @type {string[]} */
|
|
256
|
+
let members;
|
|
257
|
+
try {
|
|
258
|
+
members = docMemberNames(fs.readFileSync(docPath, 'utf-8'));
|
|
259
|
+
} catch {
|
|
260
|
+
members = [];
|
|
261
|
+
}
|
|
262
|
+
memberReads.set(docPath, {size: stat.size, mtimeMs: stat.mtimeMs, members});
|
|
263
|
+
return members;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// A sibling component folder named by a relative import or re-export.
|
|
267
|
+
const SIBLING_IMPORT_RE =
|
|
268
|
+
/(?:\bfrom|\bimport\s*\()\s*['"]\.\.\/([A-Za-z0-9_-]+)(?:\/[^'"]*)?['"]/g;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The Core doc that documents `name` as one of its `components` members: the
|
|
272
|
+
* doc of a component it builds on. HStack and VStack import Stack, and Stack's
|
|
273
|
+
* doc lists them, so each has its own folder and import path but no doc of its
|
|
274
|
+
* own. Only the folders the component's own source imports from are read.
|
|
275
|
+
* @param {string} srcDir
|
|
276
|
+
* @param {string} name
|
|
277
|
+
* @returns {string | null}
|
|
278
|
+
*/
|
|
279
|
+
function findFamilyDoc(srcDir, name) {
|
|
280
|
+
const sourcePath = [`${name}.tsx`, `XDS${name}.tsx`]
|
|
281
|
+
.map(file => path.join(srcDir, name, file))
|
|
282
|
+
.find(file => existsCaseExact(file, srcDir));
|
|
283
|
+
if (!sourcePath) return null;
|
|
284
|
+
let source;
|
|
285
|
+
try {
|
|
286
|
+
source = fs.readFileSync(sourcePath, 'utf-8');
|
|
287
|
+
} catch {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
const families = [
|
|
291
|
+
...new Set([...source.matchAll(SIBLING_IMPORT_RE)].map(match => match[1])),
|
|
292
|
+
]
|
|
293
|
+
.filter(dir => dir !== name && !SKIP_DIRS.has(dir))
|
|
294
|
+
.sort();
|
|
295
|
+
for (const dir of families) {
|
|
296
|
+
const dirPath = path.join(srcDir, dir);
|
|
297
|
+
if (!existsCaseExact(dirPath, srcDir)) continue;
|
|
298
|
+
for (const file of [...listDir(dirPath)].sort()) {
|
|
299
|
+
if (!file.endsWith('.doc.mjs')) continue;
|
|
300
|
+
const docPath = path.join(dirPath, file);
|
|
301
|
+
const members = readDocMembers(docPath);
|
|
302
|
+
if (members.includes(name) || members.includes(`XDS${name}`)) {
|
|
303
|
+
return docPath;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The entry a family doc gives `name` in its `components`, when the doc is not
|
|
312
|
+
* the component's own (Stack's doc for VStack); null otherwise.
|
|
313
|
+
* @param {any} docs - a loaded component doc
|
|
314
|
+
* @param {string} name - bare component name
|
|
315
|
+
* @returns {any}
|
|
316
|
+
*/
|
|
317
|
+
export function familyMember(docs, name) {
|
|
318
|
+
if (!docs || docs.name === name || docs.name === `XDS${name}`) return null;
|
|
319
|
+
const members = Array.isArray(docs.components) ? docs.components : [];
|
|
320
|
+
return (
|
|
321
|
+
members.find(
|
|
322
|
+
(/** @type {any} */ member) =>
|
|
323
|
+
member?.name === name || member?.name === `XDS${name}`,
|
|
324
|
+
) ?? null
|
|
325
|
+
);
|
|
110
326
|
}
|
|
111
327
|
|
|
112
328
|
/**
|
|
113
329
|
* Auto-discover components by scanning for Astryx*.tsx files in core/src/.
|
|
330
|
+
* A component is listed when a doc documents it: a doc in its folder, or, for a
|
|
331
|
+
* component folder with no doc of its own, the doc `findComponentReadme` finds
|
|
332
|
+
* elsewhere (Heading's doc in the Text folder; HStack and VStack as members of
|
|
333
|
+
* Stack's doc).
|
|
114
334
|
*
|
|
115
335
|
* Returns an ordered Record where:
|
|
116
336
|
* - Grouped components use the group name as key: `'Buttons': ['Button', 'IconButton']`
|
|
@@ -192,6 +412,25 @@ export function discoverComponents(coreDir) {
|
|
|
192
412
|
}
|
|
193
413
|
}
|
|
194
414
|
|
|
415
|
+
// A component folder with no doc in it is still listed when a doc elsewhere
|
|
416
|
+
// documents it, the doc `component <Name>` opens: its own same-stem doc in a
|
|
417
|
+
// family folder (Text/Heading.doc.mjs), or a member entry in the family's doc
|
|
418
|
+
// (HStack and VStack in Stack's doc).
|
|
419
|
+
for (const entry of topEntries) {
|
|
420
|
+
if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) continue;
|
|
421
|
+
const name = entry.name;
|
|
422
|
+
if (componentGroups.has(name) || name.endsWith('Context')) continue;
|
|
423
|
+
if (listDir(path.join(srcDir, name)).some(file => file.endsWith('.doc.mjs'))) {
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (!hasOwnFolder(srcDir, name)) continue;
|
|
427
|
+
const docPath = findComponentReadme(coreDir, name);
|
|
428
|
+
if (!docPath?.endsWith('.doc.mjs')) continue;
|
|
429
|
+
const meta = readDocMeta(docPath);
|
|
430
|
+
if (meta.hidden || meta.hiddenComponents.has(name)) continue;
|
|
431
|
+
componentGroups.set(name, meta.group);
|
|
432
|
+
}
|
|
433
|
+
|
|
195
434
|
// Build the result: group name → sorted members, or component name → [self]
|
|
196
435
|
/** @type {Map<string, string[]>} */
|
|
197
436
|
const groups = new Map();
|
|
@@ -284,6 +523,10 @@ export function findComponentReadme(coreDir, name) {
|
|
|
284
523
|
}
|
|
285
524
|
}
|
|
286
525
|
|
|
526
|
+
// A component with its own folder, documented as a member of its family's
|
|
527
|
+
// doc (HStack and VStack in Stack's doc).
|
|
528
|
+
if (hasOwnFolder(srcDir, name)) return findFamilyDoc(srcDir, name);
|
|
529
|
+
|
|
287
530
|
return null;
|
|
288
531
|
}
|
|
289
532
|
|
|
@@ -632,6 +875,95 @@ export function findExternalComponentDoc(docsDir, name) {
|
|
|
632
875
|
// commands — and the future integration-component swizzle — can disambiguate
|
|
633
876
|
// by package and route source/issues correctly.
|
|
634
877
|
|
|
878
|
+
// A doc's `name` can name another export of its file; every other doc reads as
|
|
879
|
+
// before.
|
|
880
|
+
const COMPONENT_NAME_RE = /^[A-Z][\w$]*$/;
|
|
881
|
+
|
|
882
|
+
/** @type {Map<string, {stamp: string, value: Record<string, any> | null}>} doc reads, reused while the doc file is unchanged */
|
|
883
|
+
const docReads = new Map();
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* A file's identity for a cache: ctime and inode change on a rewrite even when
|
|
887
|
+
* npm stamps the same size and mtime.
|
|
888
|
+
* @param {string} file
|
|
889
|
+
*/
|
|
890
|
+
function fileStamp(file) {
|
|
891
|
+
try {
|
|
892
|
+
const {size, mtimeMs, ctimeMs, ino} = fs.statSync(file);
|
|
893
|
+
return `${size}:${mtimeMs}:${ctimeMs}:${ino}`;
|
|
894
|
+
} catch {
|
|
895
|
+
return 'missing';
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** UTF-16 code-unit order, the same in every locale. @param {string} a @param {string} b */
|
|
900
|
+
function byCodeUnit(a, b) {
|
|
901
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* A component doc's exported object as plain data, read without running it.
|
|
906
|
+
* @param {string} docPath
|
|
907
|
+
* @returns {Record<string, any> | null}
|
|
908
|
+
*/
|
|
909
|
+
function docObject(docPath) {
|
|
910
|
+
const stamp = fileStamp(docPath);
|
|
911
|
+
const cached = docReads.get(docPath);
|
|
912
|
+
if (cached?.stamp === stamp) return cached.value;
|
|
913
|
+
const value = staticExportObject(docPath, DOC_EXPORTS.components);
|
|
914
|
+
docReads.set(docPath, {stamp, value});
|
|
915
|
+
return value;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/** @param {string} docPath */
|
|
919
|
+
export function docStem(docPath) {
|
|
920
|
+
return path.basename(docPath).replace(/\.doc\.(ts|mjs|js)$/, '');
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* The export a component doc names when its same-stem source is not named
|
|
925
|
+
* after an export, or null to keep the stem.
|
|
926
|
+
* @param {{docPath: string, sourcePath: string|null, stem: string, names: string[]}} doc
|
|
927
|
+
* `names`: top-level names a quick scan found (none: the doc is read)
|
|
928
|
+
* @returns {string | null}
|
|
929
|
+
*/
|
|
930
|
+
function otherExportName({docPath, sourcePath, stem, names}) {
|
|
931
|
+
if (sourcePath == null) return null;
|
|
932
|
+
if (names.length > 0 && names.every(name => name === stem)) return null;
|
|
933
|
+
const name = docObject(docPath)?.name;
|
|
934
|
+
return typeof name === 'string' &&
|
|
935
|
+
name !== stem &&
|
|
936
|
+
COMPONENT_NAME_RE.test(name) &&
|
|
937
|
+
moduleExportsName(sourcePath, name) &&
|
|
938
|
+
!moduleExportsName(sourcePath, stem)
|
|
939
|
+
? name
|
|
940
|
+
: null;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Visit every component doc under an integration's components root, hidden
|
|
945
|
+
* ones included, in discovery order, with its stem.
|
|
946
|
+
* @param {string | undefined} componentsDir
|
|
947
|
+
* @param {(docPath: string, stem: string) => void} visit
|
|
948
|
+
*/
|
|
949
|
+
export function forEachIntegrationDoc(componentsDir, visit) {
|
|
950
|
+
if (!componentsDir || !pathExists(componentsDir)) return;
|
|
951
|
+
/** @param {string} dirPath */
|
|
952
|
+
function scanDir(dirPath) {
|
|
953
|
+
for (const entry of listDirEntries(dirPath)) {
|
|
954
|
+
if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
|
|
955
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
956
|
+
if (entry.isDirectory()) {
|
|
957
|
+
scanDir(fullPath);
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
const suffix = INTEGRATION_DOC_SUFFIXES.find(s => entry.name.endsWith(s));
|
|
961
|
+
if (suffix) visit(fullPath, entry.name.slice(0, -suffix.length));
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
scanDir(componentsDir);
|
|
965
|
+
}
|
|
966
|
+
|
|
635
967
|
/**
|
|
636
968
|
* Given an integration component doc path, return the sibling component source
|
|
637
969
|
* (`{Name}.tsx`) if one exists, else null.
|
|
@@ -649,49 +981,71 @@ function integrationSourceForDoc(docPath) {
|
|
|
649
981
|
/**
|
|
650
982
|
* Discover ownership records for the components contributed by a single loaded
|
|
651
983
|
* integration. Scans the integration's resolved `components` dir for same-stem
|
|
652
|
-
* doc files and records each with owner package + issuesUrl + sourcePath.
|
|
984
|
+
* doc files and records each with owner package + issuesUrl + sourcePath. A doc
|
|
985
|
+
* that names a differently named export of its file is recorded under that
|
|
986
|
+
* export unless the name is taken: every doc's file name (a hidden doc's too),
|
|
987
|
+
* a Core name a doc in the package replaces, and a name an earlier doc records.
|
|
653
988
|
*
|
|
654
989
|
* @param {{name: string, components?: string, issuesUrl?: string}} integration
|
|
655
990
|
* a single entry from `loadedIntegrations` (lib/integrations.mjs)
|
|
991
|
+
* @param {{documentedNames?: boolean}} [options] `documentedNames: false`
|
|
992
|
+
* records each doc under its file name alone, without reading the names
|
|
993
|
+
* docs document: for a caller that needs only what each doc says
|
|
656
994
|
* @returns {Array<{name: string, package: string, docPath: string, sourcePath: string|null, issuesUrl: string|undefined, group: string|null}>}
|
|
657
995
|
*/
|
|
658
|
-
export function discoverIntegrationComponents(
|
|
996
|
+
export function discoverIntegrationComponents(
|
|
997
|
+
integration,
|
|
998
|
+
{documentedNames = true} = {},
|
|
999
|
+
) {
|
|
659
1000
|
const componentsDir = integration?.components;
|
|
660
1001
|
if (!componentsDir || !pathExists(componentsDir)) return [];
|
|
661
1002
|
|
|
662
1003
|
/** @type {Map<string, {name: string, package: string, docPath: string, sourcePath: string|null, issuesUrl: string|undefined, group: string|null}>} */
|
|
663
1004
|
const byName = new Map();
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
1005
|
+
/** @type {Map<string, {names: string[], replaces: string | null}>} stem → quick scan */
|
|
1006
|
+
const scans = new Map();
|
|
1007
|
+
/** @type {Set<string>} names no doc can record under another name */
|
|
1008
|
+
const taken = new Set();
|
|
1009
|
+
|
|
1010
|
+
forEachIntegrationDoc(componentsDir, (fullPath, name) => {
|
|
1011
|
+
taken.add(name);
|
|
1012
|
+
const {group, hidden, names, replaces} = readDocMeta(fullPath);
|
|
1013
|
+
if (hidden) return;
|
|
1014
|
+
// First doc wins per name (precedence matches INTEGRATION_DOC_SUFFIXES).
|
|
1015
|
+
if (byName.has(name)) return;
|
|
1016
|
+
byName.set(name, {
|
|
1017
|
+
name,
|
|
1018
|
+
package: integration.name,
|
|
1019
|
+
docPath: fullPath,
|
|
1020
|
+
sourcePath: integrationSourceForDoc(fullPath),
|
|
1021
|
+
issuesUrl: integration.issuesUrl,
|
|
1022
|
+
group: group ?? null,
|
|
1023
|
+
});
|
|
1024
|
+
scans.set(name, {names, replaces});
|
|
1025
|
+
});
|
|
1026
|
+
if (!documentedNames) return [...byName.values()];
|
|
1027
|
+
|
|
1028
|
+
// A doc that names a differently named export of its file is listed under
|
|
1029
|
+
// that export, unless the name is taken.
|
|
1030
|
+
for (const {replaces} of scans.values()) if (replaces) taken.add(replaces);
|
|
1031
|
+
const records = [...byName.values()];
|
|
1032
|
+
const byPath = [...records].sort((a, b) => byCodeUnit(a.docPath, b.docPath));
|
|
1033
|
+
/** @type {Map<string, string>} stem → export name */
|
|
1034
|
+
const exportNames = new Map();
|
|
1035
|
+
for (const record of byPath) {
|
|
1036
|
+
const exportName = otherExportName({
|
|
1037
|
+
...record,
|
|
1038
|
+
stem: record.name,
|
|
1039
|
+
names: scans.get(record.name)?.names ?? [],
|
|
1040
|
+
});
|
|
1041
|
+
if (exportName == null || taken.has(exportName)) continue;
|
|
1042
|
+
taken.add(exportName);
|
|
1043
|
+
exportNames.set(record.name, exportName);
|
|
691
1044
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
1045
|
+
return records.map(record => {
|
|
1046
|
+
const name = exportNames.get(record.name);
|
|
1047
|
+
return name ? {...record, name} : record;
|
|
1048
|
+
});
|
|
695
1049
|
}
|
|
696
1050
|
|
|
697
1051
|
/**
|
|
@@ -699,17 +1053,20 @@ export function discoverIntegrationComponents(integration) {
|
|
|
699
1053
|
* Invalid metadata or a missing same-stem source removes only that component;
|
|
700
1054
|
* callers can report the returned errors while retaining valid siblings. A
|
|
701
1055
|
* component whose doc sets `replaces` carries the value as written; the
|
|
702
|
-
* component replacement resolver decides what it means.
|
|
1056
|
+
* component replacement resolver decides what it means. A doc that fails to
|
|
1057
|
+
* load is reported under its file name: what it says cannot be trusted.
|
|
703
1058
|
*
|
|
704
1059
|
* @param {{name: string, components?: string, issuesUrl?: string}} integration
|
|
1060
|
+
* @param {{documentedNames?: boolean}} [options] as for
|
|
1061
|
+
* {@link discoverIntegrationComponents}
|
|
705
1062
|
* @returns {Promise<{
|
|
706
1063
|
* components: Array<{name: string, package: string, docPath: string, sourcePath: string, issuesUrl: string|undefined, group: string|null, replaces?: unknown}>,
|
|
707
1064
|
* discovered: Array<{name: string, package: string, docPath: string, sourcePath: string|null, issuesUrl: string|undefined, group: string|null}>,
|
|
708
1065
|
* errors: Array<{name: string, message: string}>,
|
|
709
1066
|
* }>}
|
|
710
1067
|
*/
|
|
711
|
-
export async function discoverValidIntegrationComponents(integration) {
|
|
712
|
-
const discovered = discoverIntegrationComponents(integration);
|
|
1068
|
+
export async function discoverValidIntegrationComponents(integration, options) {
|
|
1069
|
+
const discovered = discoverIntegrationComponents(integration, options);
|
|
713
1070
|
/** @type {Array<{name: string, package: string, docPath: string, sourcePath: string, issuesUrl: string|undefined, group: string|null, replaces?: unknown}>} */
|
|
714
1071
|
const components = [];
|
|
715
1072
|
/** @type {Array<{name: string, message: string}>} */
|
|
@@ -735,9 +1092,10 @@ export async function discoverValidIntegrationComponents(integration) {
|
|
|
735
1092
|
: valid,
|
|
736
1093
|
);
|
|
737
1094
|
} catch (err) {
|
|
1095
|
+
const name = docStem(record.docPath);
|
|
738
1096
|
errors.push({
|
|
739
|
-
name
|
|
740
|
-
message: `Component "${
|
|
1097
|
+
name,
|
|
1098
|
+
message: `Component "${name}" has invalid metadata: ${
|
|
741
1099
|
err instanceof Error ? err.message : String(err)
|
|
742
1100
|
}`,
|
|
743
1101
|
});
|
|
@@ -747,19 +1105,122 @@ export async function discoverValidIntegrationComponents(integration) {
|
|
|
747
1105
|
return {components, discovered, errors};
|
|
748
1106
|
}
|
|
749
1107
|
|
|
1108
|
+
/**
|
|
1109
|
+
* How an owner holds a component name, which the owner rule
|
|
1110
|
+
* ({@link unscopedComponentOwners}) reads: `core` when Core documents it;
|
|
1111
|
+
* `file` when an integration doc named after its file is recorded under it;
|
|
1112
|
+
* `doc` when only a doc records it: an export name, or the file name of a doc
|
|
1113
|
+
* recorded under another export.
|
|
1114
|
+
* @typedef {'core' | 'file' | 'doc'} NameHold
|
|
1115
|
+
*/
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* How a discovered integration record holds its name.
|
|
1119
|
+
* @param {{name: string, docPath: string|null}} record
|
|
1120
|
+
* @returns {'file' | 'doc'}
|
|
1121
|
+
*/
|
|
1122
|
+
export function integrationRecordHold(record) {
|
|
1123
|
+
return record.docPath == null || docStem(record.docPath) === record.name
|
|
1124
|
+
? 'file'
|
|
1125
|
+
: 'doc';
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* The doc that records `name` though its file is named otherwise, with its
|
|
1130
|
+
* source, reading only docs that write the name as a string.
|
|
1131
|
+
*
|
|
1132
|
+
* @param {string} componentsDir
|
|
1133
|
+
* @param {string} name
|
|
1134
|
+
* @returns {{docPath: string, sourcePath: string|null} | null}
|
|
1135
|
+
*/
|
|
1136
|
+
function findDocumentedComponent(componentsDir, name) {
|
|
1137
|
+
if (!COMPONENT_NAME_RE.test(name)) return null;
|
|
1138
|
+
const literals = [`'${name}'`, `"${name}"`, `\`${name}\``];
|
|
1139
|
+
/** @type {Set<string>} */
|
|
1140
|
+
const stems = new Set();
|
|
1141
|
+
/** @type {Array<{docPath: string, stem: string, names: string[], replaces: string | null, sourcePath: string|null}>} */
|
|
1142
|
+
const candidates = [];
|
|
1143
|
+
forEachIntegrationDoc(componentsDir, (docPath, stem) => {
|
|
1144
|
+
let text;
|
|
1145
|
+
try {
|
|
1146
|
+
text = fs.readFileSync(docPath, 'utf-8');
|
|
1147
|
+
} catch {
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
// The docs discovery records: the first visible doc for each stem.
|
|
1151
|
+
if (HIDDEN_RE.test(text) || stems.has(stem)) return;
|
|
1152
|
+
stems.add(stem);
|
|
1153
|
+
if (!literals.some(literal => text.includes(literal))) return;
|
|
1154
|
+
candidates.push({
|
|
1155
|
+
docPath,
|
|
1156
|
+
stem,
|
|
1157
|
+
...scanDocText(text),
|
|
1158
|
+
sourcePath: integrationSourceForDoc(docPath),
|
|
1159
|
+
});
|
|
1160
|
+
});
|
|
1161
|
+
// A Core name a doc in the package replaces is the replacement's to answer.
|
|
1162
|
+
if (candidates.some(candidate => candidate.replaces === name)) return null;
|
|
1163
|
+
candidates.sort((a, b) => byCodeUnit(a.docPath, b.docPath));
|
|
1164
|
+
const found = candidates.find(
|
|
1165
|
+
candidate => otherExportName(candidate) === name,
|
|
1166
|
+
);
|
|
1167
|
+
return found ? {docPath: found.docPath, sourcePath: found.sourcePath} : null;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* The integration component a name finds, as discovery records it, and its
|
|
1172
|
+
* hold: the same-stem doc first, then a doc that records the name.
|
|
1173
|
+
*
|
|
1174
|
+
* @param {{components?: string}} integration
|
|
1175
|
+
* @param {string} name
|
|
1176
|
+
* @returns {{docPath: string, sourcePath: string|null, hold: 'file' | 'doc'} | null}
|
|
1177
|
+
*/
|
|
1178
|
+
export function findIntegrationComponent(integration, name) {
|
|
1179
|
+
const componentsDir = integration?.components;
|
|
1180
|
+
if (!componentsDir || !pathExists(componentsDir)) return null;
|
|
1181
|
+
const sameStem = findSameStemDoc(componentsDir, name);
|
|
1182
|
+
if (sameStem) {
|
|
1183
|
+
const exportName = otherExportName({
|
|
1184
|
+
docPath: sameStem,
|
|
1185
|
+
sourcePath: integrationSourceForDoc(sameStem),
|
|
1186
|
+
stem: name,
|
|
1187
|
+
names: readDocMeta(sameStem).names,
|
|
1188
|
+
});
|
|
1189
|
+
const renamed =
|
|
1190
|
+
exportName != null &&
|
|
1191
|
+
findSameStemDoc(componentsDir, exportName) == null &&
|
|
1192
|
+
findDocumentedComponent(componentsDir, exportName)?.docPath === sameStem;
|
|
1193
|
+
return {
|
|
1194
|
+
docPath: sameStem,
|
|
1195
|
+
sourcePath: integrationSourceForDoc(sameStem),
|
|
1196
|
+
hold: renamed ? 'doc' : 'file',
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
const found = findDocumentedComponent(componentsDir, name);
|
|
1200
|
+
return found && {...found, hold: 'doc'};
|
|
1201
|
+
}
|
|
1202
|
+
|
|
750
1203
|
/**
|
|
751
1204
|
* Find an integration component's doc file by name within a loaded
|
|
752
1205
|
* integration's resolved `components` dir. Honors the same-stem convention
|
|
753
|
-
* (`{Name}.doc.{ts,mjs,js}`), preferring `.ts` → `.mjs` → `.js
|
|
1206
|
+
* (`{Name}.doc.{ts,mjs,js}`), preferring `.ts` → `.mjs` → `.js`, and finds a
|
|
1207
|
+
* doc that records the component under a differently named export of its file.
|
|
754
1208
|
*
|
|
755
1209
|
* @param {{components?: string}} integration
|
|
756
1210
|
* @param {string} name bare component name (no `XDS`/`Astryx` prefix)
|
|
757
1211
|
* @returns {string|null}
|
|
758
1212
|
*/
|
|
759
1213
|
export function findIntegrationComponentDoc(integration, name) {
|
|
760
|
-
|
|
761
|
-
|
|
1214
|
+
return findIntegrationComponent(integration, name)?.docPath ?? null;
|
|
1215
|
+
}
|
|
762
1216
|
|
|
1217
|
+
/**
|
|
1218
|
+
* The first `{Name}.doc.{ts,mjs,js}` under `componentsDir`, shallowest first.
|
|
1219
|
+
* @param {string} componentsDir
|
|
1220
|
+
* @param {string} name
|
|
1221
|
+
* @returns {string|null}
|
|
1222
|
+
*/
|
|
1223
|
+
function findSameStemDoc(componentsDir, name) {
|
|
763
1224
|
/**
|
|
764
1225
|
* @param {string} dirPath
|
|
765
1226
|
* @returns {string | null}
|
|
@@ -785,18 +1246,32 @@ export function findIntegrationComponentDoc(integration, name) {
|
|
|
785
1246
|
}
|
|
786
1247
|
|
|
787
1248
|
/**
|
|
788
|
-
* Find an integration component's swizzleable source file
|
|
789
|
-
*
|
|
790
|
-
* when the integration ships docs without source.
|
|
1249
|
+
* Find an integration component's swizzleable source file by name within a
|
|
1250
|
+
* loaded integration's resolved `components` dir: the same-stem `{Name}.tsx`
|
|
1251
|
+
* of its doc. Returns null when the integration ships docs without source.
|
|
791
1252
|
*
|
|
792
1253
|
* @param {{components?: string}} integration
|
|
793
1254
|
* @param {string} name bare component name
|
|
794
1255
|
* @returns {string|null}
|
|
795
1256
|
*/
|
|
796
1257
|
export function findIntegrationComponentSource(integration, name) {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
1258
|
+
return findIntegrationComponent(integration, name)?.sourcePath ?? null;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* The owner rule: an owner that holds a name only because a doc records it
|
|
1263
|
+
* (`hold: 'doc'`) yields to Core and to every file-named owner; `--package`
|
|
1264
|
+
* still reaches it. Surfaces resolve a replacement first, then this rule, then
|
|
1265
|
+
* which docs load.
|
|
1266
|
+
*
|
|
1267
|
+
* @template {{hold?: string}} T
|
|
1268
|
+
* @param {T[]} owners each with its {@link NameHold}; an owner without one
|
|
1269
|
+
* counts as Core's or file-named
|
|
1270
|
+
* @returns {T[]}
|
|
1271
|
+
*/
|
|
1272
|
+
export function unscopedComponentOwners(owners) {
|
|
1273
|
+
const primary = owners.filter(owner => owner.hold !== 'doc');
|
|
1274
|
+
return primary.length > 0 ? primary : owners;
|
|
800
1275
|
}
|
|
801
1276
|
|
|
802
1277
|
/**
|