@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
|
@@ -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;
|
|
@@ -660,11 +569,10 @@ async function validateIncompleteThemeExports(
|
|
|
660
569
|
const expectedCss = path.join(expectedDir, `${localTheme.slug}.css`);
|
|
661
570
|
try {
|
|
662
571
|
if (outputs.some(output => output.kind !== 'font stylesheet')) {
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
);
|
|
572
|
+
// The scratch directory is the write root; the package stays the cwd,
|
|
573
|
+
// so the build selects the package's Core and its paths as before.
|
|
574
|
+
const scratchBuild = {out: expectedCss, __writeRoot: expectedDir};
|
|
575
|
+
const receipt = await themeBuild(source, scratchBuild, {cwd: packageDir});
|
|
668
576
|
if (receipt == null || receipt.type !== 'theme.build') {
|
|
669
577
|
throw new Error('the theme build produced no complete receipt');
|
|
670
578
|
}
|
|
@@ -847,11 +755,12 @@ async function validatePackedThemeExports(
|
|
|
847
755
|
);
|
|
848
756
|
const expectedCss = path.join(expectedDir, `${localTheme.slug}.css`);
|
|
849
757
|
try {
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
758
|
+
// The scratch directory is the write root; the package stays the cwd,
|
|
759
|
+
// so the build selects the package's Core and its paths as before.
|
|
760
|
+
const scratchBuild = {out: expectedCss, __writeRoot: expectedDir};
|
|
761
|
+
const receipt = await themeBuild(source, scratchBuild, {
|
|
762
|
+
cwd: localIntegration.__packageDir,
|
|
763
|
+
});
|
|
855
764
|
if (receipt == null || receipt.type !== 'theme.build') {
|
|
856
765
|
issues.push(
|
|
857
766
|
error(
|
|
@@ -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,11 +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,
|
|
92
|
+
familyMember,
|
|
88
93
|
findComponentReadme,
|
|
94
|
+
forEachIntegrationDoc,
|
|
95
|
+
integrationRecordHold,
|
|
89
96
|
resolveImportPath,
|
|
90
97
|
resolveIntegrationImportPath,
|
|
91
98
|
CORE_PACKAGE,
|
|
@@ -97,6 +104,7 @@ import {
|
|
|
97
104
|
import {
|
|
98
105
|
loadComponentReplacements,
|
|
99
106
|
loadIntegrationsSafely,
|
|
107
|
+
resolveLegacyExternalDoc,
|
|
100
108
|
} from '../component/_adapter.mjs';
|
|
101
109
|
import {levenshteinDistance} from '../../foundation/text/string-utils.mjs';
|
|
102
110
|
import {discoverTemplates, extractComponents} from '../template/template.mjs';
|
|
@@ -149,6 +157,9 @@ import {setResultCoverage} from './coverage.mjs';
|
|
|
149
157
|
* @property {'page'|'block'} [_kind]
|
|
150
158
|
* @property {string} [_resultName]
|
|
151
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.
|
|
152
163
|
*/
|
|
153
164
|
|
|
154
165
|
/**
|
|
@@ -1079,7 +1090,11 @@ async function gatherCoreComponents(coreDir) {
|
|
|
1079
1090
|
/** @type {string[]} */
|
|
1080
1091
|
let guidance = [];
|
|
1081
1092
|
if (readme && readme.endsWith('.doc.mjs')) {
|
|
1082
|
-
const
|
|
1093
|
+
const loaded = await loadModuleDoc(readme);
|
|
1094
|
+
// A member documented in its family's doc (VStack in Stack's doc) is
|
|
1095
|
+
// indexed from its own entry: the family's keywords and guidance
|
|
1096
|
+
// describe the family, not the member.
|
|
1097
|
+
const doc = familyMember(loaded, comp) ?? loaded;
|
|
1083
1098
|
if (doc) {
|
|
1084
1099
|
keywords = Array.isArray(doc.keywords) ? doc.keywords : [];
|
|
1085
1100
|
description = doc.usage?.description || doc.description || '';
|
|
@@ -1105,20 +1120,50 @@ async function gatherCoreComponents(coreDir) {
|
|
|
1105
1120
|
* usage/description from each component's .doc.mjs, same as core. Without
|
|
1106
1121
|
* this, an integration component is invisible to `search`/`build` even
|
|
1107
1122
|
* though `component --list`/`component <Name>` already resolve it — the two
|
|
1108
|
-
* 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.
|
|
1109
1125
|
* @param {import('../../foundation/integrations/integrations.mjs').LoadedIntegration[]} loadedIntegrations
|
|
1110
|
-
* @returns {Promise<Candidate[]>}
|
|
1126
|
+
* @returns {Promise<{candidates: Candidate[], fileNamed: Map<string, Set<string>>, documented: Map<string, Set<string>>}>}
|
|
1111
1127
|
*/
|
|
1112
1128
|
async function gatherIntegrationComponents(loadedIntegrations) {
|
|
1113
1129
|
/** @type {Candidate[]} */
|
|
1114
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));
|
|
1115
1141
|
for (const integration of loadedIntegrations) {
|
|
1116
|
-
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
|
+
});
|
|
1117
1152
|
for (const rec of components) {
|
|
1118
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
|
+
}
|
|
1119
1161
|
candidates.push({
|
|
1120
1162
|
domain: 'component',
|
|
1121
1163
|
name: rec.name,
|
|
1164
|
+
aliases: fileName !== rec.name ? [fileName] : [],
|
|
1165
|
+
_hold: integrationRecordHold(rec),
|
|
1166
|
+
_docPath: rec.docPath,
|
|
1122
1167
|
keywords: doc && Array.isArray(doc.keywords) ? doc.keywords : [],
|
|
1123
1168
|
description: doc ? doc.usage?.description || doc.description || '' : '',
|
|
1124
1169
|
guidance: guidanceFrom(doc),
|
|
@@ -1143,7 +1188,7 @@ async function gatherIntegrationComponents(loadedIntegrations) {
|
|
|
1143
1188
|
});
|
|
1144
1189
|
}
|
|
1145
1190
|
}
|
|
1146
|
-
return candidates;
|
|
1191
|
+
return {candidates, fileNamed, documented};
|
|
1147
1192
|
}
|
|
1148
1193
|
|
|
1149
1194
|
/**
|
|
@@ -1155,11 +1200,12 @@ async function gatherIntegrationComponents(loadedIntegrations) {
|
|
|
1155
1200
|
*/
|
|
1156
1201
|
async function gatherComponents(coreDir, cwd) {
|
|
1157
1202
|
const loadedIntegrations = await loadIntegrationsSafely(cwd);
|
|
1158
|
-
const [core,
|
|
1203
|
+
const [core, gathered, replacements] = await Promise.all([
|
|
1159
1204
|
gatherCoreComponents(coreDir),
|
|
1160
1205
|
gatherIntegrationComponents(loadedIntegrations),
|
|
1161
1206
|
loadComponentReplacements(coreDir, loadedIntegrations),
|
|
1162
1207
|
]);
|
|
1208
|
+
const {candidates: integrations, fileNamed, documented} = gathered;
|
|
1163
1209
|
// An active replacement answers to the Core name it replaces, so a search
|
|
1164
1210
|
// for that name finds the replacement and not the Core original
|
|
1165
1211
|
// (spec:AST-035 FR11).
|
|
@@ -1170,9 +1216,43 @@ async function gatherComponents(coreDir, cwd) {
|
|
|
1170
1216
|
active.target,
|
|
1171
1217
|
]),
|
|
1172
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
|
+
});
|
|
1173
1250
|
return [
|
|
1174
1251
|
...core.filter(candidate => !replacements.forTarget(candidate.name)),
|
|
1175
|
-
...
|
|
1252
|
+
...[
|
|
1253
|
+
...ruled.filter(candidate => !candidate._command),
|
|
1254
|
+
...ruled.filter(candidate => candidate._command),
|
|
1255
|
+
].map(candidate => {
|
|
1176
1256
|
const target = targetOf.get(`${candidate._package}\0${candidate.name}`);
|
|
1177
1257
|
if (target != null) {
|
|
1178
1258
|
return {...candidate, aliases: [...(candidate.aliases ?? []), target]};
|
|
@@ -1225,11 +1305,11 @@ export async function componentKeywords(coreDir, cwd) {
|
|
|
1225
1305
|
.flat()
|
|
1226
1306
|
.map(async name => {
|
|
1227
1307
|
const readme = findComponentReadme(coreDir, name);
|
|
1228
|
-
const
|
|
1308
|
+
const loaded =
|
|
1229
1309
|
readme && readme.endsWith('.doc.mjs')
|
|
1230
1310
|
? await loadModuleDoc(readme)
|
|
1231
1311
|
: null;
|
|
1232
|
-
return {name, keywords: keywordsOf(
|
|
1312
|
+
return {name, keywords: keywordsOf(familyMember(loaded, name) ?? loaded)};
|
|
1233
1313
|
});
|
|
1234
1314
|
const loadedIntegrations = await loadIntegrationsSafely(cwd);
|
|
1235
1315
|
const replacements = await loadComponentReplacements(
|
|
@@ -616,6 +616,42 @@ describe('search leaf — a theme is found by its names; its description is pros
|
|
|
616
616
|
);
|
|
617
617
|
});
|
|
618
618
|
|
|
619
|
+
describe('search leaf — components whose docs live in a family folder', () => {
|
|
620
|
+
it("indexes a family member from its own entry, not the family's keywords", async () => {
|
|
621
|
+
// HStack and VStack are documented in Stack's doc, whose keywords
|
|
622
|
+
// (horizontal, vertical, row, column, ...) describe Stack.
|
|
623
|
+
const reasons = async (/** @type {string} */ query) =>
|
|
624
|
+
Object.fromEntries(
|
|
625
|
+
(await search(query, {cwd, type: 'component'})).data.results.map(r => [
|
|
626
|
+
r.name,
|
|
627
|
+
r.reason,
|
|
628
|
+
]),
|
|
629
|
+
);
|
|
630
|
+
const column = await reasons('column');
|
|
631
|
+
expect(column.HStack ?? '').not.toMatch(/keyword/);
|
|
632
|
+
expect(column.VStack ?? '').not.toMatch(/keyword/);
|
|
633
|
+
expect(column.Stack).toMatch(/keyword/);
|
|
634
|
+
const vertical = (await search('vertical', {cwd, type: 'component'})).data.results.map(
|
|
635
|
+
r => r.name,
|
|
636
|
+
);
|
|
637
|
+
expect(vertical.indexOf('VStack')).toBeGreaterThanOrEqual(0);
|
|
638
|
+
if (vertical.includes('HStack')) {
|
|
639
|
+
expect(vertical.indexOf('VStack')).toBeLessThan(vertical.indexOf('HStack'));
|
|
640
|
+
}
|
|
641
|
+
}, SLOW);
|
|
642
|
+
|
|
643
|
+
it('ranks VStack, HStack and Heading first for their own names, with their own import', async () => {
|
|
644
|
+
for (const name of ['VStack', 'HStack', 'Heading']) {
|
|
645
|
+
const r = await search(name.toLowerCase(), {cwd, type: 'component'});
|
|
646
|
+
expect(r.data.results[0]).toMatchObject({
|
|
647
|
+
domain: 'component',
|
|
648
|
+
name,
|
|
649
|
+
import: `@astryxdesign/core/${name}`,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
}, SLOW);
|
|
653
|
+
});
|
|
654
|
+
|
|
619
655
|
describe('search leaf — integration components', () => {
|
|
620
656
|
/**
|
|
621
657
|
* A minimal consumer project: a stub `@astryxdesign/core` (so `findCoreDir`
|
|
@@ -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(
|