Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion bestax-mcp/data/bulma-classes.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"schemaVersion": 1,
"schemaVersion": 2,
"roots": {
"block": {
"status": "mapped",
Expand Down
2,016 changes: 1,144 additions & 872 deletions bestax-mcp/data/catalog.json

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions bestax-mcp/src/__tests__/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,26 @@ describe('get_css_variables', () => {
const res = await call('get_css_variables', {});
expect(failed(res)).toBe(true);
});

// The index named whichever declarer the generator read last, so DateInput's
// calendar variables were "declared by DateTimeInput" (#964).
it('names every declarer, the one a variable is named after first', async () => {
const out = text(
await call('get_css_variables', { query: 'dateinput-cell-color' })
);
expect(out).toContain(
'| `--bulma-dateinput-cell-color` | DateInput, DateTimeInput |'
);

const search = text(
await call('search_bestax', {
query: '--bulma-dateinput-cell-color',
kind: 'css-var',
})
);
expect(search).toContain('declared by DateInput, DateTimeInput');
expect(search).toContain('get_css_variables({ component: "DateInput" })');
});
});

describe('get_helper_props', () => {
Expand Down
9 changes: 7 additions & 2 deletions bestax-mcp/src/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url';
import type { BulmaClassTable } from './bulma-classes.js';

/** Bumped by the generator when the shape changes incompatibly. */
export const SUPPORTED_SCHEMA_VERSION = 1;
export const SUPPORTED_SCHEMA_VERSION = 2;

export interface PropRow {
name: string;
Expand Down Expand Up @@ -107,7 +107,12 @@ export interface Catalog {
docsBase: string;
categories: { id: string; label: string; components: string[] }[];
components: CatalogEntry[];
cssVarIndex: Record<string, string>;
/**
* Every component that declares a variable. The one the variable is named after
* comes first when there is one (`--bulma-dateinput-*` is DateInput's, though
* DateTimeInput declares it too), and the rest follow in code-point order.
*/
cssVarIndex: Record<string, string[]>;
}

export interface SkillFile {
Expand Down
8 changes: 5 additions & 3 deletions bestax-mcp/src/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,14 +161,16 @@ export function searchAll(
}

if (want('css-var')) {
for (const [cssVar, owner] of Object.entries(catalog.cssVarIndex)) {
for (const [cssVar, declarers] of Object.entries(catalog.cssVarIndex)) {
const s = score(cssVar, cssVar, terms, q);
if (s > 0) {
hits.push({
kind: 'css-var',
name: cssVar,
detail: `declared by ${owner}`,
next: `get_css_variables({ component: "${owner}" })`,
// Every declarer, since an override reaches each; the next call goes to
// the first, the component the variable is named after (#964).
detail: `declared by ${declarers.join(', ')}`,
next: `get_css_variables({ component: "${declarers[0]}" })`,
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
score: s,
});
}
Expand Down
2 changes: 1 addition & 1 deletion bestax-mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,7 @@ export async function createServer(
const rows = Object.entries(catalog.cssVarIndex)
.filter(([css]) => css.toLowerCase().includes(q))
.slice(0, 60)
.map(([css, owner]) => [`\`${css}\``, owner]);
.map(([css, declarers]) => [`\`${css}\``, declarers.join(', ')]);
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
if (!rows.length) {
return textResult(`No CSS variable matches "${query}".`, note());
}
Expand Down
52 changes: 49 additions & 3 deletions scripts/gen-mcp-index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,11 @@ const DOCS_BASE = 'https://bestax.io/docs';
* it does not understand rather than silently serving half a field — the two
* ship in the same tarball, so a mismatch means a broken build, not a user
* running something old.
*
* 2: `cssVarIndex` maps a variable to every component that declares it, its
* owner first, rather than to one name (#964).
*/
const SCHEMA_VERSION = 1;
const SCHEMA_VERSION = 2;

const collapse = s =>
String(s ?? '')
Expand Down Expand Up @@ -277,6 +280,47 @@ async function cssVarsFor(info) {
return rows;
}

/**
* Every component that declares a CSS variable, the one it belongs to first.
*
* Several components read the same partial: DateTimeInput renders DateInput's
* calendar, so it declares every `--bulma-dateinput-*` variable too, and the
* form controls all read Bulma's `--bulma-input-*`. The index used to keep one
* name per variable, whichever component the generator read last, so the
* calendar's variables were "declared by DateTimeInput" and an agent theming
* DateInput was sent to the wrong component (#964).
*
* A variable belongs to the declarer it is named after: the one whose name,
* lower-cased with or without hyphens between its words, the variable continues
* after `--bulma-`. The longest such name wins, so `--bulma-icon-text-*` would
* go to IconText over Icon. The rest follow in code-point order. A variable
* named after none of its declarers (`--bulma-picker-popover-*`, which the three
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
* pickers share) has no owner, and its declarers are all in code-point order.
*
* Every declarer stays in the list, since an override reaches each of them.
*/
export function orderDeclarers(cssVar, names) {
const rest = cssVar.replace(/^--bulma-/, '');
const named = name => {
const keys = [
name.toLowerCase(),
name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(),
];
return Math.max(
0,
...keys
.filter(k => rest === k || rest.startsWith(`${k}-`))
.map(k => k.length)
);
};
const sorted = [...new Set(names)].sort(byCodePoint);
const owner = sorted.reduce(
(best, name) => (named(name) > (best ? named(best) : 0) ? name : best),
null
);
return owner ? [owner, ...sorted.filter(n => n !== owner)] : sorted;
}

/** Strip the frontmatter block, leaving the page body. */
function withoutFrontmatter(src) {
return src.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trimStart();
Expand Down Expand Up @@ -566,7 +610,7 @@ export async function build() {
} else {
const info = extractComponent(name, { markdown: false });
const cssVars = await cssVarsFor(info);
for (const v of cssVars) cssVarIndex[v.css] = name;
for (const v of cssVars) (cssVarIndex[v.css] ??= []).push(name);
record = {
...common,
summary: collapse(info.tsdoc),
Expand Down Expand Up @@ -631,7 +675,9 @@ export async function build() {
categories: categoryList,
components: catalogEntries.sort((a, b) => byCodePoint(a.name, b.name)),
cssVarIndex: Object.fromEntries(
Object.entries(cssVarIndex).sort((a, b) => byCodePoint(a[0], b[0]))
Object.entries(cssVarIndex)
.sort((a, b) => byCodePoint(a[0], b[0]))
.map(([css, names]) => [css, orderDeclarers(css, names)])
),
};

Expand Down
100 changes: 98 additions & 2 deletions scripts/gen-mcp-index.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ import { readFile } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

import { build, readSkills, reportFailure } from './gen-mcp-index.mjs';
import {
build,
orderDeclarers,
readSkills,
reportFailure,
} from './gen-mcp-index.mjs';
import { failureText, skillRefusal, skillSlug } from './lib/skills.mjs';

const REPO = join(dirname(fileURLToPath(import.meta.url)), '..');
Expand Down Expand Up @@ -127,13 +132,104 @@ test('CSS variables are indexed back to their component', () => {
assert.ok(button.cssVars.length > 0);
const row = button.cssVars.find(v => v.css === '--bulma-button-h');
assert.ok(row, '--bulma-button-h missing');
assert.equal(catalog.cssVarIndex['--bulma-button-h'], 'Button');
assert.deepEqual(catalog.cssVarIndex['--bulma-button-h'], ['Button']);
for (const v of button.cssVars) {
assert.match(v.css, /^--/);
assert.ok(['root', 'compound', 'element', 'global'].includes(v.scope));
}
});

test('every CSS variable lists every declarer, the one it is named after first (#964)', () => {
// The index kept one name per variable, whichever declarer the generator read
// last, so DateInput's calendar variables were "declared by DateTimeInput".
const declarers = new Map();
for (const [name, record] of components) {
for (const v of record.cssVars) {
if (!declarers.has(v.css)) declarers.set(v.css, new Set());
declarers.get(v.css).add(name);
}
}
const byCode = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
// How much of the variable a component's name accounts for: its lower-cased
// name, with or without hyphens between words, followed by `-` or the end.
const namedLength = (css, name) => {
const rest = css.replace(/^--bulma-/, '');
const keys = [
name.toLowerCase(),
name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(),
].filter(k => rest === k || rest.startsWith(`${k}-`));
return Math.max(0, ...keys.map(k => k.length));
};

assert.deepEqual(
Object.keys(catalog.cssVarIndex).sort(byCode),
[...declarers.keys()].sort(byCode),
'the index covers exactly the variables the components declare'
);
let shared = 0;
for (const [css, listed] of Object.entries(catalog.cssVarIndex)) {
assert.ok(Array.isArray(listed), `${css} is not a list`);
assert.deepEqual(
[...listed].sort(byCode),
[...declarers.get(css)].sort(byCode),
`${css} lists ${listed}`
);
if (listed.length > 1) shared++;
const best = Math.max(...listed.map(n => namedLength(css, n)));
const [first, ...others] = listed;
if (best > 0) {
assert.equal(namedLength(css, first), best, `${css} lists ${listed}`);
}
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
// Code-point order after the owner, or throughout when there is none.
const tail = best > 0 ? others : listed;
assert.deepEqual([...tail].sort(byCode), tail, `${css} lists ${listed}`);
}
assert.ok(shared > 0, 'no variable is declared by more than one component');

// The cases the rule exists for.
for (const [css, expected] of [
['--bulma-dateinput-cell-color', ['DateInput', 'DateTimeInput']],
['--bulma-timeinput-separator-color', ['TimeInput', 'DateTimeInput']],
['--bulma-subtitle-color', ['SubTitle', 'Title']],
['--bulma-title-color', ['Title', 'SubTitle']],
]) {
assert.deepEqual(catalog.cssVarIndex[css], expected, css);
}
const input = catalog.cssVarIndex['--bulma-input-border-color'];
assert.equal(input?.[0], 'Input');
assert.ok(input.includes('Select'), 'every declarer stays listed');
});

test('a variable goes to the declarer whose name accounts for most of it', () => {
assert.deepEqual(
orderDeclarers('--bulma-icon-text-spacing', ['Icon', 'IconText']),
['IconText', 'Icon']
);
assert.deepEqual(
orderDeclarers('--bulma-icon-dimensions', ['IconText', 'Icon']),
['Icon', 'IconText']
);
// Named after none of them: code-point order, and each name once.
assert.deepEqual(
orderDeclarers('--bulma-picker-popover-shadow', [
'TimeInput',
'DateInput',
'DateTimeInput',
'TimeInput',
]),
['DateInput', 'DateTimeInput', 'TimeInput']
);
// A name the variable merely starts with, mid-word, is not its name.
assert.deepEqual(
orderDeclarers('--bulma-dateinputs-x', ['TimeInput', 'DateInput']),
['DateInput', 'TimeInput']
);
assert.deepEqual(orderDeclarers('--bulma-box', ['Card', 'Box']), [
'Box',
'Card',
]);
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
});

test('related components resolve to real component names', () => {
// Resolved through the target page's frontmatter, not the link text, so a
// pluralised or lower-cased link still yields something the server can look up.
Expand Down
Loading