@grampro/kit 2.4.4 → 2.5.0
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/CHANGELOG.md +169 -0
- package/index.cjs +39 -4
- package/package.json +1 -1
- package/source/components/accordion/passport.json +11 -7
- package/source/components/accordion/react/Accordion.tsx +5 -0
- package/source/components/alert/passport.json +3 -3
- package/source/components/avatar/passport.json +8 -6
- package/source/components/avatar/react/Avatar.tsx +4 -0
- package/source/components/badge/passport.json +8 -6
- package/source/components/badge/react/Badge.tsx +1 -0
- package/source/components/badge/react/Tag.tsx +1 -0
- package/source/components/breadcrumb/passport.json +8 -6
- package/source/components/breadcrumb/react/Breadcrumb.tsx +1 -0
- package/source/components/button/passport.json +8 -6
- package/source/components/button/react/Button.tsx +1 -0
- package/source/components/card/passport.json +17 -9
- package/source/components/card/react/Card.tsx +5 -0
- package/source/components/card/react/Stat.tsx +5 -0
- package/source/components/checkbox/passport.json +32 -14
- package/source/components/checkbox/react/Checkbox.tsx +17 -0
- package/source/components/checkbox/react/CheckboxGroup.tsx +6 -0
- package/source/components/combobox/passport.json +36 -16
- package/source/components/combobox/react/Select.tsx +6 -1
- package/source/components/combobox/react/props.ts +23 -0
- package/source/components/data-grid/core/types.ts +53 -0
- package/source/components/data-grid/passport.json +82 -30
- package/source/components/data-grid/react/AskGrid.tsx +10 -0
- package/source/components/data-grid/react/DataGrid.tsx +16 -0
- package/source/components/date-picker/passport.json +30 -14
- package/source/components/date-picker/react/DatePicker.tsx +5 -0
- package/source/components/date-picker/react/props.ts +13 -1
- package/source/components/dialog/core/types.ts +14 -0
- package/source/components/dialog/passport.json +26 -12
- package/source/components/dialog/react/Dialog.tsx +4 -0
- package/source/components/file-uploader/passport.json +44 -18
- package/source/components/file-uploader/react/FileUploader.tsx +39 -0
- package/source/components/input/passport.json +14 -8
- package/source/components/input/react/Input.tsx +3 -0
- package/source/components/menu/passport.json +29 -13
- package/source/components/menu/react/Menu.tsx +7 -0
- package/source/components/menu/react/items.tsx +4 -0
- package/source/components/modal/passport.json +14 -8
- package/source/components/modal/react/Modal.tsx +6 -0
- package/source/components/number-input/passport.json +23 -11
- package/source/components/number-input/react/NumberInput.tsx +13 -0
- package/source/components/popover/passport.json +17 -9
- package/source/components/popover/react/Popover.tsx +7 -0
- package/source/components/progress/passport.json +3 -3
- package/source/components/radio-group/passport.json +32 -14
- package/source/components/radio-group/react/Radio.tsx +17 -0
- package/source/components/radio-group/react/RadioGroup.tsx +13 -0
- package/source/components/skeleton/passport.json +8 -6
- package/source/components/skeleton/react/Skeleton.tsx +5 -0
- package/source/components/spinner/passport.json +3 -3
- package/source/components/switch/passport.json +23 -11
- package/source/components/switch/react/Switch.tsx +14 -0
- package/source/components/tabs/passport.json +11 -7
- package/source/components/tabs/react/Tabs.tsx +2 -0
- package/source/components/textarea/passport.json +14 -8
- package/source/components/textarea/react/Textarea.tsx +3 -0
- package/source/components/toaster/passport.json +8 -6
- package/source/components/toaster/react/Toaster.tsx +4 -0
- package/source/components/tooltip/passport.json +3 -3
- package/tools/mcp/README.md +126 -34
- package/tools/mcp/figma.cjs +84 -27
- package/tools/mcp/install.cjs +270 -0
- package/tools/mcp/secret-cli.cjs +174 -0
- package/tools/mcp/secret.cjs +289 -0
- package/tools/mcp/server.cjs +53 -14
- package/tools/passport/cli.cjs +8 -1
- package/tools/passport/extract.cjs +7 -3
- package/tools/passport/merge.cjs +24 -4
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Credentials, kept by the operating system instead of by the repository.
|
|
3
|
+
*
|
|
4
|
+
* The Figma token used to live in a `.env`, which meant a long-lived
|
|
5
|
+
* credential sitting in a file inside a git working tree — one `git add -A`
|
|
6
|
+
* away from being published. This moves it to whatever the platform already
|
|
7
|
+
* uses for secrets: the Windows Data Protection API, the macOS keychain, or
|
|
8
|
+
* the Secret Service on Linux. Each of those ties the ciphertext to the
|
|
9
|
+
* logged-in user, so a copied file is useless on another machine.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here adds a dependency. Every backend is a command the OS ships
|
|
12
|
+
* with, and the fallback is a 0600 file — worse, but still outside the repo,
|
|
13
|
+
* and it says so rather than pretending otherwise.
|
|
14
|
+
*/
|
|
15
|
+
"use strict";
|
|
16
|
+
|
|
17
|
+
const fs = require("fs");
|
|
18
|
+
const os = require("os");
|
|
19
|
+
const path = require("path");
|
|
20
|
+
const { spawnSync } = require("child_process");
|
|
21
|
+
|
|
22
|
+
/** Namespace for keychain/Secret-Service entries. */
|
|
23
|
+
const SERVICE = "grampro-kit";
|
|
24
|
+
|
|
25
|
+
/** Per-user config directory, following each platform's own convention. */
|
|
26
|
+
function configDir() {
|
|
27
|
+
if (process.platform === "win32") {
|
|
28
|
+
return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), SERVICE);
|
|
29
|
+
}
|
|
30
|
+
if (process.platform === "darwin") {
|
|
31
|
+
return path.join(os.homedir(), "Library", "Application Support", SERVICE);
|
|
32
|
+
}
|
|
33
|
+
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), SERVICE);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True when a command exists and runs. */
|
|
37
|
+
function available(command, args) {
|
|
38
|
+
try {
|
|
39
|
+
const probe = spawnSync(command, args, { stdio: "ignore", windowsHide: true });
|
|
40
|
+
return !probe.error;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --------------------------------------------------------------- windows
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
* DPAPI, reached through PowerShell because Node has no binding for it.
|
|
50
|
+
*
|
|
51
|
+
* The plaintext goes in over stdin, never as an argument — command lines are
|
|
52
|
+
* readable by other processes on the machine.
|
|
53
|
+
*
|
|
54
|
+
* The script is passed with `-Command` rather than `-EncodedCommand`, which
|
|
55
|
+
* accepts the base64 and then silently runs nothing on at least one shipped
|
|
56
|
+
* 5.1 build (5.1.22621): exit 0, no output, no error. Statements are joined
|
|
57
|
+
* with semicolons for that reason, and quoting is kept to single quotes so
|
|
58
|
+
* nothing needs escaping between here and the shell.
|
|
59
|
+
*/
|
|
60
|
+
function powershell(script, input) {
|
|
61
|
+
const run = spawnSync(
|
|
62
|
+
"powershell.exe",
|
|
63
|
+
["-NoProfile", "-NonInteractive", "-Command", script],
|
|
64
|
+
{ input: input === undefined ? "" : input, encoding: "utf8", windowsHide: true },
|
|
65
|
+
);
|
|
66
|
+
if (run.error) throw run.error;
|
|
67
|
+
if (run.status !== 0) {
|
|
68
|
+
throw new Error((run.stderr || "PowerShell failed").trim().split("\n")[0]);
|
|
69
|
+
}
|
|
70
|
+
return run.stdout;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const DPAPI_PROTECT = [
|
|
74
|
+
"Add-Type -AssemblyName System.Security",
|
|
75
|
+
"$plain = [Console]::In.ReadToEnd()",
|
|
76
|
+
"$bytes = [System.Text.Encoding]::UTF8.GetBytes($plain)",
|
|
77
|
+
"[Convert]::ToBase64String([System.Security.Cryptography.ProtectedData]::Protect($bytes, $null, 'CurrentUser'))",
|
|
78
|
+
].join("; ");
|
|
79
|
+
|
|
80
|
+
const DPAPI_UNPROTECT = [
|
|
81
|
+
"Add-Type -AssemblyName System.Security",
|
|
82
|
+
"$sealed = [Convert]::FromBase64String([Console]::In.ReadToEnd().Trim())",
|
|
83
|
+
"[System.Text.Encoding]::UTF8.GetString([System.Security.Cryptography.ProtectedData]::Unprotect($sealed, $null, 'CurrentUser'))",
|
|
84
|
+
].join("; ");
|
|
85
|
+
|
|
86
|
+
const dpapiFile = (name) => path.join(configDir(), "secrets", `${name}.dpapi`);
|
|
87
|
+
|
|
88
|
+
const dpapi = {
|
|
89
|
+
label: "Windows Data Protection API (tied to your Windows account)",
|
|
90
|
+
set(name, value) {
|
|
91
|
+
// PowerShell may fold a long string across lines when there is no console
|
|
92
|
+
// to measure, and base64 does not survive that. Whitespace is dropped on
|
|
93
|
+
// the way in and on the way out.
|
|
94
|
+
const sealed = powershell(DPAPI_PROTECT, value).replace(/\s+/g, "");
|
|
95
|
+
if (!sealed) throw new Error("DPAPI returned nothing; the secret was not stored.");
|
|
96
|
+
const file = dpapiFile(name);
|
|
97
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
98
|
+
fs.writeFileSync(file, sealed, { encoding: "utf8", mode: 0o600 });
|
|
99
|
+
},
|
|
100
|
+
get(name) {
|
|
101
|
+
const file = dpapiFile(name);
|
|
102
|
+
if (!fs.existsSync(file)) return null;
|
|
103
|
+
// A file carried over from another account will not decrypt; that is the
|
|
104
|
+
// protection working, so it reads as absent rather than as an error.
|
|
105
|
+
try {
|
|
106
|
+
const sealed = fs.readFileSync(file, "utf8").replace(/\s+/g, "");
|
|
107
|
+
return powershell(DPAPI_UNPROTECT, sealed).trim() || null;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
remove(name) {
|
|
113
|
+
const file = dpapiFile(name);
|
|
114
|
+
if (!fs.existsSync(file)) return false;
|
|
115
|
+
fs.unlinkSync(file);
|
|
116
|
+
return true;
|
|
117
|
+
},
|
|
118
|
+
list() {
|
|
119
|
+
const dir = path.join(configDir(), "secrets");
|
|
120
|
+
if (!fs.existsSync(dir)) return [];
|
|
121
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith(".dpapi")).map((f) => f.slice(0, -6));
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// ----------------------------------------------------------------- macos
|
|
126
|
+
|
|
127
|
+
/*
|
|
128
|
+
* The keychain. `security` takes the password as an argument, which is
|
|
129
|
+
* visible in `ps` for the moment the command runs — a real if narrow
|
|
130
|
+
* exposure, and the alternative is an interactive prompt that cannot work
|
|
131
|
+
* from a tool. Any process able to read that argument is already running as
|
|
132
|
+
* this user and could ask the keychain directly.
|
|
133
|
+
*/
|
|
134
|
+
const keychain = {
|
|
135
|
+
label: "macOS keychain",
|
|
136
|
+
set(name, value) {
|
|
137
|
+
const run = spawnSync(
|
|
138
|
+
"security",
|
|
139
|
+
["add-generic-password", "-U", "-a", os.userInfo().username, "-s", `${SERVICE}.${name}`,
|
|
140
|
+
"-D", "api token", "-j", "GramproKit", "-w", value],
|
|
141
|
+
{ encoding: "utf8" },
|
|
142
|
+
);
|
|
143
|
+
if (run.status !== 0) throw new Error((run.stderr || "security failed").trim());
|
|
144
|
+
},
|
|
145
|
+
get(name) {
|
|
146
|
+
const run = spawnSync(
|
|
147
|
+
"security",
|
|
148
|
+
["find-generic-password", "-a", os.userInfo().username, "-s", `${SERVICE}.${name}`, "-w"],
|
|
149
|
+
{ encoding: "utf8" },
|
|
150
|
+
);
|
|
151
|
+
return run.status === 0 ? run.stdout.trim() || null : null;
|
|
152
|
+
},
|
|
153
|
+
remove(name) {
|
|
154
|
+
const run = spawnSync(
|
|
155
|
+
"security",
|
|
156
|
+
["delete-generic-password", "-a", os.userInfo().username, "-s", `${SERVICE}.${name}`],
|
|
157
|
+
{ encoding: "utf8", stdio: "ignore" },
|
|
158
|
+
);
|
|
159
|
+
return run.status === 0;
|
|
160
|
+
},
|
|
161
|
+
/* The keychain has no cheap listing by service, so the known names are probed. */
|
|
162
|
+
list() {
|
|
163
|
+
return KNOWN.filter((name) => keychain.get(name) !== null);
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// ----------------------------------------------------------------- linux
|
|
168
|
+
|
|
169
|
+
/** libsecret's CLI, which every desktop keyring implements. */
|
|
170
|
+
const secretTool = {
|
|
171
|
+
label: "Secret Service (GNOME Keyring, KWallet)",
|
|
172
|
+
set(name, value) {
|
|
173
|
+
const run = spawnSync(
|
|
174
|
+
"secret-tool",
|
|
175
|
+
["store", "--label", `GramproKit ${name}`, "service", SERVICE, "account", name],
|
|
176
|
+
{ input: value, encoding: "utf8" },
|
|
177
|
+
);
|
|
178
|
+
if (run.status !== 0) throw new Error((run.stderr || "secret-tool failed").trim());
|
|
179
|
+
},
|
|
180
|
+
get(name) {
|
|
181
|
+
const run = spawnSync("secret-tool", ["lookup", "service", SERVICE, "account", name], {
|
|
182
|
+
encoding: "utf8",
|
|
183
|
+
});
|
|
184
|
+
return run.status === 0 ? run.stdout.trim() || null : null;
|
|
185
|
+
},
|
|
186
|
+
remove(name) {
|
|
187
|
+
const run = spawnSync("secret-tool", ["clear", "service", SERVICE, "account", name], {
|
|
188
|
+
encoding: "utf8",
|
|
189
|
+
stdio: "ignore",
|
|
190
|
+
});
|
|
191
|
+
return run.status === 0;
|
|
192
|
+
},
|
|
193
|
+
list() {
|
|
194
|
+
return KNOWN.filter((name) => secretTool.get(name) !== null);
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// -------------------------------------------------------------- fallback
|
|
199
|
+
|
|
200
|
+
/*
|
|
201
|
+
* A 0600 file under the user's config directory. Not encrypted, and it says
|
|
202
|
+
* so when used — but it is outside the repository, which is the failure this
|
|
203
|
+
* whole module exists to prevent.
|
|
204
|
+
*/
|
|
205
|
+
const plainFile = {
|
|
206
|
+
label: "a 0600 file in your config directory (not encrypted)",
|
|
207
|
+
file: () => path.join(configDir(), "secrets.json"),
|
|
208
|
+
read() {
|
|
209
|
+
try {
|
|
210
|
+
return JSON.parse(fs.readFileSync(plainFile.file(), "utf8"));
|
|
211
|
+
} catch {
|
|
212
|
+
return {};
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
write(all) {
|
|
216
|
+
fs.mkdirSync(configDir(), { recursive: true });
|
|
217
|
+
fs.writeFileSync(plainFile.file(), `${JSON.stringify(all, null, 2)}\n`, {
|
|
218
|
+
encoding: "utf8",
|
|
219
|
+
mode: 0o600,
|
|
220
|
+
});
|
|
221
|
+
},
|
|
222
|
+
set(name, value) {
|
|
223
|
+
const all = plainFile.read();
|
|
224
|
+
all[name] = value;
|
|
225
|
+
plainFile.write(all);
|
|
226
|
+
},
|
|
227
|
+
get(name) {
|
|
228
|
+
return plainFile.read()[name] || null;
|
|
229
|
+
},
|
|
230
|
+
remove(name) {
|
|
231
|
+
const all = plainFile.read();
|
|
232
|
+
if (!(name in all)) return false;
|
|
233
|
+
delete all[name];
|
|
234
|
+
plainFile.write(all);
|
|
235
|
+
return true;
|
|
236
|
+
},
|
|
237
|
+
list() {
|
|
238
|
+
return Object.keys(plainFile.read());
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/** Secret names this package uses, for the backends that cannot enumerate. */
|
|
243
|
+
const KNOWN = ["figma"];
|
|
244
|
+
|
|
245
|
+
/** The best store this machine offers. Resolved once. */
|
|
246
|
+
let chosen = null;
|
|
247
|
+
function store() {
|
|
248
|
+
if (chosen) return chosen;
|
|
249
|
+
if (process.platform === "win32" && available("powershell.exe", ["-NoProfile", "-Command", "exit 0"])) {
|
|
250
|
+
chosen = dpapi;
|
|
251
|
+
} else if (process.platform === "darwin" && available("security", ["-h"])) {
|
|
252
|
+
chosen = keychain;
|
|
253
|
+
} else if (process.platform === "linux" && available("secret-tool", ["--version"])) {
|
|
254
|
+
chosen = secretTool;
|
|
255
|
+
} else {
|
|
256
|
+
chosen = plainFile;
|
|
257
|
+
}
|
|
258
|
+
return chosen;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const setSecret = (name, value) => store().set(name, value);
|
|
262
|
+
const removeSecret = (name) => store().remove(name);
|
|
263
|
+
const listSecrets = () => store().list();
|
|
264
|
+
const describeStore = () => store().label;
|
|
265
|
+
const isEncrypted = () => store() !== plainFile;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* A secret, if this machine holds one. Never throws: a missing or unreadable
|
|
269
|
+
* store is the same answer as an empty one, because every caller has another
|
|
270
|
+
* place to look.
|
|
271
|
+
*/
|
|
272
|
+
function getSecret(name) {
|
|
273
|
+
try {
|
|
274
|
+
return store().get(name);
|
|
275
|
+
} catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
module.exports = {
|
|
281
|
+
setSecret,
|
|
282
|
+
getSecret,
|
|
283
|
+
removeSecret,
|
|
284
|
+
listSecrets,
|
|
285
|
+
describeStore,
|
|
286
|
+
isEncrypted,
|
|
287
|
+
configDir,
|
|
288
|
+
KNOWN,
|
|
289
|
+
};
|
package/tools/mcp/server.cjs
CHANGED
|
@@ -36,32 +36,69 @@ const SUPPORTED_PROTOCOLS = new Set(["2024-11-05", "2025-03-26", "2025-06-18", "
|
|
|
36
36
|
|
|
37
37
|
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
/*
|
|
40
|
+
* The catalogue, read once.
|
|
41
|
+
*
|
|
42
|
+
* Every lookup used to re-read and re-parse passports off disk: naming one
|
|
43
|
+
* component scanned until it matched, so `get_component("DataGrid")` cost ten
|
|
44
|
+
* file reads, and `validate_usage` parsed all twenty-seven on every call. The
|
|
45
|
+
* catalogue ships inside the package and cannot change while the server runs,
|
|
46
|
+
* so it is built once on first use and kept.
|
|
47
|
+
*/
|
|
48
|
+
let index = null;
|
|
49
|
+
|
|
50
|
+
function catalogue() {
|
|
51
|
+
if (index) return index;
|
|
52
|
+
|
|
53
|
+
const folders = fs
|
|
41
54
|
.readdirSync(CATALOGUE, { withFileTypes: true })
|
|
42
55
|
.filter((e) => e.isDirectory() && fs.existsSync(path.join(CATALOGUE, e.name, "passport.json")))
|
|
43
56
|
.map((e) => e.name)
|
|
44
57
|
.sort();
|
|
45
58
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
for (const folder of
|
|
50
|
-
|
|
59
|
+
const byFolder = new Map();
|
|
60
|
+
const byName = new Map();
|
|
61
|
+
|
|
62
|
+
for (const folder of folders) {
|
|
63
|
+
// The one place that still touches disk — this is what builds the index.
|
|
51
64
|
const passport = readJson(path.join(CATALOGUE, folder, "passport.json"));
|
|
52
|
-
const
|
|
53
|
-
|
|
65
|
+
const entry = { folder, passport };
|
|
66
|
+
byFolder.set(folder, entry);
|
|
67
|
+
|
|
68
|
+
// Every spelling an agent might reasonably use.
|
|
69
|
+
const spellings = [folder, passport.identity.component, ...(passport.identity.exports || [])];
|
|
70
|
+
for (const spelling of spellings) {
|
|
71
|
+
byName.set(String(spelling).toLowerCase().replace(/[^a-z0-9]/g, ""), entry);
|
|
72
|
+
}
|
|
54
73
|
}
|
|
55
|
-
|
|
74
|
+
|
|
75
|
+
index = { folders, byFolder, byName };
|
|
76
|
+
return index;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const componentFolders = () => catalogue().folders;
|
|
80
|
+
|
|
81
|
+
/** Resolve a name the way an agent would write it: "DataGrid", "data-grid", "datagrid". */
|
|
82
|
+
function findFolder(name) {
|
|
83
|
+
const entry = catalogue().byName.get(
|
|
84
|
+
String(name || "").toLowerCase().replace(/[^a-z0-9]/g, ""),
|
|
85
|
+
);
|
|
86
|
+
return entry ? entry.folder : null;
|
|
56
87
|
}
|
|
57
88
|
|
|
89
|
+
/** The parsed passport for a folder, from the index rather than from disk. */
|
|
90
|
+
const passportOf = (folder) => catalogue().byFolder.get(folder).passport;
|
|
91
|
+
|
|
58
92
|
/*
|
|
59
93
|
* Most passports carry no `purpose`, so the README's opening line stands in.
|
|
60
94
|
* It is the sentence a human wrote about what the component is for, which is
|
|
61
95
|
* exactly what an agent needs to pick one.
|
|
62
96
|
*/
|
|
97
|
+
const purposeCache = new Map();
|
|
98
|
+
|
|
63
99
|
function purposeOf(folder, passport) {
|
|
64
100
|
if (passport.purpose && passport.purpose.trim()) return passport.purpose.trim();
|
|
101
|
+
if (purposeCache.has(folder)) return purposeCache.get(folder);
|
|
65
102
|
const readme = path.join(CATALOGUE, folder, "README.md");
|
|
66
103
|
if (!fs.existsSync(readme)) return "";
|
|
67
104
|
const line = fs
|
|
@@ -69,14 +106,16 @@ function purposeOf(folder, passport) {
|
|
|
69
106
|
.split("\n")
|
|
70
107
|
.map((l) => l.trim())
|
|
71
108
|
.find((l) => l && !l.startsWith("#") && !l.startsWith("<!--"));
|
|
72
|
-
|
|
109
|
+
const purpose = line ? line.replace(/\s+/g, " ") : "";
|
|
110
|
+
purposeCache.set(folder, purpose);
|
|
111
|
+
return purpose;
|
|
73
112
|
}
|
|
74
113
|
|
|
75
114
|
// -------------------------------------------------------------------- tools
|
|
76
115
|
|
|
77
116
|
function listComponents() {
|
|
78
117
|
return componentFolders().map((folder) => {
|
|
79
|
-
const passport =
|
|
118
|
+
const passport = passportOf(folder);
|
|
80
119
|
return {
|
|
81
120
|
component: passport.identity.component,
|
|
82
121
|
folder,
|
|
@@ -98,7 +137,7 @@ function getComponent(name) {
|
|
|
98
137
|
if (!folder) {
|
|
99
138
|
return { error: `No component named "${name}". Call list_components for the catalogue.` };
|
|
100
139
|
}
|
|
101
|
-
const p =
|
|
140
|
+
const p = passportOf(folder);
|
|
102
141
|
return {
|
|
103
142
|
component: p.identity.component,
|
|
104
143
|
folder,
|
|
@@ -163,7 +202,7 @@ function validateUsage(code) {
|
|
|
163
202
|
const source = String(code || "");
|
|
164
203
|
const known = new Map();
|
|
165
204
|
for (const folder of componentFolders()) {
|
|
166
|
-
const p =
|
|
205
|
+
const p = passportOf(folder);
|
|
167
206
|
const props = new Set((p.props || []).map((x) => x.name));
|
|
168
207
|
for (const e of p.events || []) props.add(e.name);
|
|
169
208
|
const required = (p.props || []).filter((x) => x.required).map((x) => x.name);
|
package/tools/passport/cli.cjs
CHANGED
|
@@ -15,7 +15,14 @@ const { generate, check } = require("./index.cjs");
|
|
|
15
15
|
|
|
16
16
|
const REPO = path.resolve(__dirname, "..", "..");
|
|
17
17
|
const LIB = path.join(REPO, "source", "components");
|
|
18
|
-
|
|
18
|
+
/*
|
|
19
|
+
* Ratchet upward over time. 40 was set when coverage counted every inherited
|
|
20
|
+
* HTML attribute in the denominator, which capped some components near 35% and
|
|
21
|
+
* made the gate unreachable by doing the work it asked for. Now that it
|
|
22
|
+
* measures the props this library authors, the library sits at 87% and the
|
|
23
|
+
* weakest component at 75, so this is the floor rather than an aspiration.
|
|
24
|
+
*/
|
|
25
|
+
const COVERAGE_THRESHOLD = 75;
|
|
19
26
|
|
|
20
27
|
const args = process.argv.slice(2);
|
|
21
28
|
const wantCheck = args.includes("--check");
|
|
@@ -16,6 +16,7 @@ const fs = require("fs");
|
|
|
16
16
|
const path = require("path");
|
|
17
17
|
const crypto = require("crypto");
|
|
18
18
|
const { allowlistFor } = require("./policy.cjs");
|
|
19
|
+
const { describable } = require("./merge.cjs");
|
|
19
20
|
|
|
20
21
|
const PASSPORT_VERSION = "1.0.0";
|
|
21
22
|
|
|
@@ -435,7 +436,10 @@ function extractComponent(ts, program, checker, { name, dir, libRoot, libraryVer
|
|
|
435
436
|
.filter((p) => /^on[A-Z]/.test(p.name))
|
|
436
437
|
.map((p) => ({ name: p.name, ...(p.signature ? { signature: p.signature } : {}) }));
|
|
437
438
|
|
|
438
|
-
|
|
439
|
+
// Same rule as the merge step, so the two never disagree: coverage is over
|
|
440
|
+
// the props this library declares, not the HTML attributes it inherits.
|
|
441
|
+
const countable = propList.filter(describable);
|
|
442
|
+
const described = countable.filter((p) => p.description).length;
|
|
439
443
|
|
|
440
444
|
const passport = {
|
|
441
445
|
passportVersion: PASSPORT_VERSION,
|
|
@@ -463,9 +467,9 @@ function extractComponent(ts, program, checker, { name, dir, libRoot, libraryVer
|
|
|
463
467
|
safeMutations: {},
|
|
464
468
|
examples: [],
|
|
465
469
|
coverage: {
|
|
466
|
-
propsTotal:
|
|
470
|
+
propsTotal: countable.length,
|
|
467
471
|
propsDescribed: described,
|
|
468
|
-
describedPct:
|
|
472
|
+
describedPct: countable.length ? Math.round((described / countable.length) * 100) : 0,
|
|
469
473
|
authoredSections: [],
|
|
470
474
|
},
|
|
471
475
|
};
|
package/tools/passport/merge.cjs
CHANGED
|
@@ -72,6 +72,25 @@ function mergeProps(derived, overlayProps, origin) {
|
|
|
72
72
|
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/*
|
|
76
|
+
* Props whose meaning is the same on every component in the library, and so
|
|
77
|
+
* belong in one page of conventions rather than in 27 passports. A one-line
|
|
78
|
+
* gloss on each would be filler, and filler in a passport is worse than
|
|
79
|
+
* silence: an agent reads it as information.
|
|
80
|
+
*/
|
|
81
|
+
const PLUMBING = new Set(["className", "classNames", "style", "ref", "key", "localeText"]);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Whether a prop counts towards description coverage.
|
|
85
|
+
*
|
|
86
|
+
* Only what this library declares itself. An inherited prop is `aria-label`
|
|
87
|
+
* or `tabIndex` arriving through `extends ButtonHTMLAttributes` - documented
|
|
88
|
+
* by the HTML specification, and not ours to restate. Counting them put the
|
|
89
|
+
* reachable ceiling for some components near 35%, so the gate asked for work
|
|
90
|
+
* that could not move it.
|
|
91
|
+
*/
|
|
92
|
+
const describable = (prop) => prop.origin === "derived" && !PLUMBING.has(prop.name);
|
|
93
|
+
|
|
75
94
|
/**
|
|
76
95
|
* Produce the effective passport written to passport.json.
|
|
77
96
|
*
|
|
@@ -112,20 +131,21 @@ function mergePassport(derived, manual = {}, local = {}) {
|
|
|
112
131
|
}
|
|
113
132
|
|
|
114
133
|
// Coverage is recomputed after overlays, since manual descriptions count.
|
|
115
|
-
const
|
|
134
|
+
const counted = out.props.filter(describable);
|
|
135
|
+
const described = counted.filter((p) => p.description).length;
|
|
116
136
|
const authoredSections = [...AUTHORED].filter((k) => {
|
|
117
137
|
const v = out[k];
|
|
118
138
|
return Array.isArray(v) ? v.length > 0 : isObject(v) ? Object.keys(v).length > 0 : Boolean(v);
|
|
119
139
|
}).sort();
|
|
120
140
|
|
|
121
141
|
out.coverage = {
|
|
122
|
-
propsTotal:
|
|
142
|
+
propsTotal: counted.length,
|
|
123
143
|
propsDescribed: described,
|
|
124
|
-
describedPct:
|
|
144
|
+
describedPct: counted.length ? Math.round((described / counted.length) * 100) : 0,
|
|
125
145
|
authoredSections,
|
|
126
146
|
};
|
|
127
147
|
|
|
128
148
|
return out;
|
|
129
149
|
}
|
|
130
150
|
|
|
131
|
-
module.exports = { mergePassport, deepMerge, mergeProps, SOURCE_OWNED, AUTHORED, UNSAFE_KEYS };
|
|
151
|
+
module.exports = { mergePassport, deepMerge, mergeProps, describable, SOURCE_OWNED, AUTHORED, UNSAFE_KEYS };
|