@sakaniui/react 0.8.9 → 0.8.11
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 +11 -2
- package/CHANGELOG.md +60 -0
- package/README.md +30 -4
- package/bin/sakaniui-mcp.mjs +222 -31
- package/dist/icon-index.json +1 -1
- package/dist/lib/ChartTooltip.d.ts +20 -0
- package/dist/lib/LiquidGlass.d.ts +93 -0
- package/dist/lib/SakaniLogo.d.ts +3 -0
- package/dist/lib/chartPalette.d.ts +12 -0
- package/dist/lib/getContrastColor.d.ts +23 -0
- package/dist/lib/iconStrokeWidth.d.ts +13 -0
- package/dist/lib/useChartTokens.d.ts +31 -0
- package/dist/lib/useHoverHighlight.d.ts +17 -0
- package/dist/lib/usePortalThemeClass.d.ts +31 -0
- package/dist/lib/useThemeTick.d.ts +10 -0
- package/dist/manifest.json +1965 -96
- package/package.json +4 -4
package/AGENTS.md
CHANGED
|
@@ -16,9 +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
|
|
19
|
+
(`npx -y @sakaniui/mcp`): `search_components`, `get_component`, `search_icons`,
|
|
20
20
|
`check_code`, `check_usage`, `get_tokens`, `get_guide`, `check_compatibility`, `get_changelog`.
|
|
21
21
|
|
|
22
|
+
**Object props have a shape.** A prop such as `columns`, `items`, `options`, `plans` or `data` takes an array
|
|
23
|
+
of objects with exactly the fields listed under the prop's `shape` (required ones must be present, and
|
|
24
|
+
`keyof T` means "a field of your own data type"). `check_code` verifies the field names of inline literals.
|
|
25
|
+
Components with a pattern the types do not teach carry `recipes`: code that compiles.
|
|
26
|
+
|
|
22
27
|
**Start from the example, check before you answer.** Every component has `example`: its imports and the
|
|
23
28
|
element with each required prop filled in, type-checked against the release. Start from it instead of
|
|
24
29
|
writing the element from memory. Never guess an icon name: find it with `search_icons` (icons are
|
|
@@ -206,7 +211,11 @@ Format: **Use for** · **Why it is built this way** · **Not for / instead**.
|
|
|
206
211
|
### Content and data
|
|
207
212
|
- **Card** — a container surface for a unit of content, optionally with actions. Not a layout wrapper for
|
|
208
213
|
everything.
|
|
209
|
-
- **Table** — dense, comparable records.
|
|
214
|
+
- **Table** — dense, comparable records. `Table<T>` is generic: every column's `key` must be a field of
|
|
215
|
+
your row type `T` (`keyof T & string`), so a column that is not data (a delete button, a menu) still
|
|
216
|
+
needs a key: give the row type an optional `never` field (`actions?: never`) and render the control in
|
|
217
|
+
`render`. Inventing a key that is not on the row type does not compile (see `recipes` in
|
|
218
|
+
`get_component("Table")` for code that does). **StatCard** — one headline metric with trend. **ListItem** — a
|
|
210
219
|
row in a list. **Charts** — pick by question: trend over time → Line/Area; compare categories → Bar;
|
|
211
220
|
part of whole → Pie/Donut (few slices only); progress to a target → Radial; compare several measures →
|
|
212
221
|
Radar; drop-off → Funnel; intensity across two dimensions → Heatmap.
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,66 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@sakaniui/react` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.8.11
|
|
6
|
+
|
|
7
|
+
Found by asking Claude Code, with the published 0.8.10 MCP server, to add a delete button to a table row:
|
|
8
|
+
every tool call was correct and `IconButton` was used properly, but it put the button in a new `Table`
|
|
9
|
+
column with `key: 'actions'`, which does not compile (`key` must be a field of the row type). The
|
|
10
|
+
contract only said `columns: TableColumn<T>[]`.
|
|
11
|
+
|
|
12
|
+
- **Object props now have a `shape`** in `manifest.json`: the fields of `TableColumn<T>`, `BreadcrumbItem`,
|
|
13
|
+
`PricingPlan`, `SelectOption`, chart data and 58 more (63 props), read from the shipped type
|
|
14
|
+
definitions, generics kept as declared (`key: keyof T & string`).
|
|
15
|
+
- **`recipes`**: code for patterns the types do not teach, compiled on every build. First one: a `Table`
|
|
16
|
+
column that is not data (a delete button, a menu). The build also checks the opposite, that a column
|
|
17
|
+
key which is not on the row type really does not compile.
|
|
18
|
+
- **MCP `check_code` checks inline object literals** against the shape: an unknown field (`title` for
|
|
19
|
+
`label`, `pct` for `percent`, with a suggestion), a missing required field (with the item index), and,
|
|
20
|
+
for a `Table`, a column `key` that is not a field of the `rows` written next to it. Spreads, identifiers
|
|
21
|
+
and anything it cannot read are left alone (0 errors on 685 real elements in three apps).
|
|
22
|
+
- `AGENTS.md` explains the generic `Table` rule and the new `shape` and `recipes`.
|
|
23
|
+
|
|
24
|
+
## 0.8.10
|
|
25
|
+
|
|
26
|
+
Readiness release: everything below was found by testing the package the way a new user, an editor
|
|
27
|
+
and an AI agent would, and every fix is now covered by a check that fails the build.
|
|
28
|
+
|
|
29
|
+
**Installing the library**
|
|
30
|
+
- **Fixed: installing `@sakaniui/react` installed Next.js.** The `geist` and `@fontsource-variable/geist`
|
|
31
|
+
font packages were runtime dependencies, though the library never loads them, and `geist` requires
|
|
32
|
+
Next.js as a peer, so every app (Vite included) got Next.js 16. Both are now dev-only; the only runtime
|
|
33
|
+
dependency is `recharts`. Load Geist yourself if you want it (`@fontsource-variable/geist`); otherwise
|
|
34
|
+
the system font is used, as before.
|
|
35
|
+
- **Fixed: missing type definitions.** `dist/lib/` was left out of the generated `.d.ts` files, so
|
|
36
|
+
`LiquidGlass`, `LiquidBackdrop`, `HoverGroup`, `useHoverHighlight`, `useLiquidGlass`, `getContrastColor`
|
|
37
|
+
and `iconStrokeWidth` had no types: `<LiquidBackdrop />` compiled without its required `src`. The build
|
|
38
|
+
now fails if any relative import in a shipped `.d.ts` does not resolve.
|
|
39
|
+
- **Fixed docs: `style.css` is required.** The README and the website's Installation page only imported
|
|
40
|
+
`tokens.css`, which leaves components unstyled. Both now import both stylesheets.
|
|
41
|
+
|
|
42
|
+
**The MCP server**
|
|
43
|
+
- **New package `@sakaniui/mcp`** (about 190 kB, no dependencies): `npx -y @sakaniui/mcp` starts in
|
|
44
|
+
about 2 s on a first run. Through `@sakaniui/react` it took ~30 s and ~675 MB, long enough for an editor
|
|
45
|
+
to give up. The copy in `@sakaniui/react` still works.
|
|
46
|
+
- **`check_code`: no false positives** on 685 real elements across three apps (it reported 77). Aliased
|
|
47
|
+
and default imports, code inside template strings, native attributes and the non-icon exports of
|
|
48
|
+
`@sakaniui/react/icons` are handled; it still catches every deliberate mistake in the test set.
|
|
49
|
+
- **Versions per prop and per value.** Each prop added after its component has its own `since` (for
|
|
50
|
+
example `Switch.variant`, 0.8.4), and values added later are listed in `valuesSince`. `since` now means
|
|
51
|
+
"first release you could import it", taken from the package's public exports at every release (the
|
|
52
|
+
social icons are 0.8.6, not 0.3.0). `check_code`, `check_usage` and `get_component` flag props and
|
|
53
|
+
values the project's installed version does not have.
|
|
54
|
+
- **Invented props are errors.** A prop that is neither the component's own nor an HTML attribute (React's
|
|
55
|
+
own attribute list now ships in `manifest.json` as `nativeAttributes`) fails `check_code` and
|
|
56
|
+
`check_usage`, with the real prop list and a suggestion (`leftIcon` -> "did you mean leadingIcon?").
|
|
57
|
+
Real HTML attributes (`placeholder`, `href`, `name`...) pass silently; `color` gets a note that it does
|
|
58
|
+
not style the component.
|
|
59
|
+
- **Protocol:** negotiates a supported protocol version, answers `resources/list`, `prompts/list` and
|
|
60
|
+
batches, exits when the editor disconnects (flushing replies first), and survives a closed pipe.
|
|
61
|
+
`get_guide` accepts `query`. Tested on Node 18, 20, 22 and 26, with the official MCP SDK client and the
|
|
62
|
+
MCP Inspector, and documented for Windows (`cmd /c npx ...`).
|
|
63
|
+
- Removed an unresolvable `$schema` link from `manifest.json`.
|
|
64
|
+
|
|
5
65
|
## 0.8.9
|
|
6
66
|
|
|
7
67
|
Stops assistants from inventing code. Found by testing the MCP server in Claude Code: it validated a
|
package/README.md
CHANGED
|
@@ -43,12 +43,33 @@ accepts Lucide or Tabler React icons. See `THIRD_PARTY_NOTICES.md` for the MIT l
|
|
|
43
43
|
npm install @sakaniui/react
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
Then import the
|
|
46
|
+
Then import the two stylesheets once, at your app's entry point (both are required: `tokens.css` defines
|
|
47
|
+
the design tokens, `style.css` styles the components), and use any component from the package root:
|
|
47
48
|
|
|
48
49
|
```tsx
|
|
49
50
|
import '@sakaniui/react/tokens.css';
|
|
51
|
+
import '@sakaniui/react/style.css';
|
|
52
|
+
|
|
53
|
+
import { Button } from '@sakaniui/react';
|
|
54
|
+
|
|
55
|
+
export function Example() {
|
|
56
|
+
return <Button variant="primary">Get started</Button>;
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Requires React 19. Typography is set in **Geist**; load the font yourself if you want it (otherwise the
|
|
61
|
+
system font is used):
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm install @fontsource-variable/geist
|
|
50
65
|
```
|
|
51
66
|
|
|
67
|
+
```tsx
|
|
68
|
+
import '@fontsource-variable/geist';
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Charts (`AreaChart`, `BarChart`, ...) use `recharts`, which is installed with the package.
|
|
72
|
+
|
|
52
73
|
> **Building with AI?** `AGENTS.md` (shipped in this package) explains *why* components behave as they do and what to do when nothing fits. Index for agents: https://www.sakaniui.com/llms.txt
|
|
53
74
|
|
|
54
75
|
## Quick start (contributing / browsing the source)
|
|
@@ -108,15 +129,20 @@ up instead of guessing prop names.
|
|
|
108
129
|
```json
|
|
109
130
|
{
|
|
110
131
|
"mcpServers": {
|
|
111
|
-
"sakaniui": { "command": "npx", "args": ["-y", "
|
|
132
|
+
"sakaniui": { "command": "npx", "args": ["-y", "@sakaniui/mcp"] }
|
|
112
133
|
}
|
|
113
134
|
}
|
|
114
135
|
```
|
|
115
136
|
|
|
116
|
-
Claude Code: `claude mcp add sakaniui -- npx -y
|
|
137
|
+
Claude Code: `claude mcp add sakaniui -- npx -y @sakaniui/mcp`
|
|
138
|
+
|
|
139
|
+
On Windows, launch `npx` through `cmd /c` (`"command": "cmd", "args": ["/c", "npx", "-y", "@sakaniui/mcp"]`,
|
|
140
|
+
or `claude mcp add sakaniui -- cmd /c npx -y @sakaniui/mcp`). Needs Node.js 18 or later.
|
|
141
|
+
|
|
142
|
+
The server is its own tiny package, [`@sakaniui/mcp`](https://www.npmjs.com/package/@sakaniui/mcp) (about 1 MB, starts in a couple of seconds; it does not install the component library). The copy bundled in `@sakaniui/react` (`npx -y -p @sakaniui/react sakaniui-mcp`) still works but downloads far more.
|
|
117
143
|
|
|
118
144
|
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`,
|
|
145
|
+
`check_code` (checks a whole snippet: imports, required props, values, icon names, and the field names of object props such as table columns), `check_usage`,
|
|
120
146
|
`list_components`, `get_tokens`, `get_guide`, `check_compatibility` and `get_changelog`.
|
|
121
147
|
|
|
122
148
|
- **Read-only.** It reads five fixed files from a `@sakaniui/react` install (package.json, the
|
package/bin/sakaniui-mcp.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* SakaniUI MCP server -- lets an AI editor/agent look up the design system instead of guessing.
|
|
4
4
|
*
|
|
5
|
-
* npx -y
|
|
5
|
+
* npx -y @sakaniui/mcp [--project <dir>] (or: npx -y -p @sakaniui/react sakaniui-mcp)
|
|
6
6
|
*
|
|
7
7
|
* Zero dependencies: speaks newline-delimited JSON-RPC (MCP stdio transport).
|
|
8
8
|
*
|
|
@@ -47,7 +47,8 @@ function findProjectInstall() {
|
|
|
47
47
|
return null;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
// inside @sakaniui/react the data sits next to bin/; inside @sakaniui/mcp it is copied into data/
|
|
51
|
+
const bundled = loadInstall(fs.existsSync(path.join(ownRoot, 'dist', 'manifest.json')) ? ownRoot : path.join(ownRoot, 'data'));
|
|
51
52
|
if (!bundled || !bundled.manifest) {
|
|
52
53
|
process.stderr.write('sakaniui-mcp: dist/manifest.json not found. Run `npm run build:lib` (or install a published version).\n');
|
|
53
54
|
process.exit(1);
|
|
@@ -68,6 +69,22 @@ const changelog = active.changelog || bundled.changelog || '';
|
|
|
68
69
|
const iconIndex = (active.icons && active.icons.icons) || (bundled.icons && bundled.icons.icons) || [];
|
|
69
70
|
const projectVersion = project ? project.version : null;
|
|
70
71
|
|
|
72
|
+
// The HTML attributes React accepts (shipped in the manifest since 0.8.10; older manifests use the fallback list).
|
|
73
|
+
const nativeAttrs = new Set(manifest.nativeAttributes || []);
|
|
74
|
+
const NATIVE = { test: (n) => /^(aria-|data-)/.test(n) || /^on[A-Z]/.test(n) || n === 'key' || n === 'ref' || n === 'children' || (nativeAttrs.size ? nativeAttrs.has(n) : NATIVE_FALLBACK.test(n)) };
|
|
75
|
+
/** What to say about a prop the component does not declare: an error when nothing accepts it, else a note. */
|
|
76
|
+
function unknownProp(c, name) {
|
|
77
|
+
if (NATIVE.test(name)) {
|
|
78
|
+
// a real HTML attribute (placeholder, href, name, defaultChecked...): passed to the element, and fine.
|
|
79
|
+
// `color` is the one people mistake for a styling prop: it does not colour the component.
|
|
80
|
+
return name === 'color' ? { level: 'warning', message: `"color" is not a ${c.name} prop: it is the old HTML colour attribute and does not change the component's colours. Use the component's own props, or design tokens (get_tokens).` } : null;
|
|
81
|
+
}
|
|
82
|
+
// close spellings, plus the usual synonyms (leftIcon -> leadingIcon, onChange vs onValueChange is left to the list)
|
|
83
|
+
const canon = (x) => x.toLowerCase().replace(/left|start|before|prefix/g, 'leading').replace(/right|end|after|suffix/g, 'trailing').replace(/colou?r|tone|kind|type/g, 'variant');
|
|
84
|
+
const near = c.props.map((p) => p.name).filter((p) => similar(p, name) || canon(p) === canon(name));
|
|
85
|
+
return { level: 'error', message: `"${name}" is not a ${c.name} prop${near.length ? ` (did you mean ${near.map((x) => `"${x}"`).join(', ')}?)` : ''}. ${c.name} accepts: ${c.props.map((p) => p.name).join(', ') || 'no props of its own'}.` };
|
|
86
|
+
}
|
|
87
|
+
|
|
71
88
|
/* ---------- versions ---------- */
|
|
72
89
|
const SEMVER = /^\d+\.\d+\.\d+/;
|
|
73
90
|
const parse = (v) => String(v).split('-')[0].split('.').map(Number);
|
|
@@ -93,6 +110,15 @@ function inInstalled(c, installed = projectVersion) {
|
|
|
93
110
|
return cmp(c.since, installed) <= 0;
|
|
94
111
|
}
|
|
95
112
|
|
|
113
|
+
/** Is this prop (or this value of it) in the project's install? Returns a message when it is not. */
|
|
114
|
+
function propTooNew(c, p, value, installed = projectVersion) {
|
|
115
|
+
if (!installed) return null;
|
|
116
|
+
if (p.since && cmp(p.since, installed) > 0) return `"${p.name}" was added to ${c.name} in ${p.since}; the project has ${installed}, where it does not exist. Upgrade (npm install @sakaniui/react@latest) or leave it out.`;
|
|
117
|
+
const vs = p.valuesSince && typeof value === 'string' ? p.valuesSince[value] : null;
|
|
118
|
+
if (vs && cmp(vs, installed) > 0) return `${p.name}=${JSON.stringify(value)} was added in ${vs}; the project has ${installed}, where it does not exist.`;
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
96
122
|
/* ---------- helpers ---------- */
|
|
97
123
|
const byName = new Map(manifest.components.map((c) => [c.name.toLowerCase(), c]));
|
|
98
124
|
const brief = (c) => {
|
|
@@ -144,6 +170,16 @@ function distance(a, b) {
|
|
|
144
170
|
return d[a.length][b.length];
|
|
145
171
|
}
|
|
146
172
|
|
|
173
|
+
/** `pct` for `percent`, `desc` for `description`: same first letter, every letter in order. */
|
|
174
|
+
function isAbbreviation(short, full) {
|
|
175
|
+
const a = short.toLowerCase(), b = full.toLowerCase();
|
|
176
|
+
if (a.length < 2 || a.length >= b.length || a[0] !== b[0]) return false;
|
|
177
|
+
let i = 0;
|
|
178
|
+
for (const ch of b) if (ch === a[i]) i++;
|
|
179
|
+
return i === a.length;
|
|
180
|
+
}
|
|
181
|
+
const similar = (a, b) => distance(a.toLowerCase(), b.toLowerCase()) <= 2 || isAbbreviation(a, b) || isAbbreviation(b, a);
|
|
182
|
+
|
|
147
183
|
function closest(name) {
|
|
148
184
|
const n = name.toLowerCase();
|
|
149
185
|
return manifest.components
|
|
@@ -157,6 +193,7 @@ function closest(name) {
|
|
|
157
193
|
/* ---------- icons ---------- */
|
|
158
194
|
const iconNames = new Set();
|
|
159
195
|
for (const i of iconIndex) { iconNames.add(i.name); if (i.filled) iconNames.add(i.filled); }
|
|
196
|
+
const iconOtherExports = new Set(((active.icons && active.icons.other) || (bundled.icons && bundled.icons.other)) || []);
|
|
160
197
|
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
198
|
|
|
162
199
|
// The conventional icon for common UI intents, ranked first (tags alone rank e.g. IconHttpDelete above IconTrash for "delete").
|
|
@@ -207,7 +244,7 @@ function searchIcons(query, limit = 12) {
|
|
|
207
244
|
}
|
|
208
245
|
|
|
209
246
|
/** Checks a value given for an icon prop. Returns an error string, or null. */
|
|
210
|
-
function iconProblem(propName, raw, importedElsewhere = new Set()) {
|
|
247
|
+
function iconProblem(propName, raw, importedElsewhere = new Set(), localNames = new Set()) {
|
|
211
248
|
const v = String(raw).trim().replace(/^\{|\}$/g, '').trim();
|
|
212
249
|
if (/^</.test(v)) {
|
|
213
250
|
const tag = (/^<\s*([A-Za-z][\w.]*)/.exec(v) || [])[1] || 'Icon';
|
|
@@ -216,12 +253,15 @@ function iconProblem(propName, raw, importedElsewhere = new Set()) {
|
|
|
216
253
|
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
254
|
}
|
|
218
255
|
if (!/^[A-Za-z_$][\w$]*$/.test(v)) return null; // an expression we cannot judge
|
|
219
|
-
if (iconNames.has(v) || importedElsewhere.has(v)) return null;
|
|
256
|
+
if (iconNames.has(v) || importedElsewhere.has(v) || localNames.has(v)) return null;
|
|
257
|
+
// a local name (alias, parameter, constant): the import line was already checked, and we cannot see the rest
|
|
258
|
+
if (!/^Icon[A-Z0-9]/.test(v)) return null;
|
|
220
259
|
const hint = searchIcons(v, 3).map((i) => i.name);
|
|
221
260
|
return `"${v}" is not a SakaniUI icon.${hint.length ? ` Did you mean: ${hint.join(', ')}?` : ''} Use search_icons to find the real name.`;
|
|
222
261
|
}
|
|
223
262
|
|
|
224
|
-
|
|
263
|
+
// Attributes every component forwards to its root element. A prop outside this list and the contract is worth a warning.
|
|
264
|
+
const NATIVE_FALLBACK = /^(className|style|id|ref|key|children|role|tabIndex|title|hidden|lang|dir|slot|on[A-Z].*|aria-.*|data-.*|href|target|rel|download|name|type|value|defaultValue|checked|defaultChecked|disabled|required|readOnly|placeholder|htmlFor|form|autoFocus|autoComplete|autoCapitalize|spellCheck|inputMode|pattern|min|max|step|minLength|maxLength|rows|cols|multiple|accept|src|srcSet|alt|width|height|loading|draggable|tabindex|ariaLabel)$/;
|
|
225
265
|
|
|
226
266
|
/** Release notes: [{version, body}] newest first, from the CHANGELOG that matches the answering install. */
|
|
227
267
|
function releases() {
|
|
@@ -333,14 +373,38 @@ function classify(expr) {
|
|
|
333
373
|
|
|
334
374
|
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
375
|
|
|
336
|
-
|
|
376
|
+
/** Replaces the contents of template literals with spaces (newlines kept): code shown inside a string is not code. */
|
|
377
|
+
function blankTemplates(code) {
|
|
378
|
+
let out = '';
|
|
379
|
+
for (let i = 0; i < code.length; i++) {
|
|
380
|
+
const ch = code[i];
|
|
381
|
+
if (ch === '`') {
|
|
382
|
+
let j = i + 1, depth = 0;
|
|
383
|
+
for (; j < code.length; j++) {
|
|
384
|
+
if (code[j] === '\\') { j++; continue; }
|
|
385
|
+
if (code[j] === '$' && code[j + 1] === '{') { depth++; j++; continue; }
|
|
386
|
+
if (code[j] === '}' && depth > 0) { depth--; continue; }
|
|
387
|
+
if (code[j] === '`' && depth === 0) break;
|
|
388
|
+
}
|
|
389
|
+
out += '`' + code.slice(i + 1, j).replace(/[^\n]/g, ' ') + '`';
|
|
390
|
+
i = j;
|
|
391
|
+
} else out += ch;
|
|
392
|
+
}
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function checkCode(source) {
|
|
397
|
+
const code = blankTemplates(source);
|
|
337
398
|
lineAt = ((idx) => (_c, pos) => idx(pos))(lineIndex(code));
|
|
338
399
|
const errors = [];
|
|
339
400
|
const warnings = [];
|
|
340
401
|
const imports = parseImports(code);
|
|
341
402
|
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);
|
|
403
|
+
for (const imp of imports) { for (const n of imp.names) importedFrom.set(n.local, imp.from); if (imp.def) importedFrom.set(imp.def, imp.from); }
|
|
343
404
|
const importedElsewhere = new Set([...importedFrom].filter(([, from]) => !from.startsWith('@sakaniui/react')).map(([n]) => n));
|
|
405
|
+
// anything the snippet declares itself (const Icon = ..., function X, class Y) or aliases: not ours to judge
|
|
406
|
+
const localNames = new Set([...code.matchAll(/\b(?:const|let|var|function|class)\s+([A-Za-z_$][\w$]*)/g)].map((m) => m[1]));
|
|
407
|
+
for (const imp of imports) for (const n of imp.names) if (n.local !== n.imported) localNames.add(n.local);
|
|
344
408
|
|
|
345
409
|
for (const imp of imports) {
|
|
346
410
|
if (!imp.from.startsWith('@sakaniui/react')) continue;
|
|
@@ -355,7 +419,7 @@ function checkCode(code) {
|
|
|
355
419
|
const c = byName.get(n.imported.toLowerCase());
|
|
356
420
|
const isUtility = manifest.utilities?.some((u) => u.name === n.imported);
|
|
357
421
|
if (entry === '@sakaniui/react/icons') {
|
|
358
|
-
if (!iconNames.has(n.imported)) {
|
|
422
|
+
if (!iconNames.has(n.imported) && !iconOtherExports.has(n.imported)) {
|
|
359
423
|
const hint = searchIcons(n.imported, 3).map((i) => i.name);
|
|
360
424
|
errors.push({ line: imp.line, message: `${n.imported} is not exported by @sakaniui/react/icons.${hint.length ? ` Did you mean: ${hint.join(', ')}?` : ''}` });
|
|
361
425
|
}
|
|
@@ -383,6 +447,7 @@ function checkCode(code) {
|
|
|
383
447
|
const c = byName.get(el.tag.toLowerCase());
|
|
384
448
|
if (!c) continue;
|
|
385
449
|
const at = (message) => ({ line: el.line, message: `<${el.tag}>: ${message}` });
|
|
450
|
+
if (inInstalled(c) === false && !importedFrom.has(el.tag)) errors.push(at(`${c.name} was added in ${c.since}; the project has ${projectVersion}, where it does not exist.`));
|
|
386
451
|
if (!importedFrom.has(el.tag)) warnings.push(at(`not imported. Add: import { ${c.name} } from '${c.import}';`));
|
|
387
452
|
const given = new Map(el.attrs.map((a) => [a.name, a]));
|
|
388
453
|
if (!el.selfClosing) given.set('children', { kind: 'expression' });
|
|
@@ -394,16 +459,18 @@ function checkCode(code) {
|
|
|
394
459
|
const known = new Map(c.props.map((p) => [p.name, p]));
|
|
395
460
|
for (const a of el.attrs) {
|
|
396
461
|
const p = known.get(a.name);
|
|
397
|
-
if (!p) {
|
|
462
|
+
if (!p) { const u = unknownProp(c, a.name); if (u) (u.level === 'error' ? errors : warnings).push(at(u.message)); continue; }
|
|
398
463
|
if (p.values && ['string', 'number', 'boolean'].includes(a.kind) && !p.values.includes(a.value)) {
|
|
399
464
|
errors.push(at(`${a.name}=${JSON.stringify(a.value)} is not allowed. Use one of: ${p.values.map((x) => JSON.stringify(x)).join(', ')}.`));
|
|
400
465
|
}
|
|
466
|
+
{ const tooNew = propTooNew(c, p, a.kind === 'string' ? a.value : undefined); if (tooNew) errors.push(at(tooNew)); }
|
|
467
|
+
for (const m of checkShape(c, p, a, new Map(el.attrs.map((x) => [x.name, x])))) errors.push(at(m));
|
|
401
468
|
if (p.type === 'IconComponent') {
|
|
402
469
|
if (a.kind === 'string') errors.push(at(`"${a.name}" takes an icon component, not a string: ${a.name}={IconName}.`));
|
|
403
470
|
else if (a.kind === 'element' || a.kind === 'identifier') {
|
|
404
|
-
const e = iconProblem(a.name, a.raw, importedElsewhere);
|
|
471
|
+
const e = iconProblem(a.name, a.raw, importedElsewhere, localNames);
|
|
405
472
|
if (e) errors.push(at(e));
|
|
406
|
-
else if (a.kind === 'identifier' && iconNames.has(a.value) && importedFrom.
|
|
473
|
+
else if (a.kind === 'identifier' && iconNames.has(a.value) && !importedFrom.has(a.value) && !localNames.has(a.value)) warnings.push(at(`${a.value} is not imported. Add: import { ${a.value} } from '@sakaniui/react/icons';`));
|
|
407
474
|
}
|
|
408
475
|
}
|
|
409
476
|
}
|
|
@@ -414,6 +481,89 @@ function checkCode(code) {
|
|
|
414
481
|
return out;
|
|
415
482
|
}
|
|
416
483
|
|
|
484
|
+
/* ---------- literal shapes: field names inside inline object literals ---------- */
|
|
485
|
+
/** Splits `text` at top-level commas (ignores commas inside brackets, braces, parentheses and strings). */
|
|
486
|
+
function splitTopLevel(text) {
|
|
487
|
+
const parts = [];
|
|
488
|
+
let depth = 0, start = 0, quote = null;
|
|
489
|
+
for (let i = 0; i < text.length && parts.length < 2000; i++) {
|
|
490
|
+
const ch = text[i];
|
|
491
|
+
if (quote) { if (ch === '\\') i++; else if (ch === quote) quote = null; continue; }
|
|
492
|
+
if (ch === '"' || ch === "'" || ch === '`') { quote = ch; continue; }
|
|
493
|
+
if (ch === '{' || ch === '[' || ch === '(') depth++;
|
|
494
|
+
else if (ch === '}' || ch === ']' || ch === ')') depth--;
|
|
495
|
+
else if (ch === ',' && depth === 0) { parts.push(text.slice(start, i)); start = i + 1; }
|
|
496
|
+
}
|
|
497
|
+
parts.push(text.slice(start));
|
|
498
|
+
return parts.map((x) => x.trim()).filter(Boolean);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** The keys written in an object literal `{ a: 1, 'b': 2, c, ...d }`. spread: it also contains `...x`. */
|
|
502
|
+
function objectKeys(literal) {
|
|
503
|
+
const inner = literal.trim().slice(1, -1);
|
|
504
|
+
const keys = []; let spread = false;
|
|
505
|
+
for (const entry of splitTopLevel(inner)) {
|
|
506
|
+
if (entry.startsWith('...')) { spread = true; continue; }
|
|
507
|
+
const m = /^(?:'([^']+)'|"([^"]+)"|([A-Za-z_$][\w$]*))\s*(?::|\(|$)/.exec(entry);
|
|
508
|
+
if (m) keys.push(m[1] || m[2] || m[3]);
|
|
509
|
+
else spread = true; // computed key or something we cannot read: do not judge this object
|
|
510
|
+
}
|
|
511
|
+
return { keys, spread };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** The object literals in an inline `[ {...}, {...} ]` (or a single `{...}`). Null when it is not an inline literal. */
|
|
515
|
+
function inlineObjects(raw, collection) {
|
|
516
|
+
const t = raw.trim();
|
|
517
|
+
if (collection === 'array') {
|
|
518
|
+
if (!(t.startsWith('[') && t.endsWith(']'))) return null;
|
|
519
|
+
const items = splitTopLevel(t.slice(1, -1));
|
|
520
|
+
if (items.some((x) => !x.startsWith('{') || !x.endsWith('}'))) return items.filter((x) => x.startsWith('{') && x.endsWith('}')).length ? items.filter((x) => x.startsWith('{') && x.endsWith('}')) : null;
|
|
521
|
+
return items;
|
|
522
|
+
}
|
|
523
|
+
return t.startsWith('{') && t.endsWith('}') ? [t] : null;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Checks inline object literals against the prop's `shape`. Returns messages. */
|
|
527
|
+
function checkShape(c, prop, attr, siblings) {
|
|
528
|
+
const sh = prop.shape;
|
|
529
|
+
if (!sh || attr.kind !== 'expression' || !attr.raw) return [];
|
|
530
|
+
const objs = inlineObjects(attr.raw, sh.collection);
|
|
531
|
+
if (!objs || !objs.length) return [];
|
|
532
|
+
const out = [];
|
|
533
|
+
const known = new Map(sh.fields.map((f) => [f.name, f]));
|
|
534
|
+
const list = sh.fields.map((f) => f.name + (f.required ? '' : '?')).join(', ');
|
|
535
|
+
objs.forEach((lit, idx) => {
|
|
536
|
+
const { keys, spread } = objectKeys(lit);
|
|
537
|
+
const where = sh.collection === 'array' ? `${prop.name}[${idx}]` : prop.name;
|
|
538
|
+
for (const k of keys) {
|
|
539
|
+
if (!known.has(k)) {
|
|
540
|
+
const near = sh.fields.map((f) => f.name).filter((f) => similar(f, k));
|
|
541
|
+
out.push(`${where}: "${k}" is not a field of ${sh.type}${near.length ? ` (did you mean "${near[0]}"?)` : ''}. Fields: ${list}.`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (!spread) for (const f of sh.fields) if (f.required && !keys.includes(f.name)) out.push(`${where}: missing required field "${f.name}" of ${sh.type}. Fields: ${list}.`);
|
|
545
|
+
});
|
|
546
|
+
// `key: keyof T`: the key must be a field of the rows given next to it (T[] sibling), when both are written inline
|
|
547
|
+
const keyField = sh.fields.find((f) => /^keyof T\b/.test(f.type));
|
|
548
|
+
if (keyField && sh.typeParameters && sh.typeParameters[0] === 'T') {
|
|
549
|
+
const rowsProp = c.props.find((p) => p.type === 'T[]');
|
|
550
|
+
const rowsAttr = rowsProp && siblings.get(rowsProp.name);
|
|
551
|
+
const rowObjs = rowsAttr && rowsAttr.kind === 'expression' ? inlineObjects(rowsAttr.raw || '', 'array') : null;
|
|
552
|
+
if (rowObjs && rowObjs.length) {
|
|
553
|
+
const rowKeys = new Set();
|
|
554
|
+
let rowsOpen = false;
|
|
555
|
+
for (const r of rowObjs) { const o = objectKeys(r); o.keys.forEach((k) => rowKeys.add(k)); if (o.spread) rowsOpen = true; }
|
|
556
|
+
if (!rowsOpen) {
|
|
557
|
+
for (const lit of objs) {
|
|
558
|
+
const kv = new RegExp(`(?:^|[{,\\s])${keyField.name}\\s*:\\s*['"]([^'"]+)['"]`).exec(lit);
|
|
559
|
+
if (kv && !rowKeys.has(kv[1])) out.push(`${prop.name}: ${keyField.name} "${kv[1]}" is not a field of the ${rowsProp.name} (${[...rowKeys].join(', ')}). It must be a key of the row type; for a column that is not data, add an optional field to the row type (see the recipe in get_component ${c.name}).`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return out;
|
|
565
|
+
}
|
|
566
|
+
|
|
417
567
|
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
418
568
|
|
|
419
569
|
/* ---------- tools ---------- */
|
|
@@ -446,9 +596,22 @@ const tools = {
|
|
|
446
596
|
const c = byName.get(n.value.toLowerCase());
|
|
447
597
|
if (!c) return fail(`No component named "${n.value}". Did you mean: ${closest(n.value).join(', ') || '(none close)'}? Use search_components.`);
|
|
448
598
|
const out = { ...c, note: 'Native HTML attributes (className, style, aria-*, event handlers) are also accepted on the root element.' };
|
|
599
|
+
if (c.props.some((p) => p.shape)) out.shapeNote = 'A prop with a `shape` takes an object (or an array of objects) with exactly those fields; `required` fields must be present. In a generic shape, `keyof T & string` means the value must be a field of your own data type.';
|
|
600
|
+
if (c.recipes && c.recipes.length) out.recipeNote = 'See `recipes`: patterns the types alone do not teach, compiled against this release.';
|
|
449
601
|
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
602
|
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.';
|
|
451
603
|
if (inInstalled(c) === false) out.availableInInstalled = false;
|
|
604
|
+
if (projectVersion) {
|
|
605
|
+
out.props = c.props.map((p) => {
|
|
606
|
+
const tooNew = propTooNew(c, p);
|
|
607
|
+
const newerValues = p.valuesSince ? Object.entries(p.valuesSince).filter(([, v]) => cmp(v, projectVersion) > 0).map(([k]) => k) : [];
|
|
608
|
+
if (!tooNew && !newerValues.length) return p;
|
|
609
|
+
const q = { ...p };
|
|
610
|
+
if (tooNew) q.availableInInstalled = false;
|
|
611
|
+
if (newerValues.length) q.valuesNotInInstalled = newerValues;
|
|
612
|
+
return q;
|
|
613
|
+
});
|
|
614
|
+
}
|
|
452
615
|
const note = versionNote();
|
|
453
616
|
if (note) out.versionNote = note;
|
|
454
617
|
if (inInstalled(c) === false) out.warning = `${c.name} was added in ${c.since} but the project has ${projectVersion}. Do not use it without upgrading (npm install @sakaniui/react@latest).`;
|
|
@@ -505,11 +668,12 @@ const tools = {
|
|
|
505
668
|
for (const r of c.contract.requiredProps) if (!Object.hasOwn(props, r)) errors.push(`Missing required prop "${r}".`);
|
|
506
669
|
for (const [k, v] of Object.entries(props)) {
|
|
507
670
|
const p = known.get(k);
|
|
508
|
-
if (!p) {
|
|
671
|
+
if (!p) { const u = unknownProp(c, k); if (u) (u.level === 'error' ? errors : warnings).push(u.message); continue; }
|
|
509
672
|
if (p.values && (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') && !p.values.includes(v)) {
|
|
510
673
|
errors.push(`"${k}" = ${JSON.stringify(v)} is not allowed. Use one of: ${p.values.map((x) => JSON.stringify(x)).join(', ')}.`);
|
|
511
674
|
}
|
|
512
675
|
if (p.type === 'IconComponent' && typeof v === 'string') { const e = iconProblem(k, v); if (e) errors.push(e); }
|
|
676
|
+
const tooNew = propTooNew(c, p, v); if (tooNew) errors.push(tooNew);
|
|
513
677
|
}
|
|
514
678
|
if (c.contract.requiresAccessibleName && !errors.length) warnings.push('This control has no visible text of its own: keep its accessible-name prop set.');
|
|
515
679
|
const out = { component: c.name, ok: errors.length === 0, errors, warnings };
|
|
@@ -580,9 +744,10 @@ const tools = {
|
|
|
580
744
|
},
|
|
581
745
|
get_guide: {
|
|
582
746
|
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.',
|
|
583
|
-
inputSchema: { type: 'object', properties: { section: { type: 'string' } } },
|
|
747
|
+
inputSchema: { type: 'object', properties: { section: { type: 'string', description: 'A section number ("3") or words from its heading ("icons").' } } },
|
|
584
748
|
run(args) {
|
|
585
|
-
|
|
749
|
+
// agents sometimes call it with `query`; treat that as the section
|
|
750
|
+
const s0 = strArg(args.section === undefined && args.query !== undefined ? { section: args.query } : args, 'section'); if (s0.err) return fail(s0.err);
|
|
586
751
|
if (!guide) return fail('AGENTS.md is not available in this install.');
|
|
587
752
|
const parts = guide.split(/^(?=## )/m).filter((p) => p.startsWith('## '));
|
|
588
753
|
const heads = parts.map((p) => p.split('\n')[0].replace(/^## /, ''));
|
|
@@ -595,45 +760,71 @@ const tools = {
|
|
|
595
760
|
};
|
|
596
761
|
|
|
597
762
|
/* ---------- JSON-RPC over stdio ---------- */
|
|
598
|
-
|
|
763
|
+
// Protocol versions this server implements; the client's is used when it is one of them.
|
|
764
|
+
const PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
|
|
599
765
|
|
|
766
|
+
let stdoutClosed = false;
|
|
767
|
+
process.stdout.on('error', () => { stdoutClosed = true; process.exit(0); }); // the client went away (EPIPE)
|
|
768
|
+
const write = (payload) => { if (!stdoutClosed) process.stdout.write(JSON.stringify(payload) + '\n'); };
|
|
769
|
+
const reply = (id, body) => ({ jsonrpc: '2.0', id, ...body });
|
|
770
|
+
|
|
771
|
+
/** Handles one message. Returns the response, or null for notifications. */
|
|
600
772
|
function handle(msg) {
|
|
601
|
-
if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return
|
|
773
|
+
if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return reply(null, { error: { code: -32600, message: 'Invalid request' } });
|
|
602
774
|
const { id, method, params } = msg;
|
|
603
775
|
const isRequest = id !== undefined && id !== null;
|
|
776
|
+
if (typeof method !== 'string') return isRequest ? reply(id, { error: { code: -32600, message: 'Invalid request: method must be a string' } }) : null;
|
|
777
|
+
if (!isRequest) return null; // notifications (initialized, cancelled, ...) need no answer
|
|
604
778
|
try {
|
|
605
779
|
switch (method) {
|
|
606
780
|
case 'initialize': {
|
|
607
781
|
const note = versionNote();
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
782
|
+
const asked = params && typeof params.protocolVersion === 'string' ? params.protocolVersion : null;
|
|
783
|
+
return reply(id, { result: {
|
|
784
|
+
protocolVersion: asked && PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
|
|
785
|
+
capabilities: { tools: { listChanged: false } },
|
|
786
|
+
serverInfo: { name: 'sakaniui', title: 'SakaniUI', version: refVersion },
|
|
787
|
+
instructions: `SakaniUI ${refVersion}${projectVersion ? ` (project has ${projectVersion})` : ''}. Read-only reference. Look components up with search_components / get_component and start from their \`example\`; find icon names with search_icons (never guess them); run check_code on every snippet before you answer; style anything beyond props with the design tokens from get_tokens (CSS variables), not hard-coded colours or utility classes the project may not have; read get_guide when nothing fits.${note ? ` ${note}` : ''}`,
|
|
613
788
|
} });
|
|
614
789
|
}
|
|
615
790
|
case 'ping':
|
|
616
|
-
return
|
|
791
|
+
return reply(id, { result: {} });
|
|
617
792
|
case 'tools/list':
|
|
618
|
-
return
|
|
793
|
+
return reply(id, { result: { tools: Object.entries(tools).map(([name, t]) => ({ name, description: t.description, inputSchema: t.inputSchema, annotations: READ_ONLY })) } });
|
|
619
794
|
case 'tools/call': {
|
|
620
795
|
const t = typeof params?.name === 'string' && Object.hasOwn(tools, params.name) ? tools[params.name] : null;
|
|
621
|
-
if (!t) return
|
|
796
|
+
if (!t) return reply(id, { error: { code: -32602, message: `Unknown tool: ${String(params?.name).slice(0, 60)}` } });
|
|
622
797
|
const args = params.arguments;
|
|
623
|
-
if (args !== undefined && args !== null && (typeof args !== 'object' || Array.isArray(args))) return
|
|
624
|
-
return
|
|
798
|
+
if (args !== undefined && args !== null && (typeof args !== 'object' || Array.isArray(args))) return reply(id, { result: fail('Tool arguments must be an object.') });
|
|
799
|
+
return reply(id, { result: t.run(args || {}) });
|
|
625
800
|
}
|
|
801
|
+
// Some clients ask for these even when only tools are offered; an empty list is the polite answer.
|
|
802
|
+
case 'resources/list': return reply(id, { result: { resources: [] } });
|
|
803
|
+
case 'resources/templates/list': return reply(id, { result: { resourceTemplates: [] } });
|
|
804
|
+
case 'prompts/list': return reply(id, { result: { prompts: [] } });
|
|
805
|
+
case 'logging/setLevel': return reply(id, { result: {} });
|
|
626
806
|
default:
|
|
627
|
-
|
|
807
|
+
return reply(id, { error: { code: -32601, message: `Method not found: ${method.slice(0, 60)}` } });
|
|
628
808
|
}
|
|
629
809
|
} catch (e) {
|
|
630
|
-
|
|
810
|
+
return reply(id, { error: { code: -32603, message: String(e?.message || e) } });
|
|
631
811
|
}
|
|
632
812
|
}
|
|
633
813
|
|
|
634
|
-
readline.createInterface({ input: process.stdin
|
|
814
|
+
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
815
|
+
rl.on('line', (line) => {
|
|
635
816
|
if (!line.trim()) return;
|
|
636
817
|
let msg;
|
|
637
|
-
try { msg = JSON.parse(line); } catch { return
|
|
638
|
-
|
|
818
|
+
try { msg = JSON.parse(line); } catch { return write(reply(null, { error: { code: -32700, message: 'Parse error' } })); }
|
|
819
|
+
if (Array.isArray(msg)) { // a batch (older protocol versions): answer with one array
|
|
820
|
+
if (!msg.length) return write(reply(null, { error: { code: -32600, message: 'Invalid request: empty batch' } }));
|
|
821
|
+
const out = msg.map(handle).filter(Boolean);
|
|
822
|
+
if (out.length) write(out);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
const r = handle(msg);
|
|
826
|
+
if (r) write(r);
|
|
639
827
|
});
|
|
828
|
+
// the editor closed the connection: exit instead of lingering as an orphan process
|
|
829
|
+
// (flush first: on macOS and Windows, writes to a pipe are asynchronous)
|
|
830
|
+
rl.on('close', () => { if (stdoutClosed) process.exit(0); else process.stdout.write('', () => process.exit(0)); });
|