diff --git a/.changeset/cli-build-command.md b/.changeset/cli-build-command.md new file mode 100644 index 000000000000..2a8ad92259ba --- /dev/null +++ b/.changeset/cli-build-command.md @@ -0,0 +1,12 @@ +--- +'@astryxdesign/cli': patch +--- + +[feat] Add `astryx build` command for page composition, with natural-language search ranking. + +`build ""` returns a composition kit — the closest page template, the +blocks that cover parts, and components to fill gaps, plus a Compose suggestion. +`build` with no args prints the how-to-build playbook. The shared search ranking +now handles oblique natural-language queries via tokenization + stopwords, a +synonym/intent map, light stemming, and page-template keyword enrichment. +@joeyfarina diff --git a/packages/cli/src/api/search.mjs b/packages/cli/src/api/search.mjs index a26b944c00b0..16b64829cdf1 100644 --- a/packages/cli/src/api/search.mjs +++ b/packages/cli/src/api/search.mjs @@ -41,14 +41,184 @@ import { } from '../lib/component-discovery.mjs'; import {discoverHooks, findHookDoc} from '../lib/hook-discovery.mjs'; import {levenshteinDistance} from '../lib/string-utils.mjs'; -import {discoverTemplates} from './template.mjs'; +import {discoverTemplates, extractComponents} from './template.mjs'; import {AstryxError} from './error.mjs'; const DOCS_DIR = path.join(CLI_ROOT, 'docs'); +/** + * Synonym / intent map: product-language terms an agent is likely to type, + * expanded to the catalog's vocabulary so oblique queries still rank. Keys and + * values are matched bidirectionally (typing any value also pulls in the key + * and its siblings). Lowercase, single words or short phrases. + */ +const SYNONYMS = { + dashboard: ['overview', 'analytics', 'kpi', 'kpis', 'metrics', 'stats', 'reporting', 'insights', 'control'], + login: ['signin', 'auth', 'authentication', 'sso', 'credentials', 'account'], + signup: ['register', 'registration', 'onboarding'], + payment: ['checkout', 'billing', 'card', 'pay', 'purchase', 'order'], + pricing: ['plans', 'plan', 'tiers', 'tier', 'subscription', 'subscriptions'], + chat: ['messaging', 'message', 'messages', 'conversation', 'inbox', 'dm'], + settings: ['preferences', 'config', 'configuration', 'account'], + calendar: ['schedule', 'scheduling', 'events', 'event', 'month', 'agenda'], + table: ['list', 'rows', 'records', 'grid', 'spreadsheet', 'datatable'], + gallery: ['photos', 'photo', 'images', 'image', 'pictures'], + hero: ['banner', 'splash', 'headline', 'landing'], + form: ['fields', 'input', 'inputs', 'survey'], + profile: ['bio', 'avatar', 'user'], + documentation: ['docs', 'reference', 'guide', 'api'], + navigation: ['nav', 'menu', 'sidebar'], +}; + +// Flatten into a token -> Set(expansions) lookup (bidirectional). +const SYNONYM_INDEX = (() => { + const idx = new Map(); + const add = (a, b) => { + if (!idx.has(a)) idx.set(a, new Set()); + idx.get(a).add(b); + }; + for (const [key, vals] of Object.entries(SYNONYMS)) { + for (const v of vals) { + add(key, v); + add(v, key); + for (const v2 of vals) if (v2 !== v) add(v, v2); + } + } + return idx; +})(); + +/** + * Light stemmer: strips common English suffixes so "charts"/"charting" and + * "chart" share a root. Deliberately crude (no Porter) — good enough to bridge + * plural/gerund gaps without a dependency. + * @param {string} w + * @returns {string} + */ +export function stem(w) { + let s = w; + for (const suf of ['ing', 'ed', 'ies', 'es', 's']) { + if (s.length > suf.length + 2 && s.endsWith(suf)) { + s = suf === 'ies' ? s.slice(0, -3) + 'y' : s.slice(0, -suf.length); + break; + } + } + return s; +} + + /** Valid domain filters for `--type`. */ export const SEARCH_DOMAINS = ['component', 'hook', 'doc', 'template']; +/** + * Filler words stripped from multi-word queries so natural-language phrasing + * ("a page where you can see business stats") ranks on its content words. + */ +const STOPWORDS = new Set([ + 'a', 'an', 'the', 'of', 'for', 'to', 'with', 'and', 'or', 'in', 'on', 'at', + 'by', 'that', 'this', 'my', 'your', 'our', 'their', 'is', 'are', 'be', 'it', + 'its', 'as', 'from', 'page', 'screen', 'app', 'application', 'view', 'where', + 'you', 'can', 'some', 'like', 'just', 'basically', 'kinda', 'want', 'wants', + 'need', 'needs', 'something', 'thing', 'things', 'build', 'make', 'create', + 'i', 'me', 'we', 'us', 'so', 'up', 'out', 'over', 'side', 'one', 'big', +]); + +/** + * Split a query into meaningful content tokens (lowercased, stopwords + very + * short words removed). Empty for single-word queries (callers fall back to + * whole-phrase scoring). + * @param {string} term - Already-lowercased query. + * @returns {string[]} + */ +export function tokenizeQuery(term) { + return term + .split(/\s+/) + // Strip only leading/trailing punctuation; keep joined identifiers intact + // (e.g. "foo_bar" stays one token) so gibberish stays gibberish. + .map(t => t.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, '')) + .filter(t => t.length >= 2 && !STOPWORDS.has(t)); +} + +/** + * Score a candidate against a query, handling multi-word natural language. + * Tries the whole phrase (so exact/near matches still win) AND a per-token + * pass (so "data table with filters" matches `table-page` via table+filter), + * and returns whichever is stronger. + * + * @param {string} term - Lowercased full query. + * @param {string[]} tokens - Content tokens from tokenizeQuery(term). + * @param {object} candidate + * @returns {{score: number, reason: string} | null} + */ +/** + * Minimum per-token score (in the multi-word pass) to count as a real match. + * 50 = a genuine name/keyword/description hit; below that is loose Levenshtein + * fuzz that would otherwise turn gibberish queries into noise. + */ +const MIN_TOKEN_SCORE = 50; + +/** + * Best score for a token against a candidate, fanning out through synonyms + * (synonym hits are discounted so a direct hit always wins). + * @returns {{score: number, reason: string} | null} + */ +function bestForToken(tok, candidate) { + let best = scoreCandidate(tok, candidate); + const syns = SYNONYM_INDEX.get(tok); + if (syns) { + for (const s of syns) { + const h = scoreCandidate(s, candidate); + if (h) { + const score = Math.round(h.score * 0.85); + if (!best || score > best.score) best = {score, reason: `${h.reason} (~${tok})`}; + } + } + } + return best; +} + +export function scoreQuery(term, tokens, candidate) { + const full = scoreCandidate(term, candidate); + + // 0–1 content tokens: keep whole-phrase fuzzy matching (typo tolerance for + // single words), but if stopwords left exactly one DIFFERENT token (e.g. + // "pricing page" → "pricing"), score that token too and take the stronger. + if (tokens.length <= 1) { + const single = tokens.length === 1 ? bestForToken(tokens[0], candidate) : null; + if (full && (!single || full.score >= single.score)) return full; + return single; + } + + // Multi-word natural language: score each content token, counting only + // strong hits, then reward coverage so candidates matching more terms win. + let sum = 0; + let matched = 0; + const hitTerms = []; + for (const tok of tokens) { + const h = bestForToken(tok, candidate); + if (h && h.score >= MIN_TOKEN_SCORE) { + sum += h.score; + matched++; + hitTerms.push(tok); + } + } + if (matched === 0) return full; + + // Reward the AVERAGE strength of the concepts that matched (not divided by + // total query length — that penalizes verbose / low-fidelity prompts), plus + // a bonus per additional matched concept and a coverage term. A candidate + // that matches several of the query's concepts beats one matching a single + // incidental word. + const avgMatched = sum / matched; + const coverage = matched / tokens.length; + const tokenScore = Math.round(avgMatched + Math.min(matched - 1, 3) * 12 + coverage * 15); + + if (full && full.score >= tokenScore) return full; + return { + score: tokenScore, + reason: `matches ${matched}/${tokens.length} terms: ${hitTerms.join(', ')}`, + }; +} + /** * Score a single candidate against the search term across name, keywords, * and prose signals. Returns the best (highest) score plus a human reason, @@ -107,10 +277,13 @@ export function scoreCandidate(term, {name, keywords = [], description = '', pro else if (dist === 2) consider(30, `keyword "${kw}" (distance ${dist})`); } - // ── Prose / description signals (whole-word boundary) ─────────── + // ── Prose / description signals (stem-tolerant whole word) ────── + // Match the term's stem as a whole word, tolerating plural/gerund suffixes + // so "chart" matches "charts" and "filter" matches "filtering". if (term.length >= 3) { - const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const re = new RegExp(`\\b${escaped}\\b`); + const root = stem(term); + const escaped = root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(`\\b${escaped}(s|es|ing|ed|ies)?\\b`); if (description && re.test(description.toLowerCase())) { consider(50, `description mentions "${term}"`); } else { @@ -240,14 +413,30 @@ async function gatherTemplates(cwd) { } catch { return []; } - return templates.map(t => ({ - domain: 'template', - name: t.dirName, - keywords: Array.isArray(t.componentsUsed) ? t.componentsUsed : [], - description: t.description || '', - _displayName: t.name, - _kind: t.type, // 'page' | 'block' - })); + return templates.map(t => { + // Blocks ship componentsUsed; page templates don't, so derive them from the + // source. Category words (e.g. "Dashboard - Analytics") are strong intent + // signal for pages, which otherwise only index on name + description. + let keywords = Array.isArray(t.componentsUsed) ? [...t.componentsUsed] : []; + if (t.type === 'page') { + if (t.filePath) { + try { + keywords = keywords.concat(extractComponents(t.filePath)); + } catch { + // Best-effort: skip keyword enrichment if the source can't be read. + } + } + if (t.category) keywords = keywords.concat(t.category.split(/[^A-Za-z0-9]+/).filter(Boolean)); + } + return { + domain: 'template', + name: t.dirName, + keywords, + description: t.description || '', + _displayName: t.name, + _kind: t.type, // 'page' | 'block' + }; + }); } /** @@ -325,6 +514,7 @@ export async function search(query, options = {}) { } const term = String(query).trim().toLowerCase(); + const tokens = tokenizeQuery(term); const coreDir = findCoreDir(cwd); if (!coreDir) { @@ -342,9 +532,13 @@ export async function search(query, options = {}) { const all = [...components, ...hooks, ...docTopics, ...templates]; + // Score every candidate on its own merits. The consumer groups results by + // role (page / block / component) and takes the top of each, so there's no + // cross-role competition to engineer — a target page only needs to be the + // strongest PAGE, not outrank every component. const scored = []; for (const candidate of all) { - const hit = scoreCandidate(term, candidate); + const hit = scoreQuery(term, tokens, candidate); if (hit) scored.push(toResult(candidate, hit.score, hit.reason)); } diff --git a/packages/cli/src/api/template.mjs b/packages/cli/src/api/template.mjs index b533c9600cb6..0c104ebf24e3 100644 --- a/packages/cli/src/api/template.mjs +++ b/packages/cli/src/api/template.mjs @@ -95,6 +95,7 @@ async function discoverPages() { dirName: dir.name, name: doc?.name || dir.name, description: doc?.description || '', + category: doc?.category || '', isReady: doc?.isReady ?? true, scaffold: doc?.scaffold ?? false, filePath: path.join(dirPath, 'page.tsx'), @@ -245,7 +246,7 @@ const UBIQUITOUS = new Set([ 'StackItem', 'Icon', ]); -function extractComponents(pagePath) { +export function extractComponents(pagePath) { const src = fs.readFileSync(pagePath, 'utf-8'); // Match JSX opening tags, e.g. `" → a COMPOSITION KIT for what you're building: + * the closest page template (scaffold or layout + * reference), the blocks that cover parts, and + * the components to fill gaps, plus a one-line + * "Compose:" suggestion. + * + * `build` is the opinionated "assemble a page" verb. For a neutral lookup across + * the whole CLI, use `astryx search ` instead. + */ + +import {getRunPrefix} from '../utils/package-manager.mjs'; +import {jsonOut, humanLog} from '../lib/json.mjs'; +import {cliError} from '../lib/cli-error.mjs'; +import {search as searchApi} from '../api/search.mjs'; + +/** A page scoring at/above this is confident enough to call a direct match. */ +const PAGE_DIRECT = 95; +/** Below this a page is too weak to even offer as a layout reference. */ +const PAGE_FLOOR = 50; + +/** Print the build playbook (shown when `build` is run with no query). */ +function printPlaybook(run) { + const lines = [ + '', + 'How to build a page with Astryx', + '', + "1. Find a starting point for what you're building:", + ` ${run} astryx build ""`, + ' → returns the closest [page] template, the [block]s that cover parts,', + ' and the [component]s to fill the gaps, with a "Compose:" suggestion.', + '', + '2. If a [page] template matches → scaffold it and adapt:', + ` ${run} astryx template [path]`, + '', + '3. If nothing matches exactly → compose:', + ` ${run} astryx template --skeleton # study a close page's layout`, + ` ${run} astryx template # drop in each block from the kit`, + ` ${run} astryx component # fill remaining gaps (read props)`, + '', + '4. Rules (keep it on-system):', + ' - No
/raw HTML for layout — use VStack/HStack/Grid/Stack/Card etc.', + ' - No style={{}} — use component props; design tokens via `astryx docs tokens`.', + ' - Wrap the app in and import core reset.css + astryx.css.', + '', + `Tip: \`${run} astryx build ""\` is the fastest way in. For a neutral`, + `lookup of any component/doc/template, use \`${run} astryx search \`.`, + '', + ]; + for (const l of lines) humanLog(l); +} + +export function registerBuild(program) { + program + .command('build [query]') + .description('Build a page: composition kit for an idea, or the workflow playbook (no args)') + .option('--type ', 'Filter the kit to one domain (component|hook|template)') + .option('--limit ', 'Max candidates to draw from (default 60)') + .option('--detail', 'Verbose output (include import paths and match reason)') + .action(async (query, options) => { + const run = getRunPrefix(); + const json = program.opts().json || false; + + // No query → print the playbook (the "how to build" skill). + if (!query || !String(query).trim()) { + if (json) return jsonOut('build.help', {playbook: true}); + printPlaybook(run); + return; + } + + // Default to a deep pool so each role section has candidates after grouping. + let limit = 60; + if (options.limit != null) { + const parsed = Number.parseInt(options.limit, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + cliError(`Invalid --limit value "${options.limit}". Must be a positive integer.`); + return; + } + limit = parsed; + } + + let result; + try { + result = await searchApi(query, {cwd: process.cwd(), type: options.type, limit}); + } catch (e) { + cliError(e.message, {suggestions: e.suggestions}); + return; + } + + if (json) return jsonOut(result.type, result.data); + + const {query: q, results} = result.data; + + if (results.length === 0) { + humanLog(''); + humanLog(`No matches for "${q}".`); + humanLog(`Try a broader term, or browse: ${run} astryx component --list`); + humanLog(''); + return; + } + + // Group by role so an agent can assemble a UI from the pieces. + const pages = results + .filter(r => r.domain === 'template' && r.kind !== 'block' && r.score >= PAGE_FLOOR) + .slice(0, 3); + const blocks = results.filter(r => r.domain === 'template' && r.kind === 'block').slice(0, 5); + const atoms = results + .filter(r => r.domain === 'component' || r.domain === 'hook') + .slice(0, 5); + const directMatch = pages.length > 0 && pages[0].score >= PAGE_DIRECT; + + const printItem = (r, label) => { + const display = r.domain === 'template' && r.displayName ? r.displayName : r.name; + humanLog(''); + humanLog(` [${label}] ${display}`); + if (r.description) humanLog(` ${r.description}`); + humanLog(` → ${run} ${r.command}`); + if (options.detail) { + if (r.import) humanLog(` import: ${r.import}`); + humanLog(` match: ${r.reason} (score ${r.score})`); + } + }; + + humanLog(''); + humanLog(`Building "${q}":`); + + let frame = null; + if (pages.length) { + humanLog(''); + humanLog( + directMatch + ? 'PAGE TEMPLATE — looks like a direct match (scaffold this):' + : 'CLOSEST PAGE TEMPLATES — scaffold if one fits, or use as a layout reference:', + ); + pages.forEach(p => printItem(p, directMatch ? 'page' : 'closest')); + humanLog(` (study structure: ${run} astryx template ${pages[0].name} --skeleton)`); + frame = pages[0]; + } else { + humanLog(''); + humanLog('NO MATCHING PAGE TEMPLATE — compose from the blocks + components below:'); + } + + if (blocks.length) { + humanLog(''); + humanLog('BLOCKS — drop-in patterns that likely cover parts of it:'); + blocks.forEach(b => printItem(b, 'block')); + } + + if (atoms.length) { + humanLog(''); + humanLog('COMPONENTS — building blocks to fill the gaps:'); + atoms.forEach(c => printItem(c, c.domain === 'hook' ? 'hook' : 'component')); + } + + const parts = []; + if (frame) { + parts.push(directMatch ? `scaffold \`${frame.name}\`` : `frame with \`${frame.name}\` (--skeleton)`); + } + if (blocks.length) parts.push(`drop in ${blocks.slice(0, 2).map(b => '`' + b.name + '`').join(', ')}`); + if (atoms.length) parts.push(`fill with ${atoms.slice(0, 2).map(c => '`' + c.name + '`').join(', ')}`); + if (parts.length) { + humanLog(''); + humanLog('Compose: ' + parts.join(' → ')); + } + humanLog(''); + }); +} diff --git a/packages/cli/src/index.mjs b/packages/cli/src/index.mjs index fcfe6a81c3cc..02490ca7bf80 100644 --- a/packages/cli/src/index.mjs +++ b/packages/cli/src/index.mjs @@ -249,6 +249,7 @@ const commands = [ {name: 'hook', path: './commands/hook/index.mjs', register: 'registerHook'}, {name: 'discover', path: './commands/discover.mjs', register: 'registerDiscover'}, {name: 'search', path: './commands/search.mjs', register: 'registerSearch'}, + {name: 'build', path: './commands/build.mjs', register: 'registerBuild'}, {name: 'doctor', path: './commands/doctor.mjs', register: 'registerDoctor'}, ];