Skip to content
Draft
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
9 changes: 9 additions & 0 deletions .changeset/cli-search-derived-page-keywords.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@astryxdesign/cli': patch
---

[fix] `search` no longer lets the components a page template happens to render outrank the page a query is actually about. A page's keywords are read back out of its own source, and each one scored as an exact keyword match — the same 90 an author's `category` earns — so every page rendering a `<List>` anywhere claimed "list" as loudly as the page that is a list. On `customer list` all 14 of them tied at 98, the tie fell through to the alphabetical tiebreak, and `table-page` came back 35th of 36. Breadth was unbounded too: `theme-showcase` renders 51 components, four times the median page, so it matched more terms of almost any query than the page written for it, and took first place on `list of users`.

Keywords now come in two grades. Authored ones — a component's `keywords`, a block's `componentsUsed`, a page's `category` — keep scoring at face value. Ones derived by reading a page's source are length-normalized by how many were derived alongside them, so a focused page outranks a kitchen sink on the same component, and a wide-surface page stops claiming concepts it only brushes against. `customer list` now answers with the table pages, and results say `renders "List"` rather than `keyword "List"` so a ranking can be read back.

@AKnassa
115 changes: 92 additions & 23 deletions packages/cli/api/search/search.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@
*
* Name + keyword signals always outweigh description/prose, so an exact match
* sorts above an incidental mention.
*
* Keywords come in two grades. Authored ones — a component's `keywords`, a
* block's `componentsUsed`, a page's `category` — score the ladder above at
* face value. Derived ones — the component names read back out of a page
* template's own source — score it scaled by {@link derivedKeywordWeight},
* because "this page renders a <List> somewhere" is weaker evidence than "this
* page is about lists", and weaker still the more components the page renders.
*/

import {pathToFileURL} from 'node:url';
Expand All @@ -51,6 +58,7 @@ import {ERROR_CODES} from '../../foundation/response/error-codes.mjs';
* @property {'component'|'hook'|'doc'|'template'} domain
* @property {string} name
* @property {string[]} [keywords]
* @property {string[]} [derivedKeywords] - Read back from source, not authored; weighted down by breadth.
* @property {string} [description]
* @property {string[]} [prose]
* @property {string} [_import]
Expand Down Expand Up @@ -250,6 +258,62 @@ export function scoreQuery(term, tokens, candidate) {
};
}

/**
* Pivot for the derived-keyword falloff `PIVOT / (PIVOT + count)`. Tuned so a
* focused page (~5 rendered components) lands an exact derived hit on the
* "related keyword" rung (70) rather than the "authored keyword" rung (90) —
* derived evidence enters one rung below what a template author declared — and
* a page past ~14 components no longer clears {@link MIN_TOKEN_SCORE} on one,
* so a kitchen-sink page stops claiming a concept it only brushes against. For
* scale: the median page template renders 13 components, the widest renders 51.
*/
const DERIVED_SURFACE_PIVOT = 18;

/**
* Length-normalize a derived keyword hit by how many keywords were derived
* alongside it. A page rendering five components is plausibly *about* any one
* of them; a page rendering fifty is about none of them. Without this every
* page that renders a `<List>` scored an identical 90 on "list" and the
* ranking degenerated into `name.localeCompare`.
*
* @param {number} count - How many keywords were derived for this candidate.
* @returns {number} Multiplier in (0, 1); 0 when nothing was derived.
*/
function derivedKeywordWeight(count) {
if (count <= 0) return 0;
return DERIVED_SURFACE_PIVOT / (DERIVED_SURFACE_PIVOT + count);
}

