diff --git a/packages/cli/api/search/search.mjs b/packages/cli/api/search/search.mjs index 19f1726fd73d..8fcf7dba1774 100644 --- a/packages/cli/api/search/search.mjs +++ b/packages/cli/api/search/search.mjs @@ -36,10 +36,12 @@ import {pathToFileURL} from 'node:url'; import {findCoreDir, CLI_ROOT} from '../../foundation/fs/paths.mjs'; import { discoverComponents, + discoverIntegrationComponents, findComponentReadme, resolveImportPath, } from '../../foundation/discovery/component-discovery.mjs'; import {discoverHooks, findHookDoc} from '../../foundation/discovery/hook-discovery.mjs'; +import {loadIntegrationsSafely} from '../component/_adapter.mjs'; import {levenshteinDistance} from '../../foundation/text/string-utils.mjs'; import {discoverTemplates, extractComponents} from '../template/template.mjs'; import {AstryxError} from '../error.mjs'; @@ -353,12 +355,12 @@ async function loadModuleDoc(docPath, exportName = 'docs') { } /** - * Build component candidates: name + keywords + usage/description from the - * component's .doc.mjs. + * Build component candidates from core's own tree: name + keywords + + * usage/description from the component's .doc.mjs. * @param {string} coreDir * @returns {Promise} */ -async function gatherComponents(coreDir) { +async function gatherCoreComponents(coreDir) { const grouped = discoverComponents(coreDir); const names = Object.values(grouped).flat(); /** @type {Candidate[]} */ @@ -386,6 +388,50 @@ async function gatherComponents(coreDir) { return candidates; } +/** + * Build component candidates contributed by the project's configured + * integrations (astryx.config's `integrations`): name + keywords + + * usage/description from each component's .doc.mjs, same as core. Without + * this, an integration component is invisible to `search`/`build` even + * though `component --list`/`component ` already resolve it — the two + * discovery paths silently disagreed. + * @param {string} cwd + * @returns {Promise} + */ +async function gatherIntegrationComponents(cwd) { + const loadedIntegrations = await loadIntegrationsSafely(cwd); + /** @type {Candidate[]} */ + const candidates = []; + for (const integration of loadedIntegrations) { + for (const rec of discoverIntegrationComponents(integration)) { + const doc = await loadModuleDoc(rec.docPath); + candidates.push({ + domain: 'component', + name: rec.name, + keywords: doc && Array.isArray(doc.keywords) ? doc.keywords : [], + description: doc ? doc.usage?.description || doc.description || '' : '', + _import: rec.package, + }); + } + } + return candidates; +} + +/** + * Build component candidates: core's own tree plus every configured + * integration's components. + * @param {string} coreDir + * @param {string} cwd + * @returns {Promise} + */ +async function gatherComponents(coreDir, cwd) { + const [core, integrations] = await Promise.all([ + gatherCoreComponents(coreDir), + gatherIntegrationComponents(cwd), + ]); + return [...core, ...integrations]; +} + /** * Build hook candidates: name + keywords + usage/description from the hook's * .doc.mjs. @@ -600,7 +646,7 @@ export async function search(query, options = {}) { /** @param {string} d */ const wants = d => !type || type === d; const [components, hooks, docTopics, templates] = await Promise.all([ - wants('component') ? gatherComponents(coreDir) : [], + wants('component') ? gatherComponents(coreDir, cwd) : [], wants('hook') ? gatherHooks(coreDir) : [], wants('doc') ? gatherDocs() : [], wants('template') ? gatherTemplates(cwd) : [], diff --git a/packages/cli/api/search/search.test.mjs b/packages/cli/api/search/search.test.mjs index d5e8279570d0..5799d49611aa 100644 --- a/packages/cli/api/search/search.test.mjs +++ b/packages/cli/api/search/search.test.mjs @@ -10,9 +10,17 @@ * `limit`, an empty query, and a bad `--type` all throw AstryxError with the * ERR_INVALID_ARGUMENT code, so a direct `@astryxdesign/cli/api` caller gets the * same contract as `astryx search` on the command line. + * + * The last describe block covers integration-contributed components, using the + * same temp-consumer harness as template-integration.test.mjs. Before this, + * `search`/`build` only ever scanned @astryxdesign/core — an integration's own + * components were invisible to both, even though `component --list` and + * `component ` already resolved them. The two discovery paths silently + * disagreed. */ import {describe, it, expect} from 'vitest'; +import * as fs from 'node:fs'; import * as path from 'node:path'; import {fileURLToPath} from 'node:url'; import {search, SEARCH_DOMAINS} from './search.mjs'; @@ -92,3 +100,66 @@ describe('search leaf — limit validation (API matches the CLI contract)', () = }); }, SLOW); }); + +describe('search leaf — integration components', () => { + /** + * A minimal consumer project: a stub `@astryxdesign/core` (so `findCoreDir` + * resolves without needing the real package) plus an installed + * `@acme/widgets` integration that contributes one component. + */ + function makeConsumerWithIntegrationComponent() { + const dir = fs.mkdtempSync(path.join(process.cwd(), '.astryx-search-it-')); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({name: 'consumer'})); + fs.writeFileSync( + path.join(dir, 'astryx.config.mjs'), + `export default { integrations: ['@acme/widgets'] };\n`, + ); + + // Stub core: just needs to exist with an (empty) src/ so discoverComponents + // doesn't throw. Its own component list is irrelevant to this test. + const coreDir = path.join(dir, 'node_modules', '@astryxdesign', 'core'); + fs.mkdirSync(path.join(coreDir, 'src'), {recursive: true}); + + const widgetsDir = path.join(dir, 'node_modules', '@acme', 'widgets'); + fs.mkdirSync(path.join(widgetsDir, 'components'), {recursive: true}); + fs.writeFileSync( + path.join(widgetsDir, 'package.json'), + JSON.stringify({name: '@acme/widgets', version: '1.0.0'}), + ); + fs.writeFileSync( + path.join(widgetsDir, 'astryx.integration.mjs'), + `export default { components: './components' };\n`, + ); + fs.writeFileSync( + path.join(widgetsDir, 'components', 'FancyGizmo.doc.mjs'), + `export const docs = { + name: 'FancyGizmo', + keywords: ['gizmo', 'widget'], + usage: {description: 'A fancy gizmo widget.'}, + };\n`, + ); + + return dir; + } + + it('includes a component contributed by a configured integration', async () => { + const dir = makeConsumerWithIntegrationComponent(); + try { + const r = await search('gizmo', {cwd: dir, type: 'component'}); + expect(r.data.results.some(x => x.name === 'FancyGizmo')).toBe(true); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }, SLOW); + + it('reports the contributing package as the import hint', async () => { + const dir = makeConsumerWithIntegrationComponent(); + try { + const r = await search('FancyGizmo', {cwd: dir, type: 'component'}); + const hit = r.data.results.find(x => x.name === 'FancyGizmo'); + expect(hit?.import).toBe('@acme/widgets'); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }, SLOW); +});