@starklab/stark-mcp 0.1.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/LICENSE +21 -0
- package/README.md +108 -0
- package/package.json +31 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Home.jsx +21 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Menu.jsx +13 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/pages/Profile.jsx +11 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/theme.css +34 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/AppButton.jsx +8 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/BrandButton.jsx +9 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/CardBase.jsx +9 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/FeatureCard.jsx +7 -0
- package/src/adopt/__fixtures__/dominion-fixture-app/src/wrappers/SectionCard.jsx +12 -0
- package/src/adopt/catalog.js +88 -0
- package/src/adopt/dominionFixture.test.js +165 -0
- package/src/adopt/moduleGraph.js +232 -0
- package/src/adopt/parseSource.js +25 -0
- package/src/adopt/propApiResolver.js +278 -0
- package/src/adopt/propApiResolver.test.js +229 -0
- package/src/adopt/referenceResolver.js +151 -0
- package/src/adopt/referenceResolver.test.js +213 -0
- package/src/adopt/rnTailwindResolver.js +347 -0
- package/src/adopt/rnTailwindResolver.test.js +263 -0
- package/src/adopt/rnTokenAliasResolver.js +474 -0
- package/src/adopt/rnTokenAliasResolver.test.js +260 -0
- package/src/adopt/tailwindResolver.js +512 -0
- package/src/adopt/tailwindResolver.test.js +178 -0
- package/src/adopt/targetDiscovery.js +237 -0
- package/src/adopt/targetDiscovery.test.js +227 -0
- package/src/adopt/tokenAliasResolver.js +513 -0
- package/src/adopt/tokenAliasResolver.test.js +319 -0
- package/src/adopt/wrapperResolver.js +874 -0
- package/src/adopt/wrapperResolver.test.js +324 -0
- package/src/cli.js +376 -0
- package/src/data.js +267 -0
- package/src/data.test.js +231 -0
- package/src/index.js +8 -0
- package/src/server.js +149 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
listComponents,
|
|
6
|
+
getComponentUsage,
|
|
7
|
+
getComponentProps,
|
|
8
|
+
buildManifest,
|
|
9
|
+
ejectComponent,
|
|
10
|
+
} from './data.js';
|
|
11
|
+
import { resolveReferences } from './adopt/referenceResolver.js';
|
|
12
|
+
import { resolveWrappers } from './adopt/wrapperResolver.js';
|
|
13
|
+
import { resolveTokenAliases } from './adopt/tokenAliasResolver.js';
|
|
14
|
+
import { resolveTailwindTokens } from './adopt/tailwindResolver.js';
|
|
15
|
+
import { resolvePropApi } from './adopt/propApiResolver.js';
|
|
16
|
+
import { resolveRnTokenAliases } from './adopt/rnTokenAliasResolver.js';
|
|
17
|
+
import { resolveRnTailwindTokens } from './adopt/rnTailwindResolver.js';
|
|
18
|
+
import { discoverTargets, workspacePackageMap } from './adopt/targetDiscovery.js';
|
|
19
|
+
|
|
20
|
+
function print(value) {
|
|
21
|
+
console.log(JSON.stringify(value, null, 2));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function fail(message) {
|
|
25
|
+
console.error(message);
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hasFlag(args, name) {
|
|
30
|
+
return args.includes(`--${name}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function flagValue(args, name, fallback) {
|
|
34
|
+
const prefix = `--${name}=`;
|
|
35
|
+
const arg = args.find(a => a.startsWith(prefix));
|
|
36
|
+
return arg ? arg.slice(prefix.length) : fallback;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Shared by the single-target `adopt` path and the `--all-targets` loop —
|
|
40
|
+
// `workspacePackages` is only non-empty in the latter, where it enables
|
|
41
|
+
// wrapperResolver.js's cross-workspace resolution (ADOPTION_APP_PLAN.md §3e).
|
|
42
|
+
function runAdopt(root, platform, ignore, workspacePackages) {
|
|
43
|
+
const references = resolveReferences(root, { platform, ignore });
|
|
44
|
+
const wrappers = resolveWrappers(root, { platform, ignore, workspacePackages });
|
|
45
|
+
// CSS custom properties and Tailwind utility classes have no RN
|
|
46
|
+
// equivalent — both resolvers only run for the web platform.
|
|
47
|
+
const tokenAliases = platform === 'web' ? resolveTokenAliases(root, { platform, ignore }) : null;
|
|
48
|
+
const tailwind = platform === 'web' ? resolveTailwindTokens(root, { platform, ignore }) : null;
|
|
49
|
+
// prop-mapping/ is flat, with no rn/ subdirectory — there is no artifact
|
|
50
|
+
// to validate an RN prop against, so metric 6 only runs on web.
|
|
51
|
+
const propApi = platform === 'web' ? resolvePropApi(root, { platform, ignore }) : null;
|
|
52
|
+
// The RN JS-object token indirection resolver is the inverse: it only
|
|
53
|
+
// makes sense for the native platform (see rnTokenAliasResolver.js).
|
|
54
|
+
const rnTokenAliases = platform === 'native' ? resolveRnTokenAliases(root, { platform, ignore }) : null;
|
|
55
|
+
// NativeWind is the RN analog of web Tailwind — same platform split.
|
|
56
|
+
const rnTailwind = platform === 'native' ? resolveRnTailwindTokens(root, { platform, ignore }) : null;
|
|
57
|
+
return {
|
|
58
|
+
...references,
|
|
59
|
+
wrappers: wrappers.wrappers,
|
|
60
|
+
byComponent: wrappers.byComponent,
|
|
61
|
+
contradictions: wrappers.contradictions,
|
|
62
|
+
unresolvedDeclarations: wrappers.unresolvedDeclarations,
|
|
63
|
+
provenanceTriple: wrappers.provenanceTriple,
|
|
64
|
+
dominionConfigFound: wrappers.dominionConfigFound,
|
|
65
|
+
tokenAliases,
|
|
66
|
+
tailwind,
|
|
67
|
+
propApi,
|
|
68
|
+
rnTokenAliases,
|
|
69
|
+
rnTailwind,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const HELP = `stark-cli — query the Stark design system catalog from the terminal
|
|
74
|
+
|
|
75
|
+
Usage:
|
|
76
|
+
stark-cli list
|
|
77
|
+
stark-cli usage <Component>
|
|
78
|
+
stark-cli props <Component> [--platform=web|native] [--tokens]
|
|
79
|
+
stark-cli manifest
|
|
80
|
+
stark-cli eject <Component> [--platform=web|native] [--out=<dir>] [--force]
|
|
81
|
+
stark-cli adopt [path] [--platform=web|native] [--ignore=<glob>,<glob>,...] [--all-targets]
|
|
82
|
+
stark-cli targets [path]
|
|
83
|
+
|
|
84
|
+
Options:
|
|
85
|
+
--platform=<web|native> Target platform for "props", "eject", and "adopt" (default: web)
|
|
86
|
+
--tokens Include component token JSON in "props" output
|
|
87
|
+
--out=<dir> Where to copy the component source (default: ./stark-eject/<Component>)
|
|
88
|
+
--force Overwrite the output directory if it already exists and isn't empty
|
|
89
|
+
--ignore=<globs> Extra comma-separated glob patterns to exclude from "adopt"'s scan
|
|
90
|
+
--all-targets Discover every workspace target under [path] and run "adopt" on
|
|
91
|
+
each in-scope one, instead of treating [path] as a single target
|
|
92
|
+
|
|
93
|
+
"eject --platform=native" copies from @starklab/stk-react-native
|
|
94
|
+
instead of @starklab/stk-components.
|
|
95
|
+
|
|
96
|
+
"adopt [path]" scans a consumer repo (default: current directory) for
|
|
97
|
+
references to catalog components and reports usage counts per component,
|
|
98
|
+
classified by how certain each reference is (jsx/createElement/hoc are
|
|
99
|
+
counted usages; indirect needs review; reexport isn't a usage). Every
|
|
100
|
+
catalog component is reported, including ones with zero references —
|
|
101
|
+
never derive the component list from the scan alone.
|
|
102
|
+
|
|
103
|
+
It also resolves the consumer's own wrapper components (e.g. a local
|
|
104
|
+
MyButton that renders <Button/>) down to the catalog component each one
|
|
105
|
+
ultimately wraps, classifies how faithfully each wrapper passes props
|
|
106
|
+
through (transparent/constraining/augmenting/divergent), and counts how
|
|
107
|
+
often each wrapper is itself used — reported under "wrappers" and rolled
|
|
108
|
+
up per-component under "byComponent". A dominion.config.json at the repo
|
|
109
|
+
root ({"wrappers": {"<path>": "<ComponentName>"}}) fills in wrappers
|
|
110
|
+
auto-detection couldn't resolve; it never overrides a contradicting
|
|
111
|
+
auto-detected terminal (see "contradictions" in the output).
|
|
112
|
+
|
|
113
|
+
On the web platform, "adopt" also resolves the consumer's own CSS custom-
|
|
114
|
+
property alias graph (e.g. --app-primary: var(--stk-surface-brand-1-default))
|
|
115
|
+
transitively, so an aliased token counts as adopted rather than absent —
|
|
116
|
+
reported under "tokenAliases". Each consumer property is classified
|
|
117
|
+
conformant / drift (aliases a raw value) / broken (undefined or cyclic),
|
|
118
|
+
or partial-conformance when the same property resolves differently under
|
|
119
|
+
different selectors (e.g. :root vs a theme override) — never collapsed to
|
|
120
|
+
one dominant scope. Findings also flag alias chains through a primitive
|
|
121
|
+
token (layer-violation), chains deeper than 3 hops, and raw-value var()
|
|
122
|
+
fallbacks (info-level: works today, silently hardcodes if the aliased
|
|
123
|
+
property is ever renamed). "tokenAliases.report" gives the three headline
|
|
124
|
+
numbers — direct / aliased / unresolved — never a single collapsed score.
|
|
125
|
+
Not run for --platform=native (CSS custom properties have no RN
|
|
126
|
+
equivalent).
|
|
127
|
+
|
|
128
|
+
Also on the web platform, "adopt" resolves Tailwind's own indirection layer:
|
|
129
|
+
a v4 CSS "@theme{}" block or a v3 "tailwind.config.{js,cjs,mjs,ts}"
|
|
130
|
+
theme.extend.colors map. A utility class like "bg-primary" has no var() at
|
|
131
|
+
its call site, so tokenAliasResolver alone can't see it — tailwind resolves
|
|
132
|
+
the theme key back through the same alias graph and scans JSX/TSX
|
|
133
|
+
"className"/"class" attributes for statically-known utility usages,
|
|
134
|
+
reported under "tailwind". Variant prefixes (hover:, dark:, arbitrary
|
|
135
|
+
[&:hover]:) are stripped before matching; an arbitrary-value var() utility
|
|
136
|
+
like "bg-[var(--stk-surface-brand-1-strong)]" is classified "direct", same
|
|
137
|
+
as a literal var() call site. Utility classes that don't correspond to a
|
|
138
|
+
theme key the consumer declared (Tailwind's own default palette, e.g.
|
|
139
|
+
"bg-red-500") are out of scope, not counted as violations. Dynamic
|
|
140
|
+
className expressions (clsx(), ternaries, interpolated templates) aren't
|
|
141
|
+
statically resolvable and are skipped.
|
|
142
|
+
|
|
143
|
+
Detection is always cheap, resolution isn't: if no Tailwind config or
|
|
144
|
+
@theme block is found at all, "tailwind.detected" is false and "report" is
|
|
145
|
+
null rather than a fabricated 0% score. If Tailwind is detected but no
|
|
146
|
+
theme entries could be statically extracted (e.g. a dynamic/function-based
|
|
147
|
+
v3 config), "tailwind.themeParseIncomplete" is true and "report" is again
|
|
148
|
+
null — never scored low just because resolution, not detection, hit a
|
|
149
|
+
wall. Not run for --platform=native (Tailwind utility classes have no RN
|
|
150
|
+
equivalent).
|
|
151
|
+
|
|
152
|
+
Also on the web platform, "adopt" validates every JSX call site of a
|
|
153
|
+
catalog component against that component's own
|
|
154
|
+
prop-mapping/components/<slug>.mapping.json — the legal prop surface
|
|
155
|
+
already used to keep Storybook and Figma in sync — reported under
|
|
156
|
+
"propApi". Four checks: an enum prop set to a value outside its declared
|
|
157
|
+
"values" (invalid-enum-value, critical), a prop marked "deprecated" in the
|
|
158
|
+
mapping still passed at a call site (deprecated-prop-passed, warning), a
|
|
159
|
+
prop marked "required" missing from a call site (required-prop-missing,
|
|
160
|
+
critical, unless the element also spreads props ({...rest}), which could
|
|
161
|
+
forward it dynamically and is left unresolved rather than flagged), and
|
|
162
|
+
"className"/"style" passed to a component whose contract is token-driven
|
|
163
|
+
styling (style-escape-hatch, warning — a visible, intentional escape hatch,
|
|
164
|
+
so lower severity than an invalid value). A dynamic prop value
|
|
165
|
+
(variant={someVar}) is never scored valid or invalid, only unresolved, same
|
|
166
|
+
rule as every other resolver. "propApi.report" gives the three headline
|
|
167
|
+
numbers — valid / invalid / unresolved — computed only from the two checks
|
|
168
|
+
with a real valid population (enum values, required props); the
|
|
169
|
+
deprecated/style-escape-hatch checks only ever produce findings, never a
|
|
170
|
+
"valid" row, so they sit outside that ledger the same way tokenAliases'
|
|
171
|
+
raw-fallback/layer-violation findings do. "required"/"deprecated" are
|
|
172
|
+
optional propMapEntry fields (packages/stk/prop-mapping/schema.json) that
|
|
173
|
+
no shipped mapping file sets yet, so those two checks currently report zero
|
|
174
|
+
findings against real data — an honest "not yet authored" state, not a
|
|
175
|
+
missing feature; population is a separate, later pass. Not run for
|
|
176
|
+
--platform=native (prop-mapping/ has no RN equivalent) — metric 6 is a
|
|
177
|
+
visible "n/a" there, never an implicit zero.
|
|
178
|
+
|
|
179
|
+
On the native platform, "adopt" instead resolves the consumer's own JS-
|
|
180
|
+
object token indirection — RN has no CSS custom properties, so the
|
|
181
|
+
indirection is a local const or object property instead of var(), e.g.
|
|
182
|
+
"const theme = { primary: stkSurfaceBrand1Strong }" or
|
|
183
|
+
"const primary = stkSurfaceBrand1Strong". Reported under "rnTokenAliases"
|
|
184
|
+
with the same shape as "tokenAliases": conformant / drift (aliases a raw
|
|
185
|
+
literal) / broken (undefined or foreign import, or a cycle), findings for
|
|
186
|
+
layer-violation and alias chains deeper than 3 hops, and a three-number
|
|
187
|
+
"report" (direct / aliased / unresolved). A usage site whose containing
|
|
188
|
+
object has no static origin (e.g. a "const t = useTokens()" hook return)
|
|
189
|
+
is still classified "direct" with "heuristic: true" when the accessed
|
|
190
|
+
property name is an unambiguous Stark RN token name — a name match, never
|
|
191
|
+
a value match. If no file imports from a @starklab/stk RN token
|
|
192
|
+
package (rn, rn-spacing, rn-typography, rn-easing, rn-shadows, rn-dark) at
|
|
193
|
+
all, "rnTokenAliases.detected" is false and "report" is null, same
|
|
194
|
+
detection-first rule as "tailwind". Unlike the web resolvers, this one is
|
|
195
|
+
static-only — there is no getComputedStyle()-equivalent runtime oracle on
|
|
196
|
+
React Native to reconcile against — so "rnTokenAliases.confidence" is
|
|
197
|
+
always "uncertain" on a detected result. Not run for --platform=web (CSS
|
|
198
|
+
var()/Tailwind classes are the web analog).
|
|
199
|
+
|
|
200
|
+
Also on the native platform, "adopt" resolves NativeWind — the one real
|
|
201
|
+
Tailwind-syntax library on React Native ("className" props via a Babel
|
|
202
|
+
transform, built on Tailwind's own config/theming). RN has no CSS custom
|
|
203
|
+
properties, so a NativeWind consumer can only alias a Stark token by
|
|
204
|
+
importing the RN token constant directly into tailwind.config.{js,cjs,mjs,ts}
|
|
205
|
+
and referencing it as a JS expression (an identifier or a single-hop member
|
|
206
|
+
access) — never var(), since there's no DOM/CSS runtime on RN to resolve
|
|
207
|
+
that against. Reported under "rnTailwind" with the same shape as "tailwind":
|
|
208
|
+
"properties" (theme.colors entries classified conformant/drift/broken),
|
|
209
|
+
"usages" (className utility occurrences resolved back through the theme
|
|
210
|
+
map), findings for layer-violation and alias chains deeper than 3 hops, and
|
|
211
|
+
a three-number "report" (direct / aliased / unresolved) — "direct" is
|
|
212
|
+
always 0 here, since there's no var()-arbitrary-value escape hatch on RN.
|
|
213
|
+
Colors-only (theme.colors / theme.extend.colors), matching the web v3
|
|
214
|
+
config parser's actual coverage; no v4 CSS "@theme" support (a CSS file
|
|
215
|
+
can't import a JS token constant). Detection requires a genuine NativeWind
|
|
216
|
+
signal — a "nativewind" package.json dependency, a "nativewind/babel" Babel
|
|
217
|
+
preset, or a "withNativeWind" Metro config wrapper — a bare
|
|
218
|
+
tailwind.config.js alone doesn't count, since it could belong to an
|
|
219
|
+
unrelated web app elsewhere in the same monorepo; "rnTailwind.detected" is
|
|
220
|
+
false and "report" is null when none of those are found, and
|
|
221
|
+
"rnTailwind.themeParseIncomplete" is true (report still null) when
|
|
222
|
+
NativeWind is detected but no theme.colors entries could be statically
|
|
223
|
+
resolved. Not run for --platform=web (Tailwind's own web resolver is the
|
|
224
|
+
analog).
|
|
225
|
+
|
|
226
|
+
"stark-cli targets [path]" and "adopt --all-targets" address
|
|
227
|
+
ADOPTION_APP_PLAN.md §3e: a product is rarely one repo passed as a single
|
|
228
|
+
[path] — it's a monorepo of workspace packages, only some of which
|
|
229
|
+
actually depend on the design system. "targets" discovers every workspace
|
|
230
|
+
target under [path] (from package.json#workspaces, falling back to
|
|
231
|
+
pnpm-workspace.yaml, falling back to a scan for any nested package.json if
|
|
232
|
+
neither manifest exists) and classifies each one in or out of scope: a
|
|
233
|
+
target with no dependency on @starklab/stk*, direct or via a
|
|
234
|
+
workspace sibling, is out of scope, not scored 0%. Excluded targets that
|
|
235
|
+
still depend on a UI framework (React, React Native, etc.) are surfaced
|
|
236
|
+
separately under "opportunities" — the most valuable excluded rows,
|
|
237
|
+
which must never be read as zeros. A dominion.config.json
|
|
238
|
+
{"targets": {"forceInclude": ["<dir>"], "forceExclude": {"<dir>": "<reason>"}}}
|
|
239
|
+
overrides the automatic classification asymmetrically: force-include is
|
|
240
|
+
unrestricted, force-exclude always carries a recorded reason shown beside
|
|
241
|
+
the excluded count.
|
|
242
|
+
|
|
243
|
+
"adopt --all-targets [path]" runs the full "adopt" scan (references,
|
|
244
|
+
wrappers, tokenAliases, tailwind, propApi, etc.) once per in-scope target
|
|
245
|
+
discovered under [path], reported under "targets" keyed by each target's
|
|
246
|
+
directory, alongside the same "excluded" and "opportunities" lists
|
|
247
|
+
"targets" reports on its own. Every per-target wrapper resolution is given
|
|
248
|
+
the full set of discovered workspace package names, so a wrapper that
|
|
249
|
+
imports a *sibling* workspace package (e.g. an app importing its own repo's
|
|
250
|
+
internal @acme/ui) resolves into that sibling's real source instead of
|
|
251
|
+
terminating at the workspace boundary as an ordinary third-party import —
|
|
252
|
+
without this, a shared internal UI package would score near-0% in every
|
|
253
|
+
consuming target and 100% in the one that defines it, the same inverted-
|
|
254
|
+
result problem catalog left-joining exists to prevent, recurring one level
|
|
255
|
+
up at package granularity.
|
|
256
|
+
`;
|
|
257
|
+
|
|
258
|
+
const [, , command, ...rest] = process.argv;
|
|
259
|
+
|
|
260
|
+
switch (command) {
|
|
261
|
+
case 'list':
|
|
262
|
+
print(listComponents());
|
|
263
|
+
break;
|
|
264
|
+
|
|
265
|
+
case 'usage': {
|
|
266
|
+
const [component] = rest;
|
|
267
|
+
if (!component) {
|
|
268
|
+
fail('Usage: stark-cli usage <Component>');
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
print(getComponentUsage(component));
|
|
273
|
+
} catch (err) {
|
|
274
|
+
fail(err.message);
|
|
275
|
+
}
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
case 'props': {
|
|
280
|
+
const [component] = rest;
|
|
281
|
+
if (!component) {
|
|
282
|
+
fail('Usage: stark-cli props <Component> [--platform=web|native] [--tokens]');
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
const platform = flagValue(rest, 'platform', 'web');
|
|
286
|
+
const includeTokens = hasFlag(rest, 'tokens');
|
|
287
|
+
try {
|
|
288
|
+
print(getComponentProps(component, platform, includeTokens));
|
|
289
|
+
} catch (err) {
|
|
290
|
+
fail(err.message);
|
|
291
|
+
}
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
case 'manifest':
|
|
296
|
+
print(buildManifest());
|
|
297
|
+
break;
|
|
298
|
+
|
|
299
|
+
case 'eject': {
|
|
300
|
+
const [component] = rest;
|
|
301
|
+
if (!component) {
|
|
302
|
+
fail('Usage: stark-cli eject <Component> [--platform=web|native] [--out=<dir>] [--force]');
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
const platform = flagValue(rest, 'platform', 'web');
|
|
306
|
+
const outDir = flagValue(rest, 'out', undefined);
|
|
307
|
+
const force = hasFlag(rest, 'force');
|
|
308
|
+
try {
|
|
309
|
+
print(ejectComponent(component, { platform, outDir, force }));
|
|
310
|
+
} catch (err) {
|
|
311
|
+
fail(err.message);
|
|
312
|
+
}
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
case 'adopt': {
|
|
317
|
+
const [maybePath] = rest;
|
|
318
|
+
const target = maybePath && !maybePath.startsWith('--') ? maybePath : '.';
|
|
319
|
+
const root = path.resolve(process.cwd(), target);
|
|
320
|
+
const platform = flagValue(rest, 'platform', 'web');
|
|
321
|
+
const ignoreArg = flagValue(rest, 'ignore', '');
|
|
322
|
+
const ignore = ignoreArg ? ignoreArg.split(',').map(s => s.trim()).filter(Boolean) : [];
|
|
323
|
+
|
|
324
|
+
if (hasFlag(rest, 'all-targets')) {
|
|
325
|
+
try {
|
|
326
|
+
const discovery = discoverTargets(root);
|
|
327
|
+
const workspacePackages = workspacePackageMap(discovery);
|
|
328
|
+
const targets = {};
|
|
329
|
+
for (const t of discovery.inScope) {
|
|
330
|
+
const targetRoot = path.join(discovery.root, t.dir);
|
|
331
|
+
targets[t.dir] = runAdopt(targetRoot, platform, ignore, workspacePackages);
|
|
332
|
+
}
|
|
333
|
+
print({
|
|
334
|
+
root: discovery.root,
|
|
335
|
+
manifestSource: discovery.manifestSource,
|
|
336
|
+
monorepoTooling: discovery.monorepoTooling,
|
|
337
|
+
targets,
|
|
338
|
+
excluded: discovery.excluded,
|
|
339
|
+
opportunities: discovery.opportunities,
|
|
340
|
+
});
|
|
341
|
+
} catch (err) {
|
|
342
|
+
fail(err.message);
|
|
343
|
+
}
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
print(runAdopt(root, platform, ignore));
|
|
349
|
+
} catch (err) {
|
|
350
|
+
fail(err.message);
|
|
351
|
+
}
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
case 'targets': {
|
|
356
|
+
const [maybePath] = rest;
|
|
357
|
+
const target = maybePath && !maybePath.startsWith('--') ? maybePath : '.';
|
|
358
|
+
const root = path.resolve(process.cwd(), target);
|
|
359
|
+
try {
|
|
360
|
+
print(discoverTargets(root));
|
|
361
|
+
} catch (err) {
|
|
362
|
+
fail(err.message);
|
|
363
|
+
}
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
case undefined:
|
|
368
|
+
case '--help':
|
|
369
|
+
case '-h':
|
|
370
|
+
case 'help':
|
|
371
|
+
console.log(HELP);
|
|
372
|
+
break;
|
|
373
|
+
|
|
374
|
+
default:
|
|
375
|
+
fail(`Unknown command "${command}".\n\n${HELP}`);
|
|
376
|
+
}
|
package/src/data.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { readFileSync, readdirSync, existsSync, mkdirSync, copyFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { loadUsage } from '@starklab/stk/usage/loader.js';
|
|
6
|
+
import { buildCatalogFromDir } from '@starklab/stk/conformance/catalog.js';
|
|
7
|
+
import { runConformance, hasBlocking } from '@starklab/stk/conformance/index.js';
|
|
8
|
+
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
|
|
11
|
+
export function stkRoot() {
|
|
12
|
+
return path.dirname(require.resolve('@starklab/stk/package.json'));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function mappingDir(root = stkRoot()) {
|
|
16
|
+
return path.join(root, 'prop-mapping', 'components');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Resolves @starklab/stk-components relative to the caller's own
|
|
20
|
+
// project (cwd), not stark-mcp's own dependency tree — eject only makes sense
|
|
21
|
+
// against the component library the consumer actually has installed.
|
|
22
|
+
export function stkComponentsRoot(cwd = process.cwd()) {
|
|
23
|
+
return path.dirname(require.resolve('@starklab/stk-components/package.json', { paths: [cwd] }));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Same idea as stkComponentsRoot, for the React Native port. RN component
|
|
27
|
+
// dirs have a different shape (single .jsx, no .css, no .figma.tsx) but
|
|
28
|
+
// ejectComponent's "copy every file in the source dir" logic already handles
|
|
29
|
+
// that without a separate code path.
|
|
30
|
+
export function stkReactNativeRoot(cwd = process.cwd()) {
|
|
31
|
+
return path.dirname(require.resolve('@starklab/stk-react-native/package.json', { paths: [cwd] }));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function tokensDir(root = stkRoot()) {
|
|
35
|
+
return path.join(root, 'tokens', 'components');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Mirrors the file-naming convention across prop-mapping/ and usage/:
|
|
39
|
+
// PascalCase component names on disk as kebab-case ("TextInput" -> "text-input").
|
|
40
|
+
export function toSlug(name) {
|
|
41
|
+
return String(name)
|
|
42
|
+
.trim()
|
|
43
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
44
|
+
.replace(/[\s_]+/g, '-')
|
|
45
|
+
.toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readJson(absPath) {
|
|
49
|
+
return JSON.parse(readFileSync(absPath, 'utf-8'));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function mappingFiles(dir) {
|
|
53
|
+
return readdirSync(dir).filter(f => f.endsWith('.mapping.json'));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function listComponents() {
|
|
57
|
+
const dir = mappingDir();
|
|
58
|
+
return mappingFiles(dir)
|
|
59
|
+
.map(file => {
|
|
60
|
+
const mapping = readJson(path.join(dir, file));
|
|
61
|
+
const slug = toSlug(mapping.component);
|
|
62
|
+
const usage = loadUsage(slug);
|
|
63
|
+
return {
|
|
64
|
+
name: mapping.component,
|
|
65
|
+
slug,
|
|
66
|
+
platforms: mapping.platforms ?? ['web'],
|
|
67
|
+
status: usage?.status ?? 'unknown',
|
|
68
|
+
description: usage?.description ?? null,
|
|
69
|
+
figmaUrl: usage?.figmaUrl ?? null,
|
|
70
|
+
};
|
|
71
|
+
})
|
|
72
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getComponentUsage(component) {
|
|
76
|
+
const slug = toSlug(component);
|
|
77
|
+
const usage = loadUsage(slug);
|
|
78
|
+
if (!usage) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`No usage rules found for "${component}" (looked for slug "${slug}"). Call list_components to see valid names.`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return usage;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function findMappingFile(dir, slug) {
|
|
87
|
+
return mappingFiles(dir).find(f => toSlug(f.replace(/\.mapping\.json$/, '')) === slug);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Component token JSON (tokens/components/{slug}.json) doesn't exist for pure
|
|
91
|
+
// layout primitives (Container, Grid, Stack...) that carry no component-layer
|
|
92
|
+
// tokens of their own — absence is expected, not an error.
|
|
93
|
+
export function getComponentTokens(component) {
|
|
94
|
+
const slug = toSlug(component);
|
|
95
|
+
const file = path.join(tokensDir(), `${slug}.json`);
|
|
96
|
+
try {
|
|
97
|
+
return readJson(file);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (err.code === 'ENOENT') return null;
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function getComponentProps(component, platform = 'web', includeTokens = false) {
|
|
105
|
+
const dir = mappingDir();
|
|
106
|
+
const slug = toSlug(component);
|
|
107
|
+
const file = findMappingFile(dir, slug);
|
|
108
|
+
if (!file) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`No prop mapping found for "${component}" (looked for slug "${slug}"). Call list_components to see valid names.`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
const mapping = readJson(path.join(dir, file));
|
|
114
|
+
const platforms = mapping.platforms ?? ['web'];
|
|
115
|
+
if (!platforms.includes(platform)) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Component "${mapping.component}" does not support platform "${platform}". Available: ${platforms.join(', ')}.`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
// Mirrors buildCatalogFromDir's platform-namespaced-vs-legacy-flat fallback.
|
|
121
|
+
const block = mapping[platform] ?? mapping;
|
|
122
|
+
const result = {
|
|
123
|
+
component: mapping.component,
|
|
124
|
+
figmaComponentSetName: mapping.figmaComponentSetName,
|
|
125
|
+
platform,
|
|
126
|
+
propMap: block.propMap ?? [],
|
|
127
|
+
ignore: block.ignore ?? [],
|
|
128
|
+
};
|
|
129
|
+
if (includeTokens) {
|
|
130
|
+
result.tokens = getComponentTokens(mapping.component);
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Aggregates the full catalog — usage, props (per supported platform), and
|
|
136
|
+
// tokens — into one artifact, so an external caller doesn't need N round
|
|
137
|
+
// trips (list_components, then get_component_usage/get_component_props per
|
|
138
|
+
// component) just to see the whole system's shape.
|
|
139
|
+
export function buildManifest() {
|
|
140
|
+
const components = listComponents().map(({ name, slug, status, description, figmaUrl, platforms }) => {
|
|
141
|
+
let usage = null;
|
|
142
|
+
try {
|
|
143
|
+
usage = getComponentUsage(name);
|
|
144
|
+
} catch {
|
|
145
|
+
// no usage/*.usage.json for this component
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const props = {};
|
|
149
|
+
for (const platform of ['web', 'native']) {
|
|
150
|
+
try {
|
|
151
|
+
const { propMap, ignore } = getComponentProps(name, platform);
|
|
152
|
+
props[platform] = { propMap, ignore };
|
|
153
|
+
} catch {
|
|
154
|
+
// component doesn't support this platform
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
name,
|
|
160
|
+
slug,
|
|
161
|
+
status,
|
|
162
|
+
description,
|
|
163
|
+
figmaUrl,
|
|
164
|
+
platforms,
|
|
165
|
+
usage,
|
|
166
|
+
props,
|
|
167
|
+
tokens: getComponentTokens(name),
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
generatedAt: new Date().toISOString(),
|
|
173
|
+
package: '@starklab/stk',
|
|
174
|
+
componentCount: components.length,
|
|
175
|
+
components,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function getLayoutCatalog(platform = 'web') {
|
|
180
|
+
return buildCatalogFromDir(mappingDir(), { platform });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function getLayoutSchema(component, platform = 'web') {
|
|
184
|
+
const slug = toSlug(component);
|
|
185
|
+
const catalog = getLayoutCatalog(platform);
|
|
186
|
+
const entry = catalog.find(c => toSlug(c.name) === slug);
|
|
187
|
+
if (!entry) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`No layoutSchema for "${component}" on platform "${platform}" — it may be an overlay/sub-component with no ` +
|
|
190
|
+
`standalone layout node, or the platform doesn't apply. Call get_layout_catalog to see what's available.`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return entry;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// `canvasCases` (the renderer's supported node types) is optional and defaults
|
|
197
|
+
// to []. Omitting it silently skips the catalog<->renderer parity check inside
|
|
198
|
+
// runConformance — real for external callers whose renderer isn't Stark's own
|
|
199
|
+
// LayoutCanvas.jsx. Pass your own renderer's supported type list if you have one.
|
|
200
|
+
export function validateLayout({ layout, intent = {}, platform = 'web', canvasCases = [] } = {}) {
|
|
201
|
+
const catalog = getLayoutCatalog(platform);
|
|
202
|
+
const findings = runConformance({ layout, intent, catalog, canvasCases });
|
|
203
|
+
return { findings, hasBlocking: hasBlocking(findings) };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function getGenerationProtocol() {
|
|
207
|
+
return readJson(path.join(stkRoot(), 'generation-protocol.json'));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const EJECT_PACKAGES = {
|
|
211
|
+
web: { name: '@starklab/stk-components', resolve: stkComponentsRoot },
|
|
212
|
+
native: { name: '@starklab/stk-react-native', resolve: stkReactNativeRoot },
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// Copies one component's source out of the installed component package
|
|
216
|
+
// (@starklab/stk-components for web, @starklab/stk-react-native
|
|
217
|
+
// for native) into the caller's own project, so they can customize it locally
|
|
218
|
+
// without forking the whole library — at the cost of losing future upstream
|
|
219
|
+
// fixes for that component. Mirrors Docusaurus's "swizzle"/eject: an escape
|
|
220
|
+
// hatch for the rare case where the token/prop API genuinely can't express
|
|
221
|
+
// what's needed.
|
|
222
|
+
export function ejectComponent(component, { outDir, cwd = process.cwd(), force = false, platform = 'web' } = {}) {
|
|
223
|
+
const pkg = EJECT_PACKAGES[platform];
|
|
224
|
+
if (!pkg) {
|
|
225
|
+
throw new Error(`Unsupported platform "${platform}". Available: ${Object.keys(EJECT_PACKAGES).join(', ')}.`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const match = listComponents().find(c => c.slug === toSlug(component));
|
|
229
|
+
if (!match) {
|
|
230
|
+
throw new Error(`Unknown component "${component}". Call list_components to see valid names.`);
|
|
231
|
+
}
|
|
232
|
+
const name = match.name;
|
|
233
|
+
if (!match.platforms.includes(platform)) {
|
|
234
|
+
throw new Error(`Component "${name}" does not support platform "${platform}". Available: ${match.platforms.join(', ')}.`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
let root;
|
|
238
|
+
try {
|
|
239
|
+
root = pkg.resolve(cwd);
|
|
240
|
+
} catch {
|
|
241
|
+
throw new Error(`Could not resolve "${pkg.name}" from ${cwd}. Install it first: npm install ${pkg.name}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const sourceDir = path.join(root, 'src', name);
|
|
245
|
+
if (!existsSync(sourceDir)) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`"${name}" has no source directory in the installed ${pkg.name} (looked in ${sourceDir}). ` +
|
|
248
|
+
`The installed version may be out of date.`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const target = path.resolve(cwd, outDir || path.join('stark-eject', name));
|
|
253
|
+
if (existsSync(target) && readdirSync(target).length > 0 && !force) {
|
|
254
|
+
throw new Error(`"${target}" already exists and is not empty. Pass force to overwrite.`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// .figma.tsx is Code Connect metadata, not runtime source — an ejected
|
|
258
|
+
// component doesn't need it, and it may reference internal Figma node IDs.
|
|
259
|
+
const files = readdirSync(sourceDir).filter(f => !f.endsWith('.figma.tsx'));
|
|
260
|
+
|
|
261
|
+
mkdirSync(target, { recursive: true });
|
|
262
|
+
for (const file of files) {
|
|
263
|
+
copyFileSync(path.join(sourceDir, file), path.join(target, file));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return { component: name, platform, from: sourceDir, to: target, files };
|
|
267
|
+
}
|