/**
* Run the keyword ladder (exact 90 / substring + distance-1 70 / distance-2 30)
* over one keyword list, scaled by `weight`, feeding each hit to `consider`.
*
* @param {string} term - Lowercased search term.
* @param {string[]} keywords
* @param {number} weight - 1 for authored keywords; see {@link derivedKeywordWeight}.
* @param {string} label - How the hit reads in the `reason` ("keyword" / "renders").
* @param {(score: number, why: string) => void} consider
*/
function considerKeywords(term, keywords, weight, label, consider) {
/** @param {number} score */
const scaled = score => Math.round(score * weight);
for (const kw of keywords) {
const kwLower = String(kw).toLowerCase();
if (kwLower === term) {
consider(scaled(90), `${label} "${kw}"`);
continue;
}
const s = term.length < kwLower.length ? term : kwLower;
const l = term.length < kwLower.length ? kwLower : term;
if (s.length >= 4 && l.includes(s) && s.length / l.length >= 0.5) {
consider(scaled(70), `${label} "${kw}"`);
}
const dist = levenshteinDistance(term, kwLower);
if (dist === 1) consider(scaled(70), `${label} "${kw}" (distance ${dist})`);
else if (dist === 2) consider(scaled(30), `${label} "${kw}" (distance ${dist})`);
}
}

/**
* Score a single candidate against the search term across name, keywords,
* and prose signals. Returns the best (highest) score plus a human reason,
Expand All @@ -258,12 +322,16 @@ export function scoreQuery(term, tokens, candidate) {
* @param {string} term - Lowercased search term.
* @param {object} candidate
* @param {string} candidate.name - Primary identifier (component/hook name, topic, template name).
* @param {string[]} [candidate.keywords]
* @param {string[]} [candidate.keywords] - Authored keywords; scored at face value.
* @param {string[]} [candidate.derivedKeywords] - Read back from source; scored down by breadth.
* @param {string} [candidate.description]
* @param {string[]} [candidate.prose] - Extra free-text blobs (doc section text, best practices).
* @returns {{score: number, reason: string} | null}
*/
export function scoreCandidate(term, {name, keywords = [], description = '', prose = []}) {
export function scoreCandidate(
term,
{name, keywords = [], derivedKeywords = [], description = '', prose = []},
) {
let best = 0;
let reason = '';
/**
Expand Down Expand Up @@ -296,21 +364,16 @@ export function scoreCandidate(term, {name, keywords = [], description = '', pro
}

// ── Keyword signals ─────────────────────────────────────────────
for (const kw of keywords) {
const kwLower = String(kw).toLowerCase();
if (kwLower === term) {
consider(90, `keyword "${kw}"`);
continue;
}
const s = term.length < kwLower.length ? term : kwLower;
const l = term.length < kwLower.length ? kwLower : term;
if (s.length >= 4 && l.includes(s) && s.length / l.length >= 0.5) {
consider(70, `keyword "${kw}"`);
}
const dist = levenshteinDistance(term, kwLower);
if (dist === 1) consider(70, `keyword "${kw}" (distance ${dist})`);
else if (dist === 2) consider(30, `keyword "${kw}" (distance ${dist})`);
}
considerKeywords(term, keywords, 1, 'keyword', consider);

// ── Derived keyword signals (length-normalized) ─────────────────
considerKeywords(
term,
derivedKeywords,
derivedKeywordWeight(derivedKeywords.length),
'renders',
consider,
);

// ── Prose / description signals (stem-tolerant whole word) ──────
// Match the term's stem as a whole word, tolerating plural/gerund suffixes
Expand Down Expand Up @@ -483,24 +546,30 @@ async function gatherTemplates(cwd) {
return [];
}
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] : [];
// Blocks ship componentsUsed; page templates usually don't, so read them
// back out of the source. Category words (e.g. "Dashboard - Analytics") are
// strong intent signal for pages, which otherwise only index on name +
// description.
const keywords = Array.isArray(t.componentsUsed) ? [...t.componentsUsed] : [];
/** @type {string[]} */
let derivedKeywords = [];
if (t.type === 'page') {
if (t.filePath) {
try {
keywords = keywords.concat(extractComponents(t.filePath));
// Source-read, not authored: a page rendering a <List> in a sidebar
// must not claim "list" as loudly as one whose category says so.
derivedKeywords = 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));
if (t.category) keywords.push(...t.category.split(/[^A-Za-z0-9]+/).filter(Boolean));
}
return {
domain: 'template',
name: t.dirName,
keywords,
derivedKeywords,
description: t.description || '',
_displayName: t.name,
_kind: t.type, // 'page' | 'block'
Expand Down
60 changes: 59 additions & 1 deletion packages/cli/api/search/search.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import {describe, it, expect} from 'vitest';
import * as path from 'node:path';
import {fileURLToPath} from 'node:url';
import {search, SEARCH_DOMAINS} from './search.mjs';
import {search, scoreCandidate, SEARCH_DOMAINS} from './search.mjs';

const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..');
const cwd = REPO;
Expand Down Expand Up @@ -92,3 +92,61 @@ describe('search leaf — limit validation (API matches the CLI contract)', () =
});
}, SLOW);
});

