@astryxdesign/cli 0.7.2-canary.e2909cf → 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/integration-export-name.test.mjs +419 -0
- package/api/gap-report/gap-report.mjs +66 -4
- package/api/integration/pack-check.mjs +3 -94
- package/api/integration/pack-check.test.mjs +112 -0
- package/api/search/search.d.mts +9 -0
- package/api/search/search.mjs +82 -7
- package/api/swizzle/copy/copy.mjs +28 -14
- package/assets/docs/tree/component-imports.doc.mjs +8 -0
- package/authoring/doctypes/component/component.doc.mjs +1 -1
- package/authoring/doctypes/component/type.ts +6 -3
- package/foundation/discovery/component-discovery.d.mts +83 -6
- package/foundation/discovery/component-discovery.export-names.test.mjs +293 -0
- package/foundation/discovery/component-discovery.mjs +313 -49
- 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/package.json +9 -9
|
@@ -17,7 +17,17 @@
|
|
|
17
17
|
import {Worker} from 'node:worker_threads';
|
|
18
18
|
import {AstryxError} from '../error.mjs';
|
|
19
19
|
import {Project} from '../../foundation/config/project.mjs';
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
CORE_PACKAGE,
|
|
22
|
+
discoverIntegrationComponents,
|
|
23
|
+
docStem,
|
|
24
|
+
findComponentReadme,
|
|
25
|
+
findIntegrationComponent,
|
|
26
|
+
integrationRecordHold,
|
|
27
|
+
unscopedComponentOwners,
|
|
28
|
+
} from '../../foundation/discovery/component-discovery.mjs';
|
|
29
|
+
import {legacyExternalKeeps} from '../component/_adapter.mjs';
|
|
30
|
+
import {findCoreDir} from '../../foundation/fs/paths.mjs';
|
|
21
31
|
import {captureEnv} from '../../foundation/debug/event.mjs';
|
|
22
32
|
import {ERROR_CODES} from '../../foundation/response/error-codes.mjs';
|
|
23
33
|
import {parseGapReportReceipt} from '../../authoring/gap-report/parse.mjs';
|
|
@@ -178,13 +188,65 @@ async function selectTarget(project, component, packageName) {
|
|
|
178
188
|
coreName,
|
|
179
189
|
);
|
|
180
190
|
if (replacement) return targetForPackage(project, replacement.package);
|
|
191
|
+
// The owner rule runs over every owner discovery finds, loadable or not
|
|
192
|
+
// (a doc recorded under another export still answers to its file's name);
|
|
193
|
+
// then only owners whose docs load route a report.
|
|
194
|
+
const wanted = bareComponent.toLowerCase();
|
|
195
|
+
const loads = new Set(records.map(r => `${r.package}\0${r.docPath}`));
|
|
196
|
+
const recorded = project.loadedIntegrations.flatMap(integration =>
|
|
197
|
+
discoverIntegrationComponents(integration).flatMap(r =>
|
|
198
|
+
r.name.toLowerCase() === wanted
|
|
199
|
+
? [{...r, hold: integrationRecordHold(r)}]
|
|
200
|
+
: docStem(r.docPath).toLowerCase() === wanted
|
|
201
|
+
? [{...r, hold: 'doc'}]
|
|
202
|
+
: [],
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
// The checks below find files by name, so they run on every spelling the
|
|
206
|
+
// case-blind match above found, as well as the one typed.
|
|
207
|
+
const spellings = new Set([
|
|
208
|
+
bareComponent,
|
|
209
|
+
...recorded.flatMap(r =>
|
|
210
|
+
[r.name, docStem(r.docPath)].filter(n => n.toLowerCase() === wanted),
|
|
211
|
+
),
|
|
212
|
+
]);
|
|
213
|
+
// Core also holds a name only its doc lookup finds, as in detail: a name a
|
|
214
|
+
// doc records yields to it, though it routes no report itself.
|
|
215
|
+
const coreDir = findCoreDir(project.cwd);
|
|
216
|
+
const coreDocs =
|
|
217
|
+
coreDir != null &&
|
|
218
|
+
[...spellings].some(name => findComponentReadme(coreDir, name) != null);
|
|
219
|
+
const ruled = unscopedComponentOwners([
|
|
220
|
+
...records
|
|
221
|
+
.filter(
|
|
222
|
+
r => r.package === CORE_PACKAGE && r.name.toLowerCase() === wanted,
|
|
223
|
+
)
|
|
224
|
+
.map(r => ({...r, hold: 'core'})),
|
|
225
|
+
...recorded,
|
|
226
|
+
// A hidden doc named after its file still holds that name, as the
|
|
227
|
+
// component lookup finds it; it routes no report.
|
|
228
|
+
...project.loadedIntegrations.flatMap(integration =>
|
|
229
|
+
[...spellings].flatMap(spelling => {
|
|
230
|
+
const found = findIntegrationComponent(integration, spelling);
|
|
231
|
+
return found?.hold === 'file'
|
|
232
|
+
? [{...found, package: integration.name}]
|
|
233
|
+
: [];
|
|
234
|
+
}),
|
|
235
|
+
),
|
|
236
|
+
]).filter(r => !coreDocs || r.hold !== 'doc');
|
|
237
|
+
// A legacy docs package keeps an export name a doc records, as in detail.
|
|
181
238
|
const owners = [
|
|
182
239
|
...new Set(
|
|
183
|
-
|
|
240
|
+
([...spellings].some(name =>
|
|
241
|
+
legacyExternalKeeps(project.cwd, name, ruled),
|
|
242
|
+
)
|
|
243
|
+
? []
|
|
244
|
+
: ruled
|
|
245
|
+
)
|
|
184
246
|
.filter(
|
|
185
|
-
|
|
247
|
+
r => r.hold === 'core' || loads.has(`${r.package}\0${r.docPath}`),
|
|
186
248
|
)
|
|
187
|
-
.map(
|
|
249
|
+
.map(r => r.package),
|
|
188
250
|
),
|
|
189
251
|
];
|
|
190
252
|
|
|
@@ -24,7 +24,6 @@ import * as path from 'node:path';
|
|
|
24
24
|
import * as os from 'node:os';
|
|
25
25
|
import {fileURLToPath} from 'node:url';
|
|
26
26
|
import {spawnSync} from 'node:child_process';
|
|
27
|
-
import jscodeshift from 'jscodeshift';
|
|
28
27
|
import {assertWithin} from '../../foundation/fs/path-safety.mjs';
|
|
29
28
|
import {resolvePackageDir} from '../../foundation/integrations/integrations.mjs';
|
|
30
29
|
import {
|
|
@@ -41,6 +40,7 @@ import {
|
|
|
41
40
|
} from '../../foundation/discovery/docs-discovery.mjs';
|
|
42
41
|
import {discoverIntegrationThemes} from '../../foundation/discovery/theme-discovery.mjs';
|
|
43
42
|
import {inspectPackageExport} from '../../foundation/discovery/package-exports.mjs';
|
|
43
|
+
import {moduleExportsName} from '../../foundation/discovery/module-exports.mjs';
|
|
44
44
|
import {
|
|
45
45
|
ThemeImportError,
|
|
46
46
|
resolveThemeImports,
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
} from '../theme/build/build.mjs';
|
|
52
52
|
import {
|
|
53
53
|
discoverIntegrationComponents,
|
|
54
|
+
docStem,
|
|
54
55
|
resolveIntegrationImportPath,
|
|
55
56
|
} from '../../foundation/discovery/component-discovery.mjs';
|
|
56
57
|
import {loadComponentReplacements} from '../component/_adapter.mjs';
|
|
@@ -307,98 +308,6 @@ function extractTarball(tgzPath, extractDir) {
|
|
|
307
308
|
}
|
|
308
309
|
}
|
|
309
310
|
|
|
310
|
-
const MODULE_EXTENSIONS = ['.mjs', '.js', '.mts', '.ts', '.tsx', '.jsx'];
|
|
311
|
-
const moduleParser = jscodeshift.withParser('tsx');
|
|
312
|
-
|
|
313
|
-
/** @param {unknown} specifier @param {string} fromFile */
|
|
314
|
-
function resolveLocalModule(specifier, fromFile) {
|
|
315
|
-
if (typeof specifier !== 'string' || !specifier.startsWith('.')) return null;
|
|
316
|
-
const base = path.resolve(path.dirname(fromFile), specifier);
|
|
317
|
-
const candidates = [
|
|
318
|
-
base,
|
|
319
|
-
...MODULE_EXTENSIONS.map(extension => `${base}${extension}`),
|
|
320
|
-
...MODULE_EXTENSIONS.map(extension => path.join(base, `index${extension}`)),
|
|
321
|
-
];
|
|
322
|
-
return candidates.find(candidate => fs.existsSync(candidate)) ?? null;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/** @param {any} declaration @param {string} exportName */
|
|
326
|
-
function declarationExports(declaration, exportName) {
|
|
327
|
-
if (!declaration) return false;
|
|
328
|
-
if (['FunctionDeclaration', 'ClassDeclaration'].includes(declaration.type)) {
|
|
329
|
-
return declaration.id?.name === exportName;
|
|
330
|
-
}
|
|
331
|
-
if (declaration.type !== 'VariableDeclaration') return false;
|
|
332
|
-
return declaration.declarations.some(
|
|
333
|
-
(/** @type {any} */ item) =>
|
|
334
|
-
item.id?.type === 'Identifier' && item.id.name === exportName,
|
|
335
|
-
);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Check one resolved module's static export surface without executing package
|
|
340
|
-
* code. Local ESM re-exports are followed recursively.
|
|
341
|
-
*
|
|
342
|
-
* @param {string} file
|
|
343
|
-
* @param {string} exportName
|
|
344
|
-
* @param {Set<string>} [seen]
|
|
345
|
-
*/
|
|
346
|
-
function moduleExportsName(file, exportName, seen = new Set()) {
|
|
347
|
-
const identity = `${file}\0${exportName}`;
|
|
348
|
-
if (seen.has(identity) || !fs.existsSync(file)) return false;
|
|
349
|
-
seen.add(identity);
|
|
350
|
-
|
|
351
|
-
let root;
|
|
352
|
-
try {
|
|
353
|
-
root = moduleParser(fs.readFileSync(file, 'utf-8'));
|
|
354
|
-
} catch {
|
|
355
|
-
return false;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
let found = false;
|
|
359
|
-
root
|
|
360
|
-
.find(moduleParser.ExportNamedDeclaration)
|
|
361
|
-
.forEach((/** @type {any} */ exportPath) => {
|
|
362
|
-
if (found) return;
|
|
363
|
-
const node = exportPath.node;
|
|
364
|
-
if (declarationExports(node.declaration, exportName)) {
|
|
365
|
-
found = true;
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
for (const specifier of node.specifiers ?? []) {
|
|
369
|
-
const exported = specifier.exported?.name ?? specifier.exported?.value;
|
|
370
|
-
if (exported !== exportName) continue;
|
|
371
|
-
if (!node.source) {
|
|
372
|
-
found = true;
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
const target = resolveLocalModule(node.source.value, file);
|
|
376
|
-
const imported =
|
|
377
|
-
specifier.local?.name ?? specifier.local?.value ?? exportName;
|
|
378
|
-
if (target && moduleExportsName(target, imported, seen)) {
|
|
379
|
-
found = true;
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
});
|
|
384
|
-
if (found) return true;
|
|
385
|
-
if (
|
|
386
|
-
exportName === 'default' &&
|
|
387
|
-
root.find(moduleParser.ExportDefaultDeclaration).size() > 0
|
|
388
|
-
) {
|
|
389
|
-
return true;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
root
|
|
393
|
-
.find(moduleParser.ExportAllDeclaration)
|
|
394
|
-
.forEach((/** @type {any} */ exportPath) => {
|
|
395
|
-
if (found) return;
|
|
396
|
-
const target = resolveLocalModule(exportPath.node.source?.value, file);
|
|
397
|
-
if (target && moduleExportsName(target, exportName, seen)) found = true;
|
|
398
|
-
});
|
|
399
|
-
return found;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
311
|
/**
|
|
403
312
|
* Whether any of these doc files has a section that sets `id`.
|
|
404
313
|
* @param {string[]} files
|
|
@@ -480,7 +389,7 @@ async function validatePackedComponentExports(integration, scratchBase) {
|
|
|
480
389
|
issues.push(
|
|
481
390
|
error(
|
|
482
391
|
'component_doc_unloadable',
|
|
483
|
-
`Component "${record.
|
|
392
|
+
`Component "${docStem(record.docPath)}" doc at "${record.docPath}" could not be loaded: ${err instanceof Error ? err.message : String(err)}`,
|
|
484
393
|
),
|
|
485
394
|
);
|
|
486
395
|
continue;
|
|
@@ -754,6 +754,38 @@ describe('integrationPackCheck', () => {
|
|
|
754
754
|
);
|
|
755
755
|
});
|
|
756
756
|
|
|
757
|
+
it('checks the export a component doc names when its file is named otherwise', async () => {
|
|
758
|
+
fs.writeFileSync(
|
|
759
|
+
path.join(tmpDir, 'package.json'),
|
|
760
|
+
`${JSON.stringify({
|
|
761
|
+
name: '@acme/widgets',
|
|
762
|
+
version: '1.0.0',
|
|
763
|
+
files: ['astryx.integration.mjs', 'components'],
|
|
764
|
+
exports: {'./components/Slides': './components/Slides.tsx'},
|
|
765
|
+
})}\n`,
|
|
766
|
+
);
|
|
767
|
+
fs.writeFileSync(
|
|
768
|
+
path.join(tmpDir, 'astryx.integration.mjs'),
|
|
769
|
+
"export default {components: './components'};\n",
|
|
770
|
+
);
|
|
771
|
+
fs.mkdirSync(path.join(tmpDir, 'components'));
|
|
772
|
+
// Slides.tsx exports AcmeCarousel and nothing named Slides.
|
|
773
|
+
fs.writeFileSync(
|
|
774
|
+
path.join(tmpDir, 'components', 'Slides.tsx'),
|
|
775
|
+
'export function AcmeCarousel() { return null; }\n',
|
|
776
|
+
);
|
|
777
|
+
fs.writeFileSync(
|
|
778
|
+
path.join(tmpDir, 'components', 'Slides.doc.mjs'),
|
|
779
|
+
"export default {type: 'component', name: 'AcmeCarousel', import: '@acme/widgets/components/Slides', description: 'x', props: []};\n",
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
const result = await integrationPackCheck({cwd: tmpDir});
|
|
783
|
+
expect(result.data.packable).toBe(true);
|
|
784
|
+
expect(result.data.contributions.packed.components).toEqual([
|
|
785
|
+
'AcmeCarousel',
|
|
786
|
+
]);
|
|
787
|
+
});
|
|
788
|
+
|
|
757
789
|
it('passes a generated component through its exact packed export', async () => {
|
|
758
790
|
fs.writeFileSync(
|
|
759
791
|
path.join(tmpDir, 'package.json'),
|
|
@@ -811,6 +843,86 @@ describe('integrationPackCheck', () => {
|
|
|
811
843
|
);
|
|
812
844
|
});
|
|
813
845
|
|
|
846
|
+
it('does not count a type-only re-export as the component', async () => {
|
|
847
|
+
fs.writeFileSync(
|
|
848
|
+
path.join(tmpDir, 'package.json'),
|
|
849
|
+
`${JSON.stringify({
|
|
850
|
+
name: '@acme/widgets',
|
|
851
|
+
version: '1.0.0',
|
|
852
|
+
files: ['astryx.integration.mjs', 'src'],
|
|
853
|
+
exports: {'.': './src/index.ts'},
|
|
854
|
+
})}\n`,
|
|
855
|
+
);
|
|
856
|
+
fs.writeFileSync(
|
|
857
|
+
path.join(tmpDir, 'astryx.integration.mjs'),
|
|
858
|
+
"export default {components: './src'};\n",
|
|
859
|
+
);
|
|
860
|
+
fs.mkdirSync(path.join(tmpDir, 'src'));
|
|
861
|
+
fs.writeFileSync(
|
|
862
|
+
path.join(tmpDir, 'src', 'index.ts'),
|
|
863
|
+
"export type {AcmeWidget} from './AcmeWidget';\n",
|
|
864
|
+
);
|
|
865
|
+
fs.writeFileSync(
|
|
866
|
+
path.join(tmpDir, 'src', 'AcmeWidget.tsx'),
|
|
867
|
+
'export function AcmeWidget() { return null; }\n',
|
|
868
|
+
);
|
|
869
|
+
fs.writeFileSync(
|
|
870
|
+
path.join(tmpDir, 'src', 'AcmeWidget.doc.mjs'),
|
|
871
|
+
"export default {type: 'component', name: 'AcmeWidget', import: '@acme/widgets', description: 'Widget.', props: []};\n",
|
|
872
|
+
);
|
|
873
|
+
|
|
874
|
+
const result = await integrationPackCheck({cwd: tmpDir});
|
|
875
|
+
|
|
876
|
+
expect(result.data.issues).toContainEqual(
|
|
877
|
+
expect.objectContaining({code: 'component_export_missing'}),
|
|
878
|
+
);
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
it('follows a re-export of a folder to its index file', async () => {
|
|
882
|
+
// `./primitives` names a folder: TypeScript and bundlers load its index file.
|
|
883
|
+
fs.writeFileSync(
|
|
884
|
+
path.join(tmpDir, 'package.json'),
|
|
885
|
+
`${JSON.stringify({
|
|
886
|
+
name: '@acme/widgets',
|
|
887
|
+
version: '1.0.0',
|
|
888
|
+
files: ['astryx.integration.mjs', 'src'],
|
|
889
|
+
exports: {'.': './src/index.ts'},
|
|
890
|
+
})}\n`,
|
|
891
|
+
);
|
|
892
|
+
fs.writeFileSync(
|
|
893
|
+
path.join(tmpDir, 'astryx.integration.mjs'),
|
|
894
|
+
"export default {components: './src'};\n",
|
|
895
|
+
);
|
|
896
|
+
const primitives = path.join(tmpDir, 'src', 'primitives');
|
|
897
|
+
fs.mkdirSync(primitives, {recursive: true});
|
|
898
|
+
fs.writeFileSync(
|
|
899
|
+
path.join(tmpDir, 'src', 'index.ts'),
|
|
900
|
+
"export * from './primitives';\n",
|
|
901
|
+
);
|
|
902
|
+
fs.writeFileSync(
|
|
903
|
+
path.join(primitives, 'index.ts'),
|
|
904
|
+
"export * from './AcmeWidget';\n",
|
|
905
|
+
);
|
|
906
|
+
fs.writeFileSync(
|
|
907
|
+
path.join(primitives, 'AcmeWidget.tsx'),
|
|
908
|
+
'export function AcmeWidget() { return null; }\n',
|
|
909
|
+
);
|
|
910
|
+
fs.writeFileSync(
|
|
911
|
+
path.join(primitives, 'AcmeWidget.doc.mjs'),
|
|
912
|
+
"export default {type: 'component', name: 'AcmeWidget', import: '@acme/widgets', description: 'Widget.', props: []};\n",
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
const result = await integrationPackCheck({cwd: tmpDir});
|
|
916
|
+
|
|
917
|
+
expect(result.data.issues).not.toContainEqual(
|
|
918
|
+
expect.objectContaining({code: 'component_export_missing'}),
|
|
919
|
+
);
|
|
920
|
+
expect(
|
|
921
|
+
result.data.packable,
|
|
922
|
+
JSON.stringify(result.data.issues, null, 2),
|
|
923
|
+
).toBe(true);
|
|
924
|
+
});
|
|
925
|
+
|
|
814
926
|
it('fails when packed template source is hidden by package exports', async () => {
|
|
815
927
|
writePackage({
|
|
816
928
|
manifest: "export default {templates: './templates'};\n",
|
package/api/search/search.d.mts
CHANGED
|
@@ -252,4 +252,13 @@ export type Candidate = {
|
|
|
252
252
|
_kind?: "page" | "block" | undefined;
|
|
253
253
|
_resultName?: string | undefined;
|
|
254
254
|
_commandName?: string | undefined;
|
|
255
|
+
/**
|
|
256
|
+
* - How an integration component holds its
|
|
257
|
+
* name, for the owner rule.
|
|
258
|
+
*/
|
|
259
|
+
_hold?: "file" | "doc" | undefined;
|
|
260
|
+
/**
|
|
261
|
+
* - An integration component's doc.
|
|
262
|
+
*/
|
|
263
|
+
_docPath?: string | undefined;
|
|
255
264
|
};
|
package/api/search/search.mjs
CHANGED
|
@@ -81,12 +81,18 @@
|
|
|
81
81
|
|
|
82
82
|
import {withListingCache} from '../../foundation/fs/listing-cache.mjs';
|
|
83
83
|
import {readDocView} from '../../foundation/doc-compiler/read.mjs';
|
|
84
|
-
import {
|
|
84
|
+
import {
|
|
85
|
+
discoverExternalPackages,
|
|
86
|
+
findCoreDir,
|
|
87
|
+
} from '../../foundation/fs/paths.mjs';
|
|
85
88
|
import {
|
|
86
89
|
discoverComponents,
|
|
87
90
|
discoverValidIntegrationComponents,
|
|
91
|
+
docStem,
|
|
88
92
|
familyMember,
|
|
89
93
|
findComponentReadme,
|
|
94
|
+
forEachIntegrationDoc,
|
|
95
|
+
integrationRecordHold,
|
|
90
96
|
resolveImportPath,
|
|
91
97
|
resolveIntegrationImportPath,
|
|
92
98
|
CORE_PACKAGE,
|
|
@@ -98,6 +104,7 @@ import {
|
|
|
98
104
|
import {
|
|
99
105
|
loadComponentReplacements,
|
|
100
106
|
loadIntegrationsSafely,
|
|
107
|
+
resolveLegacyExternalDoc,
|
|
101
108
|
} from '../component/_adapter.mjs';
|
|
102
109
|
import {levenshteinDistance} from '../../foundation/text/string-utils.mjs';
|
|
103
110
|
import {discoverTemplates, extractComponents} from '../template/template.mjs';
|
|
@@ -150,6 +157,9 @@ import {setResultCoverage} from './coverage.mjs';
|
|
|
150
157
|
* @property {'page'|'block'} [_kind]
|
|
151
158
|
* @property {string} [_resultName]
|
|
152
159
|
* @property {string} [_commandName]
|
|
160
|
+
* @property {'file'|'doc'} [_hold] - How an integration component holds its
|
|
161
|
+
* name, for the owner rule.
|
|
162
|
+
* @property {string} [_docPath] - An integration component's doc.
|
|
153
163
|
*/
|
|
154
164
|
|
|
155
165
|
/**
|
|
@@ -1110,20 +1120,50 @@ async function gatherCoreComponents(coreDir) {
|
|
|
1110
1120
|
* usage/description from each component's .doc.mjs, same as core. Without
|
|
1111
1121
|
* this, an integration component is invisible to `search`/`build` even
|
|
1112
1122
|
* though `component --list`/`component <Name>` already resolve it — the two
|
|
1113
|
-
* discovery paths silently disagreed.
|
|
1123
|
+
* discovery paths silently disagreed. Also returns, per name, the docs that
|
|
1124
|
+
* hold it by their file name and the loadable docs that only record it.
|
|
1114
1125
|
* @param {import('../../foundation/integrations/integrations.mjs').LoadedIntegration[]} loadedIntegrations
|
|
1115
|
-
* @returns {Promise<Candidate[]>}
|
|
1126
|
+
* @returns {Promise<{candidates: Candidate[], fileNamed: Map<string, Set<string>>, documented: Map<string, Set<string>>}>}
|
|
1116
1127
|
*/
|
|
1117
1128
|
async function gatherIntegrationComponents(loadedIntegrations) {
|
|
1118
1129
|
/** @type {Candidate[]} */
|
|
1119
1130
|
const candidates = [];
|
|
1131
|
+
// What the owner rule reads: the docs that hold a name by their file name,
|
|
1132
|
+
// loadable or not and hidden ones too, and the loadable docs that only
|
|
1133
|
+
// record it.
|
|
1134
|
+
/** @type {Map<string, Set<string>>} */
|
|
1135
|
+
const fileNamed = new Map();
|
|
1136
|
+
/** @type {Map<string, Set<string>>} */
|
|
1137
|
+
const documented = new Map();
|
|
1138
|
+
/** @param {Map<string, Set<string>>} map @param {string} name @param {string} docPath */
|
|
1139
|
+
const hold = (map, name, docPath) =>
|
|
1140
|
+
map.set(name, (map.get(name) ?? new Set()).add(docPath));
|
|
1120
1141
|
for (const integration of loadedIntegrations) {
|
|
1121
|
-
const {components} =
|
|
1142
|
+
const {components, discovered} =
|
|
1143
|
+
await discoverValidIntegrationComponents(integration);
|
|
1144
|
+
const renamed = new Set(
|
|
1145
|
+
discovered
|
|
1146
|
+
.filter(rec => integrationRecordHold(rec) === 'doc')
|
|
1147
|
+
.map(rec => rec.docPath),
|
|
1148
|
+
);
|
|
1149
|
+
forEachIntegrationDoc(integration.components, (docPath, stem) => {
|
|
1150
|
+
if (!renamed.has(docPath)) hold(fileNamed, stem, docPath);
|
|
1151
|
+
});
|
|
1122
1152
|
for (const rec of components) {
|
|
1123
1153
|
const doc = await loadModuleDoc(rec.docPath);
|
|
1154
|
+
// A doc that names another export of its file still answers to the
|
|
1155
|
+
// file's name, as `component <file name>` does.
|
|
1156
|
+
const fileName = docStem(rec.docPath);
|
|
1157
|
+
if (renamed.has(rec.docPath)) {
|
|
1158
|
+
hold(documented, rec.name, rec.docPath);
|
|
1159
|
+
hold(documented, fileName, rec.docPath);
|
|
1160
|
+
}
|
|
1124
1161
|
candidates.push({
|
|
1125
1162
|
domain: 'component',
|
|
1126
1163
|
name: rec.name,
|
|
1164
|
+
aliases: fileName !== rec.name ? [fileName] : [],
|
|
1165
|
+
_hold: integrationRecordHold(rec),
|
|
1166
|
+
_docPath: rec.docPath,
|
|
1127
1167
|
keywords: doc && Array.isArray(doc.keywords) ? doc.keywords : [],
|
|
1128
1168
|
description: doc ? doc.usage?.description || doc.description || '' : '',
|
|
1129
1169
|
guidance: guidanceFrom(doc),
|
|
@@ -1148,7 +1188,7 @@ async function gatherIntegrationComponents(loadedIntegrations) {
|
|
|
1148
1188
|
});
|
|
1149
1189
|
}
|
|
1150
1190
|
}
|
|
1151
|
-
return candidates;
|
|
1191
|
+
return {candidates, fileNamed, documented};
|
|
1152
1192
|
}
|
|
1153
1193
|
|
|
1154
1194
|
/**
|
|
@@ -1160,11 +1200,12 @@ async function gatherIntegrationComponents(loadedIntegrations) {
|
|
|
1160
1200
|
*/
|
|
1161
1201
|
async function gatherComponents(coreDir, cwd) {
|
|
1162
1202
|
const loadedIntegrations = await loadIntegrationsSafely(cwd);
|
|
1163
|
-
const [core,
|
|
1203
|
+
const [core, gathered, replacements] = await Promise.all([
|
|
1164
1204
|
gatherCoreComponents(coreDir),
|
|
1165
1205
|
gatherIntegrationComponents(loadedIntegrations),
|
|
1166
1206
|
loadComponentReplacements(coreDir, loadedIntegrations),
|
|
1167
1207
|
]);
|
|
1208
|
+
const {candidates: integrations, fileNamed, documented} = gathered;
|
|
1168
1209
|
// An active replacement answers to the Core name it replaces, so a search
|
|
1169
1210
|
// for that name finds the replacement and not the Core original
|
|
1170
1211
|
// (spec:AST-035 FR11).
|
|
@@ -1175,9 +1216,43 @@ async function gatherComponents(coreDir, cwd) {
|
|
|
1175
1216
|
active.target,
|
|
1176
1217
|
]),
|
|
1177
1218
|
);
|
|
1219
|
+
// The owner rule, as component detail applies it: a candidate `component
|
|
1220
|
+
// <name>` does not open names its package and follows the owner, and a
|
|
1221
|
+
// file-name alias that opens another owner is dropped.
|
|
1222
|
+
const coreNames = new Set(core.map(candidate => candidate.name));
|
|
1223
|
+
/** @param {string} name @param {Candidate} candidate */
|
|
1224
|
+
const opens = (name, candidate) =>
|
|
1225
|
+
!coreNames.has(name) &&
|
|
1226
|
+
findComponentReadme(coreDir, name) == null &&
|
|
1227
|
+
[...(fileNamed.get(name) ?? [])].every(doc => doc === candidate._docPath) &&
|
|
1228
|
+
[...(documented.get(name) ?? [])].every(doc => doc === candidate._docPath);
|
|
1229
|
+
// A legacy docs package keeps an exact name over an export name a doc records.
|
|
1230
|
+
/** @type {ReturnType<typeof discoverExternalPackages> | undefined} */
|
|
1231
|
+
let externals;
|
|
1232
|
+
/** @param {string} name */
|
|
1233
|
+
const legacyDocuments = name =>
|
|
1234
|
+
(externals ??= discoverExternalPackages(cwd)).some(
|
|
1235
|
+
ext => resolveLegacyExternalDoc(ext, name) != null,
|
|
1236
|
+
);
|
|
1237
|
+
const ruled = integrations.map(candidate => {
|
|
1238
|
+
const aliases = (candidate.aliases ?? []).filter(alias =>
|
|
1239
|
+
opens(alias, candidate),
|
|
1240
|
+
);
|
|
1241
|
+
return candidate._hold === 'doc' &&
|
|
1242
|
+
(!opens(candidate.name, candidate) || legacyDocuments(candidate.name))
|
|
1243
|
+
? {
|
|
1244
|
+
...candidate,
|
|
1245
|
+
aliases,
|
|
1246
|
+
_command: `astryx component ${candidate.name} --package ${candidate._package}`,
|
|
1247
|
+
}
|
|
1248
|
+
: {...candidate, aliases};
|
|
1249
|
+
});
|
|
1178
1250
|
return [
|
|
1179
1251
|
...core.filter(candidate => !replacements.forTarget(candidate.name)),
|
|
1180
|
-
...
|
|
1252
|
+
...[
|
|
1253
|
+
...ruled.filter(candidate => !candidate._command),
|
|
1254
|
+
...ruled.filter(candidate => candidate._command),
|
|
1255
|
+
].map(candidate => {
|
|
1181
1256
|
const target = targetOf.get(`${candidate._package}\0${candidate.name}`);
|
|
1182
1257
|
if (target != null) {
|
|
1183
1258
|
return {...candidate, aliases: [...(candidate.aliases ?? []), target]};
|
|
@@ -21,8 +21,10 @@ import {checkGhCli} from '../_github.mjs';
|
|
|
21
21
|
import {Project} from '../../../foundation/config/project.mjs';
|
|
22
22
|
import {
|
|
23
23
|
CORE_PACKAGE,
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
docStem,
|
|
25
|
+
findComponentReadme,
|
|
26
|
+
findIntegrationComponent,
|
|
27
|
+
unscopedComponentOwners,
|
|
26
28
|
} from '../../../foundation/discovery/component-discovery.mjs';
|
|
27
29
|
import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs';
|
|
28
30
|
import {AstryxError, writeFailed} from '../../error.mjs';
|
|
@@ -126,33 +128,38 @@ async function loadConfigSafely(cwd) {
|
|
|
126
128
|
|
|
127
129
|
/**
|
|
128
130
|
* Build the set of OWNER packages that provide a component named `name` across
|
|
129
|
-
* core + every loaded integration.
|
|
131
|
+
* core + every loaded integration. Core holds every name it documents, but has
|
|
132
|
+
* source to copy (`sourceDir`) only for a name with its own folder.
|
|
130
133
|
* @param {string} coreDir
|
|
131
134
|
* @param {Array<{name: string, components?: string, issuesUrl?: string}>} loadedIntegrations
|
|
132
135
|
* @param {string} name
|
|
133
136
|
* @param {string|undefined} coreIssuesUrl
|
|
134
|
-
* @returns {Array<{package: string, sourceDir: string|null, ownerPackage: string, issuesUrl: string|undefined}>}
|
|
137
|
+
* @returns {Array<{package: string, sourceDir: string|null, docPath: string|null, ownerPackage: string, issuesUrl: string|undefined, hold: string}>}
|
|
135
138
|
*/
|
|
136
139
|
function resolveOwners(coreDir, loadedIntegrations, name, coreIssuesUrl) {
|
|
137
140
|
const owners = [];
|
|
138
141
|
const coreComponentDir = path.join(coreDir, 'src', name);
|
|
139
|
-
|
|
142
|
+
const coreFolder = fs.existsSync(coreComponentDir);
|
|
143
|
+
if (coreFolder || findComponentReadme(coreDir, name)) {
|
|
140
144
|
owners.push({
|
|
141
145
|
package: CORE_PACKAGE,
|
|
142
|
-
sourceDir: coreComponentDir,
|
|
146
|
+
sourceDir: coreFolder ? coreComponentDir : null,
|
|
147
|
+
docPath: null,
|
|
143
148
|
ownerPackage: CORE_PACKAGE,
|
|
144
149
|
issuesUrl: coreIssuesUrl || DEFAULT_ISSUES_URL,
|
|
150
|
+
hold: 'core',
|
|
145
151
|
});
|
|
146
152
|
}
|
|
147
153
|
for (const integration of loadedIntegrations) {
|
|
148
|
-
const
|
|
149
|
-
if (!
|
|
150
|
-
const sourcePath = findIntegrationComponentSource(integration, name);
|
|
154
|
+
const found = findIntegrationComponent(integration, name);
|
|
155
|
+
if (!found) continue;
|
|
151
156
|
owners.push({
|
|
152
157
|
package: integration.name,
|
|
153
|
-
sourceDir: sourcePath ? path.dirname(sourcePath) : null,
|
|
158
|
+
sourceDir: found.sourcePath ? path.dirname(found.sourcePath) : null,
|
|
159
|
+
docPath: found.docPath,
|
|
154
160
|
ownerPackage: integration.name,
|
|
155
161
|
issuesUrl: integration.issuesUrl,
|
|
162
|
+
hold: found.hold,
|
|
156
163
|
});
|
|
157
164
|
}
|
|
158
165
|
return owners;
|
|
@@ -222,25 +229,32 @@ export async function swizzleCopy(component, options = {}) {
|
|
|
222
229
|
: undefined;
|
|
223
230
|
// An active integration replacement answers to the Core name it replaces,
|
|
224
231
|
// bare or qualified by its own package (spec:AST-035 FR11): copy it under its
|
|
225
|
-
//
|
|
232
|
+
// doc's file name, the folder it has always gone to. `--package
|
|
233
|
+
// @astryxdesign/core` still copies the Core original.
|
|
226
234
|
if (pkg !== CORE_PACKAGE) {
|
|
227
235
|
const replacement = await resolveComponentReplacement(
|
|
228
236
|
coreDir,
|
|
229
237
|
loadedIntegrations,
|
|
230
238
|
dirName,
|
|
231
239
|
);
|
|
240
|
+
const copyName = replacement ? docStem(replacement.docPath) : null;
|
|
232
241
|
if (
|
|
233
242
|
replacement &&
|
|
234
|
-
|
|
243
|
+
copyName &&
|
|
244
|
+
(!pkg || (pkg === replacement.package && copyName !== dirName))
|
|
235
245
|
) {
|
|
236
|
-
return swizzleCopy(
|
|
246
|
+
return swizzleCopy(copyName, {
|
|
237
247
|
...options,
|
|
238
248
|
package: replacement.package,
|
|
239
249
|
});
|
|
240
250
|
}
|
|
241
251
|
}
|
|
242
252
|
|
|
243
|
-
const
|
|
253
|
+
const found = resolveOwners(coreDir, loadedIntegrations, dirName, coreIssuesUrl);
|
|
254
|
+
// The owner rule, then a Core name without its own folder drops out.
|
|
255
|
+
const allOwners = (pkg ? found : unscopedComponentOwners(found)).filter(
|
|
256
|
+
owner => owner.package !== CORE_PACKAGE || owner.sourceDir != null,
|
|
257
|
+
);
|
|
244
258
|
|
|
245
259
|
if (allOwners.length === 0) {
|
|
246
260
|
throw new AstryxError(
|
|
@@ -35,6 +35,14 @@ export const docs = {
|
|
|
35
35
|
type: 'prose',
|
|
36
36
|
text: 'Add writes the entry only when package.json already has an `exports` map, so start every package with `"exports": {}`.',
|
|
37
37
|
},
|
|
38
|
+
{
|
|
39
|
+
type: 'prose',
|
|
40
|
+
text: 'A file does not have to be named after its component. When `Slides.tsx` exports `AcmeCarousel` and nothing named `Slides`, keep the doc beside it as `Slides.doc.mjs` and set its `name` to `AcmeCarousel`: apps list, search and open the component by that name, and `integration verify` checks that its import exports `AcmeCarousel`. A file that exports its own name keeps that name.',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
type: 'prose',
|
|
44
|
+
text: "The file name still opens the doc. A name that Core or another doc's file already has stays theirs, and so does a Core name your package replaces: with `Badge.doc.mjs` naming `AcmeBadge`, `astryx component Badge` opens Core's `Badge`, and `--package` with your package name reaches yours.",
|
|
45
|
+
},
|
|
38
46
|
],
|
|
39
47
|
},
|
|
40
48
|
{
|
|
@@ -30,7 +30,7 @@ export const doc = {
|
|
|
30
30
|
name: 'name',
|
|
31
31
|
type: 'string',
|
|
32
32
|
description:
|
|
33
|
-
"Stable machine identity and directory name without the Astryx prefix, PascalCase. e.g. 'Button', 'TextInput', 'AppShell'. Change `displayName`, not `name`, to edit the visible label; registry URLs derive from this identity by default.",
|
|
33
|
+
"Stable machine identity and directory name without the Astryx prefix, PascalCase. e.g. 'Button', 'TextInput', 'AppShell'. For an integration component it is the export apps import: the doc sits beside the file that exports it, and when that file exports nothing under its own name, `name` may name the export it does have. Change `displayName`, not `name`, to edit the visible label; registry URLs derive from this identity by default.",
|
|
34
34
|
required: true,
|
|
35
35
|
},
|
|
36
36
|
{
|
|
@@ -33,9 +33,12 @@ export interface ComponentBaseDoc extends AuthoredDocGraphFields {
|
|
|
33
33
|
type?: 'component';
|
|
34
34
|
/**
|
|
35
35
|
* Stable machine identity and directory name without the Astryx prefix,
|
|
36
|
-
* PascalCase (for example `Button`, `Table`, or `AppShell`).
|
|
37
|
-
*
|
|
38
|
-
*
|
|
36
|
+
* PascalCase (for example `Button`, `Table`, or `AppShell`). For an
|
|
37
|
+
* integration component it is the export apps import: the doc sits beside
|
|
38
|
+
* the file that exports it, and when that file exports nothing under its own
|
|
39
|
+
* name, `name` may name the export it does have. Do not change this to edit
|
|
40
|
+
* the visible label; use `displayName` for that. Registry URLs derive from
|
|
41
|
+
* this value by default.
|
|
39
42
|
*/
|
|
40
43
|
name: string;
|
|
41
44
|
/** Human-readable display name with spaces between words, used by the
|