Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 50 additions & 4 deletions packages/cli/api/search/search.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Candidate[]>}
*/
async function gatherComponents(coreDir) {
async function gatherCoreComponents(coreDir) {
const grouped = discoverComponents(coreDir);
const names = Object.values(grouped).flat();
/** @type {Candidate[]} */
Expand Down Expand Up @@ -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 <Name>` already resolve it — the two
* discovery paths silently disagreed.
* @param {string} cwd
* @returns {Promise<Candidate[]>}
*/
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<Candidate[]>}
*/
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.
Expand Down Expand Up @@ -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) : [],
Expand Down
71 changes: 71 additions & 0 deletions packages/cli/api/search/search.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Name>` 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';
Expand Down Expand Up @@ -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);
});