/**
* A page template's keywords are auto-derived from the component names it
* renders. Scored at the same weight as an authored keyword, that signal
* drowns out intent: every page that renders a `<List>` anywhere claimed the
* same "list" match as the page that IS a list, so the ranking flattened into
* a tie and fell through to the alphabetical tiebreak.
*/
describe('search leaf — page ranking is not dominated by incidental renders', () => {
/**
* @param {Awaited<ReturnType<typeof search>>} r
* @returns {Array<{name: string, score: number, reason: string}>} pages, in rank order
*/
const pagesOf = r => /** @type {any[]} */ (r.data.results).filter(x => x.kind === 'page');

it('ranks a table page top-3 for "customer list", not the pages that merely render a List', async () => {
const pages = pagesOf(await search('customer list', {cwd, type: 'template', limit: 400}));
const top = pages.slice(0, 3).map(p => p.name);
expect(top).toEqual(expect.arrayContaining([expect.stringMatching(/^table/)]));
}, SLOW);

it('keeps the widest-surface page off the top spot for a query it only brushes', async () => {
// theme-showcase renders ~51 components — 4x the median page — so it used
// to match more tokens of almost any query than the page actually about them.
const pages = pagesOf(await search('list of users', {cwd, type: 'template', limit: 400}));
expect(pages.length).toBeGreaterThan(0);
expect(pages[0].name).not.toBe('theme-showcase');
}, SLOW);

it('separates pages by score instead of collapsing into one alphabetical tie', async () => {
const pages = pagesOf(await search('customer list', {cwd, type: 'template', limit: 400}));
expect(pages.length).toBeGreaterThan(5);
expect(pages[0].score).toBeGreaterThan(pages[5].score);
}, SLOW);
});

describe('scoreCandidate() — derived vs authored keywords', () => {
it('scores an authored keyword above the same word derived from the source', () => {
const authored = scoreCandidate('list', {name: 'x', keywords: ['List']});
const derived = scoreCandidate('list', {name: 'x', derivedKeywords: ['List']});
expect(authored.score).toBeGreaterThan(derived.score);
});

it('scores a derived keyword lower as the page renders more components', () => {
const focused = scoreCandidate('list', {name: 'x', derivedKeywords: ['List', 'Card']});
const sprawling = scoreCandidate('list', {
name: 'x',
derivedKeywords: ['List', ...Array.from({length: 50}, (_, i) => `C${i}`)],
});
expect(focused.score).toBeGreaterThan(sprawling.score);
});

it('still reports a derived hit rather than dropping it', () => {
const hit = scoreCandidate('kanban', {name: 'x', derivedKeywords: ['Kanban', 'Card']});
expect(hit).not.toBeNull();
expect(hit.score).toBeGreaterThan(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ export const doc = {
isReady: true,
// Surfaced via the Themes page "Open in Playground" action, not the
// Templates gallery, so keep it out of the overview + playground menu.
// No `category` — it's hidden from the overview gallery (the only consumer
// of `category`), and 'Showcase' isn't part of the TemplateCategory taxonomy.
// No `category` — it's hidden from the overview gallery, and 'Showcase'
// isn't part of the TemplateCategory taxonomy. Note the gallery is no longer
// the only reader: `search()` indexes a page's category words as intent
// keywords, so this page ranks on its name + description alone. That is the
// intent — this is a theme preview harness, not an answer to a page query.
isHiddenFromOverview: true,
};
Loading