@sakaniui/react 0.8.8 → 0.8.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +8 -2
- package/CHANGELOG.md +23 -0
- package/README.md +7 -6
- package/bin/sakaniui-mcp.mjs +281 -4
- package/dist/icon-index.json +1 -0
- package/dist/manifest.json +341 -172
- package/package.json +3 -2
package/AGENTS.md
CHANGED
|
@@ -16,8 +16,14 @@ import manifest from '@sakaniui/react/manifest.json';
|
|
|
16
16
|
const button = manifest.components.find((c) => c.name === 'Button'); // props[], contract.requiredProps
|
|
17
17
|
```
|
|
18
18
|
Editors can query the same data through the bundled MCP server
|
|
19
|
-
(`npx -y -p @sakaniui/react sakaniui-mcp`): `
|
|
20
|
-
`
|
|
19
|
+
(`npx -y -p @sakaniui/react sakaniui-mcp`): `search_components`, `get_component`, `search_icons`,
|
|
20
|
+
`check_code`, `check_usage`, `get_tokens`, `get_guide`, `check_compatibility`, `get_changelog`.
|
|
21
|
+
|
|
22
|
+
**Start from the example, check before you answer.** Every component has `example`: its imports and the
|
|
23
|
+
element with each required prop filled in, type-checked against the release. Start from it instead of
|
|
24
|
+
writing the element from memory. Never guess an icon name: find it with `search_icons` (icons are
|
|
25
|
+
`IconTrash`, `IconSettings`, ... from `@sakaniui/react/icons`, passed as `icon={IconTrash}`, not
|
|
26
|
+
`icon={<IconTrash />}`). If the server is connected, run `check_code` on every snippet you write.
|
|
21
27
|
|
|
22
28
|
**Versions.** Every component has `since`, the first release that contained it. The project may have an
|
|
23
29
|
older `@sakaniui/react` installed than the one the manifest describes: if an entry is marked
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@sakaniui/react` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.8.9
|
|
6
|
+
|
|
7
|
+
Stops assistants from inventing code. Found by testing the MCP server in Claude Code: it validated a
|
|
8
|
+
snippet correctly, then wrote its own example as `icon={<TrashIcon />}` -- an icon that does not exist,
|
|
9
|
+
passed as an element instead of a component.
|
|
10
|
+
|
|
11
|
+
- **Examples.** Every component and block in `manifest.json` has `example`: the import lines and the
|
|
12
|
+
element with each required prop filled in with a value of the right shape (an array of `BreadcrumbItem`
|
|
13
|
+
gets one real item). Prop types are read from the shipped `.d.ts`, and every example is type-checked
|
|
14
|
+
against the built package; the build fails if one does not compile.
|
|
15
|
+
- **Icon index.** `@sakaniui/react/icon-index.json` lists all 5,184 icons (and their 1,054 filled
|
|
16
|
+
variants) with Tabler's category and search tags. The build fails if it differs from the exports.
|
|
17
|
+
- **MCP: `search_icons`.** Finds real icon names by meaning ("delete" -> `IconTrash`); common UI intents
|
|
18
|
+
rank the conventional icon first.
|
|
19
|
+
- **MCP: `check_code`.** Checks a whole snippet as text (it is never run): imports must exist and come
|
|
20
|
+
from the right entry point, every SakaniUI element needs its required props, values must be allowed,
|
|
21
|
+
and icon props must be real icon components passed as `icon={IconTrash}`. Errors carry line numbers
|
|
22
|
+
and suggestions ("TrashIcon is not exported... Did you mean: IconTrash?").
|
|
23
|
+
- **MCP: `check_usage`** now validates icon props too, and `get_component` points at the example and
|
|
24
|
+
at `search_icons`.
|
|
25
|
+
- Fixed a type error in `KanbanBoardBlock` (drag attributes when dragging is disabled) that was printed
|
|
26
|
+
during every build.
|
|
27
|
+
|
|
5
28
|
## 0.8.8
|
|
6
29
|
|
|
7
30
|
Audit of the agent contract and MCP server (accuracy, security, versioning).
|
package/README.md
CHANGED
|
@@ -115,18 +115,19 @@ up instead of guessing prop names.
|
|
|
115
115
|
|
|
116
116
|
Claude Code: `claude mcp add sakaniui -- npx -y -p @sakaniui/react sakaniui-mcp`
|
|
117
117
|
|
|
118
|
-
Tools: `search_components`, `get_component
|
|
119
|
-
(
|
|
118
|
+
Tools: `search_components`, `get_component` (with a type-checked `example`), `search_icons`,
|
|
119
|
+
`check_code` (checks a whole snippet: imports, required props, values, icon names), `check_usage`,
|
|
120
|
+
`list_components`, `get_tokens`, `get_guide`, `check_compatibility` and `get_changelog`.
|
|
120
121
|
|
|
121
|
-
- **Read-only.** It reads
|
|
122
|
-
manifest, AGENTS.md, CHANGELOG.md). It writes nothing, runs nothing, opens no network connection and
|
|
122
|
+
- **Read-only.** It reads five fixed files from a `@sakaniui/react` install (package.json, the
|
|
123
|
+
manifest, the icon index, AGENTS.md, CHANGELOG.md). Code given to `check_code` is read as text, never run. It writes nothing, runs nothing, opens no network connection and
|
|
123
124
|
takes no file paths. Every tool is marked read-only.
|
|
124
125
|
- **Matches your version.** It finds the `@sakaniui/react` installed in your project (working directory,
|
|
125
126
|
or `--project <dir>`) and flags components your version does not have yet, with the release notes for
|
|
126
127
|
the versions in between (`get_changelog`).
|
|
127
128
|
- **Accurate by construction.** `npm run build:lib` fails if the manifest disagrees with the real build:
|
|
128
|
-
an export that is not described, a prop that is not declared,
|
|
129
|
-
that looks like a private reference.
|
|
129
|
+
an export that is not described, a prop that is not declared, an example that does not type-check, an
|
|
130
|
+
icon name that does not exist, a token that is not defined, or anything that looks like a private reference.
|
|
130
131
|
|
|
131
132
|
## Glass & liquid glass
|
|
132
133
|
|
package/bin/sakaniui-mcp.mjs
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Zero dependencies: speaks newline-delimited JSON-RPC (MCP stdio transport).
|
|
8
8
|
*
|
|
9
|
-
* Read-only by construction. It reads
|
|
10
|
-
* AGENTS.md, CHANGELOG.md) from a @sakaniui/react install and writes nothing,
|
|
11
|
-
* opens no network connection. No tool takes a path; every
|
|
9
|
+
* Read-only by construction. It reads five fixed files (package.json, dist/manifest.json,
|
|
10
|
+
* dist/icon-index.json, AGENTS.md, CHANGELOG.md) from a @sakaniui/react install and writes nothing,
|
|
11
|
+
* runs nothing and opens no network connection. No tool takes a path; every argument is validated.
|
|
12
|
+
* Code passed to check_code is only read as text, never executed.
|
|
12
13
|
*
|
|
13
14
|
* Version-aware. `npx` may fetch a newer release than the project uses, so the server looks for
|
|
14
15
|
* the project's own installed @sakaniui/react (walking up from --project or the working
|
|
@@ -30,7 +31,7 @@ const readJson = (root, rel) => { try { return JSON.parse(readText(root, rel));
|
|
|
30
31
|
function loadInstall(root) {
|
|
31
32
|
const pkg = readJson(root, 'package.json');
|
|
32
33
|
if (!pkg || pkg.name !== '@sakaniui/react') return null;
|
|
33
|
-
return { root, version: pkg.version, manifest: readJson(root, 'dist/manifest.json'), guide: readText(root, 'AGENTS.md'), changelog: readText(root, 'CHANGELOG.md') };
|
|
34
|
+
return { root, version: pkg.version, manifest: readJson(root, 'dist/manifest.json'), icons: readJson(root, 'dist/icon-index.json'), guide: readText(root, 'AGENTS.md'), changelog: readText(root, 'CHANGELOG.md') };
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
function findProjectInstall() {
|
|
@@ -64,6 +65,7 @@ const active = project && project.manifest && schemaOf(project) >= schemaOf(bund
|
|
|
64
65
|
const manifest = active.manifest;
|
|
65
66
|
const guide = active.guide || bundled.guide || '';
|
|
66
67
|
const changelog = active.changelog || bundled.changelog || '';
|
|
68
|
+
const iconIndex = (active.icons && active.icons.icons) || (bundled.icons && bundled.icons.icons) || [];
|
|
67
69
|
const projectVersion = project ? project.version : null;
|
|
68
70
|
|
|
69
71
|
/* ---------- versions ---------- */
|
|
@@ -152,6 +154,73 @@ function closest(name) {
|
|
|
152
154
|
.map(([, c]) => c);
|
|
153
155
|
}
|
|
154
156
|
|
|
157
|
+
/* ---------- icons ---------- */
|
|
158
|
+
const iconNames = new Set();
|
|
159
|
+
for (const i of iconIndex) { iconNames.add(i.name); if (i.filled) iconNames.add(i.filled); }
|
|
160
|
+
const iconWords = (name) => name.replace(/^Icon/, '').replace(/Filled$/, '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/([A-Za-z])(\d)/g, '$1 $2').toLowerCase();
|
|
161
|
+
|
|
162
|
+
// The conventional icon for common UI intents, ranked first (tags alone rank e.g. IconHttpDelete above IconTrash for "delete").
|
|
163
|
+
const COMMON = {
|
|
164
|
+
delete: ['IconTrash'], remove: ['IconTrash', 'IconX'], trash: ['IconTrash'], bin: ['IconTrash'], edit: ['IconPencil', 'IconEdit'], rename: ['IconPencil'],
|
|
165
|
+
close: ['IconX'], dismiss: ['IconX'], cancel: ['IconX'], add: ['IconPlus'], new: ['IconPlus'], create: ['IconPlus'], plus: ['IconPlus'],
|
|
166
|
+
search: ['IconSearch'], find: ['IconSearch'], settings: ['IconSettings', 'IconAdjustments'], preferences: ['IconSettings'], menu: ['IconMenu2'],
|
|
167
|
+
more: ['IconDots', 'IconDotsVertical'], options: ['IconDots', 'IconDotsVertical'], user: ['IconUser'], profile: ['IconUser', 'IconUserCircle'], account: ['IconUserCircle'],
|
|
168
|
+
home: ['IconHome'], back: ['IconArrowLeft', 'IconChevronLeft'], next: ['IconArrowRight', 'IconChevronRight'], forward: ['IconArrowRight'], previous: ['IconArrowLeft', 'IconChevronLeft'],
|
|
169
|
+
expand: ['IconChevronDown'], collapse: ['IconChevronUp'], dropdown: ['IconChevronDown'], upload: ['IconUpload'], download: ['IconDownload'], share: ['IconShare'],
|
|
170
|
+
copy: ['IconCopy'], duplicate: ['IconCopy'], save: ['IconDeviceFloppy'], filter: ['IconFilter'], sort: ['IconArrowsSort'], calendar: ['IconCalendar'], date: ['IconCalendar'],
|
|
171
|
+
notification: ['IconBell'], notifications: ['IconBell'], bell: ['IconBell'], mail: ['IconMail'], email: ['IconMail'], message: ['IconMessage'], chat: ['IconMessageCircle'],
|
|
172
|
+
phone: ['IconPhone'], call: ['IconPhone'], logout: ['IconLogout'], signout: ['IconLogout'], login: ['IconLogin'], help: ['IconHelpCircle', 'IconHelp'], info: ['IconInfoCircle'],
|
|
173
|
+
warning: ['IconAlertTriangle'], error: ['IconAlertCircle'], success: ['IconCircleCheck'], check: ['IconCheck'], done: ['IconCheck'], favorite: ['IconHeart', 'IconStar'],
|
|
174
|
+
like: ['IconHeart', 'IconThumbUp'], star: ['IconStar'], rating: ['IconStar'], cart: ['IconShoppingCart'], refresh: ['IconRefresh'], reload: ['IconRefresh'], link: ['IconLink'],
|
|
175
|
+
attach: ['IconPaperclip'], attachment: ['IconPaperclip'], lock: ['IconLock'], password: ['IconLock'], view: ['IconEye'], show: ['IconEye'], hide: ['IconEyeOff'],
|
|
176
|
+
play: ['IconPlayerPlay'], pause: ['IconPlayerPause'], stop: ['IconPlayerStop'], theme: ['IconSun', 'IconMoon'], dark: ['IconMoon'], light: ['IconSun'],
|
|
177
|
+
external: ['IconExternalLink'], drag: ['IconGripVertical'], sidebar: ['IconLayoutSidebar'], dashboard: ['IconLayoutDashboard'], analytics: ['IconChartBar'], chart: ['IconChartBar'],
|
|
178
|
+
money: ['IconCurrencyDollar'], payment: ['IconCreditCard'], card: ['IconCreditCard'], location: ['IconMapPin'], time: ['IconClock'], clock: ['IconClock'], file: ['IconFile'],
|
|
179
|
+
folder: ['IconFolder'], image: ['IconPhoto'], photo: ['IconPhoto'], video: ['IconVideo'],
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
function searchIcons(query, limit = 12) {
|
|
183
|
+
const terms = String(query).toLowerCase().replace(/icon/g, ' ').split(/[^a-z0-9]+/).filter(Boolean);
|
|
184
|
+
if (!terms.length) return [];
|
|
185
|
+
const boosted = new Map();
|
|
186
|
+
for (const t of terms) (COMMON[t] || []).forEach((n, k) => boosted.set(n, Math.max(boosted.get(n) || 0, 40 - k * 5)));
|
|
187
|
+
return iconIndex
|
|
188
|
+
.map((i) => {
|
|
189
|
+
const words = iconWords(i.name).split(' ');
|
|
190
|
+
const tags = (i.tags || []).map((t) => t.toLowerCase());
|
|
191
|
+
let s = boosted.get(i.name) || 0;
|
|
192
|
+
for (const t of terms) {
|
|
193
|
+
if (words.join(' ') === t) s += 30;
|
|
194
|
+
else if (words[0] === t) s += 12;
|
|
195
|
+
else if (words.includes(t)) s += 8;
|
|
196
|
+
else if (words.some((w) => w.startsWith(t))) s += 4;
|
|
197
|
+
if (tags.includes(t)) s += 6;
|
|
198
|
+
else if (tags.some((g) => g.includes(t))) s += 2;
|
|
199
|
+
if ((i.category || '').toLowerCase() === t) s += 2;
|
|
200
|
+
}
|
|
201
|
+
return [s - words.length * 0.1, i];
|
|
202
|
+
})
|
|
203
|
+
.filter(([s]) => s > 0.5)
|
|
204
|
+
.sort((a, b) => b[0] - a[0])
|
|
205
|
+
.slice(0, limit)
|
|
206
|
+
.map(([, i]) => i);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Checks a value given for an icon prop. Returns an error string, or null. */
|
|
210
|
+
function iconProblem(propName, raw, importedElsewhere = new Set()) {
|
|
211
|
+
const v = String(raw).trim().replace(/^\{|\}$/g, '').trim();
|
|
212
|
+
if (/^</.test(v)) {
|
|
213
|
+
const tag = (/^<\s*([A-Za-z][\w.]*)/.exec(v) || [])[1] || 'Icon';
|
|
214
|
+
const real = iconNames.has(tag) || importedElsewhere.has(tag) ? tag : (searchIcons(tag, 1)[0] || {}).name;
|
|
215
|
+
const unknown = real && real !== tag ? ` ${tag} is not a SakaniUI icon either.` : '';
|
|
216
|
+
return `"${propName}" takes the icon component itself, not an element: write ${propName}={${real || 'IconName'}}, not ${propName}={<${tag} />}.${unknown}${!real ? ' Use search_icons to find the real name.' : ''}`;
|
|
217
|
+
}
|
|
218
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(v)) return null; // an expression we cannot judge
|
|
219
|
+
if (iconNames.has(v) || importedElsewhere.has(v)) return null;
|
|
220
|
+
const hint = searchIcons(v, 3).map((i) => i.name);
|
|
221
|
+
return `"${v}" is not a SakaniUI icon.${hint.length ? ` Did you mean: ${hint.join(', ')}?` : ''} Use search_icons to find the real name.`;
|
|
222
|
+
}
|
|
223
|
+
|
|
155
224
|
const NATIVE = /^(className|style|id|ref|key|children|role|tabIndex|title|hidden|lang|dir|slot|on[A-Z].*|aria-.*|data-.*)$/;
|
|
156
225
|
|
|
157
226
|
/** Release notes: [{version, body}] newest first, from the CHANGELOG that matches the answering install. */
|
|
@@ -162,6 +231,189 @@ function releases() {
|
|
|
162
231
|
return out.sort((a, b) => cmp(b.version, a.version));
|
|
163
232
|
}
|
|
164
233
|
|
|
234
|
+
/* ---------- check_code: a small, read-only JSX scanner (the code is never executed) ---------- */
|
|
235
|
+
/** Line number lookup in O(log n): a precomputed index of line starts per code string. */
|
|
236
|
+
function lineIndex(code) {
|
|
237
|
+
const starts = [0];
|
|
238
|
+
for (let i = 0; i < code.length; i++) if (code.charCodeAt(i) === 10) starts.push(i + 1);
|
|
239
|
+
return (pos) => { let lo = 0, hi = starts.length - 1; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (starts[mid] <= pos) lo = mid; else hi = mid - 1; } return lo + 1; };
|
|
240
|
+
}
|
|
241
|
+
let lineAt = (code, i) => code.slice(0, i).split('\n').length;
|
|
242
|
+
|
|
243
|
+
function parseImports(code) {
|
|
244
|
+
const out = [];
|
|
245
|
+
const re = /import\s+(type\s+)?(?:([A-Za-z_$][\w$]*)\s*,?\s*)?(?:\{([^}]*)\})?\s*from\s*['"]([^'"]+)['"]/g;
|
|
246
|
+
let m;
|
|
247
|
+
while ((m = re.exec(code))) {
|
|
248
|
+
const names = (m[3] || '').split(',').map((x) => x.trim()).filter(Boolean).map((x) => {
|
|
249
|
+
const typeOnly = /^type\s+/.test(x);
|
|
250
|
+
const [imported, local] = x.replace(/^type\s+/, '').split(/\s+as\s+/).map((y) => y.trim());
|
|
251
|
+
return { imported, local: local || imported, typeOnly: typeOnly || !!m[1] };
|
|
252
|
+
});
|
|
253
|
+
out.push({ from: m[4], names, line: lineAt(code, m.index), def: m[2] || null });
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Reads a balanced {...} starting at code[i] === '{'. Returns the index after the closing brace. */
|
|
259
|
+
function skipBraces(code, i) {
|
|
260
|
+
let depth = 0;
|
|
261
|
+
for (; i < code.length; i++) {
|
|
262
|
+
const ch = code[i];
|
|
263
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
264
|
+
const q = ch;
|
|
265
|
+
for (i++; i < code.length && code[i] !== q; i++) if (code[i] === '\\') i++;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (ch === '{') depth++;
|
|
269
|
+
else if (ch === '}') { depth--; if (depth === 0) return i + 1; }
|
|
270
|
+
}
|
|
271
|
+
return code.length;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const ATTR_NAME = /[A-Za-z_][\w:.-]*/y;
|
|
275
|
+
|
|
276
|
+
function parseElements(code, wanted) {
|
|
277
|
+
const els = [];
|
|
278
|
+
const re = /<([A-Z][\w]*(?:\.[A-Z][\w]*)?)(?=[\s/>])/g;
|
|
279
|
+
let m;
|
|
280
|
+
while ((m = re.exec(code))) {
|
|
281
|
+
const tag = m[1];
|
|
282
|
+
if (!wanted(tag)) continue;
|
|
283
|
+
let i = m.index + m[0].length;
|
|
284
|
+
const attrs = [];
|
|
285
|
+
let spread = false;
|
|
286
|
+
let selfClosing = false;
|
|
287
|
+
// every pass must move forward; an unterminated attribute ends the element instead of looping
|
|
288
|
+
for (let guard = 0; i < code.length && guard < 5000; guard++) {
|
|
289
|
+
while (/\s/.test(code[i])) i++;
|
|
290
|
+
if (code[i] === '/' && code[i + 1] === '>') { selfClosing = true; i += 2; break; }
|
|
291
|
+
if (code[i] === '>') { i++; break; }
|
|
292
|
+
if (code[i] === '<') break; // a new tag before this one closed: malformed, stop here
|
|
293
|
+
if (code[i] === '{') { const end = skipBraces(code, i); if (/^\{\s*\.\.\./.test(code.slice(i, end))) spread = true; i = end; continue; }
|
|
294
|
+
ATTR_NAME.lastIndex = i;
|
|
295
|
+
const nm = ATTR_NAME.exec(code);
|
|
296
|
+
if (!nm) { i++; continue; }
|
|
297
|
+
const name = nm[0];
|
|
298
|
+
i += name.length;
|
|
299
|
+
while (/\s/.test(code[i])) i++;
|
|
300
|
+
if (code[i] !== '=') { attrs.push({ name, kind: 'boolean', value: true }); continue; }
|
|
301
|
+
i++;
|
|
302
|
+
while (/\s/.test(code[i])) i++;
|
|
303
|
+
if (code[i] === '"' || code[i] === "'") {
|
|
304
|
+
const q = code[i];
|
|
305
|
+
const end = code.indexOf(q, i + 1);
|
|
306
|
+
if (end === -1) { attrs.push({ name, kind: 'string', value: code.slice(i + 1) }); i = code.length; break; }
|
|
307
|
+
attrs.push({ name, kind: 'string', value: code.slice(i + 1, end) });
|
|
308
|
+
i = end + 1;
|
|
309
|
+
} else if (code[i] === '{') {
|
|
310
|
+
const end = skipBraces(code, i);
|
|
311
|
+
const expr = code.slice(i + 1, end - 1).trim();
|
|
312
|
+
attrs.push({ name, ...classify(expr) });
|
|
313
|
+
i = end;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
els.push({ tag, attrs, spread, selfClosing, line: lineAt(code, m.index) });
|
|
317
|
+
if (els.length >= 5000) break;
|
|
318
|
+
re.lastIndex = Math.max(re.lastIndex, i);
|
|
319
|
+
}
|
|
320
|
+
return els;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function classify(expr) {
|
|
324
|
+
let m;
|
|
325
|
+
if ((m = /^(['"])([\s\S]*)\1$/.exec(expr))) return { kind: 'string', value: m[2], raw: expr };
|
|
326
|
+
if ((m = /^`([^`$]*)`$/.exec(expr))) return { kind: 'string', value: m[1], raw: expr };
|
|
327
|
+
if (/^-?\d+(\.\d+)?$/.test(expr)) return { kind: 'number', value: Number(expr), raw: expr };
|
|
328
|
+
if (expr === 'true' || expr === 'false') return { kind: 'boolean', value: expr === 'true', raw: expr };
|
|
329
|
+
if (/^</.test(expr)) return { kind: 'element', raw: expr };
|
|
330
|
+
if (/^[A-Za-z_$][\w$]*$/.test(expr)) return { kind: 'identifier', value: expr, raw: expr };
|
|
331
|
+
return { kind: 'expression', raw: expr };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const TYPE_LIKE = /(Props|Type|Variant|Size|Item|Option|Datum|Column|Link|Plan|Post|Action|Step|Image|Author|Change|Data|Tab|Color|Category|Job|Member|Testimonial|Transaction)$/;
|
|
335
|
+
|
|
336
|
+
function checkCode(code) {
|
|
337
|
+
lineAt = ((idx) => (_c, pos) => idx(pos))(lineIndex(code));
|
|
338
|
+
const errors = [];
|
|
339
|
+
const warnings = [];
|
|
340
|
+
const imports = parseImports(code);
|
|
341
|
+
const importedFrom = new Map(); // local name -> module
|
|
342
|
+
for (const imp of imports) for (const n of imp.names) importedFrom.set(n.local, imp.from);
|
|
343
|
+
const importedElsewhere = new Set([...importedFrom].filter(([, from]) => !from.startsWith('@sakaniui/react')).map(([n]) => n));
|
|
344
|
+
|
|
345
|
+
for (const imp of imports) {
|
|
346
|
+
if (!imp.from.startsWith('@sakaniui/react')) continue;
|
|
347
|
+
const entry = imp.from;
|
|
348
|
+
if (!['@sakaniui/react', '@sakaniui/react/blocks', '@sakaniui/react/icons'].includes(entry)) {
|
|
349
|
+
if (imp.names.length) errors.push({ line: imp.line, message: `"${entry}" has no named exports. Import components from @sakaniui/react, blocks from @sakaniui/react/blocks and icons from @sakaniui/react/icons.` });
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (imp.def) errors.push({ line: imp.line, message: `${entry} has no default export; use a named import: import { ${imp.def} } from '${entry}';` });
|
|
353
|
+
for (const n of imp.names) {
|
|
354
|
+
if (n.typeOnly) continue;
|
|
355
|
+
const c = byName.get(n.imported.toLowerCase());
|
|
356
|
+
const isUtility = manifest.utilities?.some((u) => u.name === n.imported);
|
|
357
|
+
if (entry === '@sakaniui/react/icons') {
|
|
358
|
+
if (!iconNames.has(n.imported)) {
|
|
359
|
+
const hint = searchIcons(n.imported, 3).map((i) => i.name);
|
|
360
|
+
errors.push({ line: imp.line, message: `${n.imported} is not exported by @sakaniui/react/icons.${hint.length ? ` Did you mean: ${hint.join(', ')}?` : ''}` });
|
|
361
|
+
}
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (c && c.name !== n.imported) { errors.push({ line: imp.line, message: `${n.imported} has the wrong casing; the export is ${c.name}.` }); continue; }
|
|
365
|
+
if (entry === '@sakaniui/react/blocks') {
|
|
366
|
+
if (!c || c.kind !== 'block') errors.push({ line: imp.line, message: c ? `${n.imported} is a component: import it from @sakaniui/react.` : `${n.imported} is not a SakaniUI block.` });
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (c && c.kind === 'block') { errors.push({ line: imp.line, message: `${n.imported} is a block: import it from @sakaniui/react/blocks.` }); continue; }
|
|
370
|
+
if (!c && !isUtility) {
|
|
371
|
+
if (TYPE_LIKE.test(n.imported)) continue; // most likely a type export
|
|
372
|
+
const near = closest(n.imported);
|
|
373
|
+
errors.push({ line: imp.line, message: `${n.imported} is not exported by @sakaniui/react.${near.length ? ` Did you mean: ${near.join(', ')}?` : ''} Use search_components.` });
|
|
374
|
+
}
|
|
375
|
+
if (c && inInstalled(c) === false) errors.push({ line: imp.line, message: `${c.name} was added in ${c.since}; the project has ${projectVersion}.` });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// elements: anything imported from @sakaniui, plus manifest names used without an import
|
|
380
|
+
const isSakani = (tag) => importedFrom.get(tag)?.startsWith('@sakaniui/react') || (!importedFrom.has(tag) && byName.has(tag.toLowerCase()) && byName.get(tag.toLowerCase()).name === tag);
|
|
381
|
+
const elements = parseElements(code, isSakani);
|
|
382
|
+
for (const el of elements) {
|
|
383
|
+
const c = byName.get(el.tag.toLowerCase());
|
|
384
|
+
if (!c) continue;
|
|
385
|
+
const at = (message) => ({ line: el.line, message: `<${el.tag}>: ${message}` });
|
|
386
|
+
if (!importedFrom.has(el.tag)) warnings.push(at(`not imported. Add: import { ${c.name} } from '${c.import}';`));
|
|
387
|
+
const given = new Map(el.attrs.map((a) => [a.name, a]));
|
|
388
|
+
if (!el.selfClosing) given.set('children', { kind: 'expression' });
|
|
389
|
+
for (const r of c.contract.requiredProps) {
|
|
390
|
+
if (given.has(r)) continue;
|
|
391
|
+
if (el.spread) warnings.push(at(`required prop "${r}" is not written out (it may come from the spread).`));
|
|
392
|
+
else errors.push(at(`missing required prop "${r}".`));
|
|
393
|
+
}
|
|
394
|
+
const known = new Map(c.props.map((p) => [p.name, p]));
|
|
395
|
+
for (const a of el.attrs) {
|
|
396
|
+
const p = known.get(a.name);
|
|
397
|
+
if (!p) { if (!NATIVE.test(a.name) && a.name !== 'key' && a.name !== 'ref') warnings.push(at(`"${a.name}" is not a ${c.name} prop (it only works if it is a native attribute).`)); continue; }
|
|
398
|
+
if (p.values && ['string', 'number', 'boolean'].includes(a.kind) && !p.values.includes(a.value)) {
|
|
399
|
+
errors.push(at(`${a.name}=${JSON.stringify(a.value)} is not allowed. Use one of: ${p.values.map((x) => JSON.stringify(x)).join(', ')}.`));
|
|
400
|
+
}
|
|
401
|
+
if (p.type === 'IconComponent') {
|
|
402
|
+
if (a.kind === 'string') errors.push(at(`"${a.name}" takes an icon component, not a string: ${a.name}={IconName}.`));
|
|
403
|
+
else if (a.kind === 'element' || a.kind === 'identifier') {
|
|
404
|
+
const e = iconProblem(a.name, a.raw, importedElsewhere);
|
|
405
|
+
if (e) errors.push(at(e));
|
|
406
|
+
else if (a.kind === 'identifier' && iconNames.has(a.value) && importedFrom.get(a.value) !== '@sakaniui/react/icons') warnings.push(at(`${a.value} is not imported. Add: import { ${a.value} } from '@sakaniui/react/icons';`));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (!elements.length && !imports.some((i) => i.from.startsWith('@sakaniui/react'))) warnings.push({ line: 1, message: 'No SakaniUI imports or elements found in this code.' });
|
|
412
|
+
const out = { ok: errors.length === 0, checkedElements: elements.length, errors, warnings };
|
|
413
|
+
const note = versionNote(); if (note) out.versionNote = note;
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
416
|
+
|
|
165
417
|
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
166
418
|
|
|
167
419
|
/* ---------- tools ---------- */
|
|
@@ -194,6 +446,8 @@ const tools = {
|
|
|
194
446
|
const c = byName.get(n.value.toLowerCase());
|
|
195
447
|
if (!c) return fail(`No component named "${n.value}". Did you mean: ${closest(n.value).join(', ') || '(none close)'}? Use search_components.`);
|
|
196
448
|
const out = { ...c, note: 'Native HTML attributes (className, style, aria-*, event handlers) are also accepted on the root element.' };
|
|
449
|
+
if (c.example) out.exampleNote = 'Start from `example`: it is type-checked against this release. Replace the placeholder text and values; keep the prop names and shapes.';
|
|
450
|
+
if (c.props.some((p) => p.type === 'IconComponent')) out.iconNote = 'Icon props take the component itself (icon={IconTrash}), not an element. The example uses IconStar as a placeholder: find the real icon with search_icons.';
|
|
197
451
|
if (inInstalled(c) === false) out.availableInInstalled = false;
|
|
198
452
|
const note = versionNote();
|
|
199
453
|
if (note) out.versionNote = note;
|
|
@@ -255,6 +509,7 @@ const tools = {
|
|
|
255
509
|
if (p.values && (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') && !p.values.includes(v)) {
|
|
256
510
|
errors.push(`"${k}" = ${JSON.stringify(v)} is not allowed. Use one of: ${p.values.map((x) => JSON.stringify(x)).join(', ')}.`);
|
|
257
511
|
}
|
|
512
|
+
if (p.type === 'IconComponent' && typeof v === 'string') { const e = iconProblem(k, v); if (e) errors.push(e); }
|
|
258
513
|
}
|
|
259
514
|
if (c.contract.requiresAccessibleName && !errors.length) warnings.push('This control has no visible text of its own: keep its accessible-name prop set.');
|
|
260
515
|
const out = { component: c.name, ok: errors.length === 0, errors, warnings };
|
|
@@ -301,6 +556,28 @@ const tools = {
|
|
|
301
556
|
return text(pick.map((r) => `## ${r.version}\n\n${r.body}`).join('\n\n'));
|
|
302
557
|
},
|
|
303
558
|
},
|
|
559
|
+
search_icons: {
|
|
560
|
+
description: 'Find real icon names (Tabler set, exported from @sakaniui/react/icons) by meaning, e.g. "delete", "settings", "arrow right". Never guess an icon name: only names returned here exist.',
|
|
561
|
+
inputSchema: { type: 'object', properties: { query: { type: 'string' }, limit: { type: 'number' } }, required: ['query'] },
|
|
562
|
+
run(args) {
|
|
563
|
+
const q = strArg(args, 'query', { required: true }); if (q.err) return fail(q.err);
|
|
564
|
+
if (!iconIndex.length) return fail('The icon index is not available in this install.');
|
|
565
|
+
const limit = Number.isInteger(args.limit) ? Math.min(Math.max(args.limit, 1), 30) : 12;
|
|
566
|
+
const hits = searchIcons(q.value, limit).map((i) => { const o = { name: i.name }; if (i.filled) o.filled = i.filled; if (i.category) o.category = i.category; if (i.tags) o.tags = i.tags.slice(0, 5); return o; });
|
|
567
|
+
if (!hits.length) return text(`No icon matches "${q.value}". Try a simpler word (e.g. "delete" rather than "remove item").`);
|
|
568
|
+
return text({ import: "import { IconName } from '@sakaniui/react/icons';", usage: 'icon={IconName} (the component, not <IconName />)', icons: hits });
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
check_code: {
|
|
572
|
+
description: 'Check a JSX/TSX snippet you wrote: imports from @sakaniui/react (and /blocks, /icons) must exist, every SakaniUI element must have its required props, prop values must be allowed, and icon props must be real icon components passed as components. The code is only read, never run. Run it on every snippet before you answer.',
|
|
573
|
+
inputSchema: { type: 'object', properties: { code: { type: 'string', description: 'The snippet, including its import lines.' } }, required: ['code'] },
|
|
574
|
+
run(args) {
|
|
575
|
+
const code = args.code;
|
|
576
|
+
if (typeof code !== 'string' || !code.trim()) return fail('"code" must be a non-empty string.');
|
|
577
|
+
if (code.length > 50000) return fail('"code" is too long (max 50,000 characters).');
|
|
578
|
+
return text(checkCode(code));
|
|
579
|
+
},
|
|
580
|
+
},
|
|
304
581
|
get_guide: {
|
|
305
582
|
description: 'The AGENTS.md guide (principles, token rules, when nothing fits, glass, icons, per-component "why / when not to use", checklist). Pass a section number or heading text to get just that section; omit for the section list.',
|
|
306
583
|
inputSchema: { type: 'object', properties: { section: { type: 'string' } } },
|