dslinter 0.1.0 → 0.1.2
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 +8 -0
- package/README.md +43 -0
- package/bin/dslinter.mjs +6 -0
- package/bin/lib/dev-banner.mjs +6 -3
- package/bin/lib/dev-banner.test.mjs +19 -3
- package/bin/modes/dev.mjs +19 -7
- package/bin/modes/init.mjs +73 -0
- package/dashboard-dist/assets/index-BN-Qjczl.js +205 -0
- package/dashboard-dist/assets/index-BhDQfrwA.css +1 -0
- package/dashboard-dist/index.html +2 -2
- package/index.cjs +52 -52
- package/package.json +7 -6
- package/src/components/ComponentInspectPane.tsx +198 -0
- package/src/components/DashboardCommandPalette.tsx +1 -15
- package/src/components/GovernancePane.tsx +3 -3
- package/src/components/Sidebar.tsx +4 -8
- package/src/dashboard/ComponentCatalog.tsx +41 -68
- package/src/dashboard/ComponentPropUsageDetail.tsx +90 -0
- package/src/dashboard/DashboardBody.tsx +3 -3
- package/src/index.ts +27 -0
- package/src/playground/buildPlaygroundEntriesFromReport.test.ts +53 -0
- package/src/playground/buildPlaygroundEntriesFromReport.ts +389 -0
- package/src/playground/createPlaygroundRegistry.ts +48 -0
- package/src/playground/playgroundJoin.test.ts +74 -0
- package/src/playground/playgroundJoin.ts +116 -0
- package/src/report/findingsForComponent.ts +24 -0
- package/src/shell/DashboardLayout.tsx +51 -37
- package/src/shell/hashRoute.test.ts +41 -0
- package/src/shell/hashRoute.ts +2 -1
- package/templates/playground/buildRegistry.ts +26 -0
- package/templates/vite.dslinter.snippet.ts +28 -0
- package/dashboard-dist/assets/index-Pc1to7nD.css +0 -1
- package/dashboard-dist/assets/index-YvDeIoPr.js +0 -205
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { PlaygroundEntry } from "../types/playground";
|
|
3
|
+
import type { WorkspaceReport } from "../types/report";
|
|
4
|
+
import { resolvePlaygroundEntry } from "./buildPlaygroundEntriesFromReport";
|
|
5
|
+
|
|
6
|
+
const entries: PlaygroundEntry[] = [
|
|
7
|
+
{
|
|
8
|
+
id: "PrimaryButton",
|
|
9
|
+
meta: { id: "PrimaryButton", title: "PrimaryButton" },
|
|
10
|
+
modulePath: "@dslint-scan/src/components/PrimaryButton.tsx",
|
|
11
|
+
controls: [],
|
|
12
|
+
Preview: () => null,
|
|
13
|
+
},
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
describe("resolvePlaygroundEntry", () => {
|
|
17
|
+
it("finds by catalog export name id", () => {
|
|
18
|
+
expect(resolvePlaygroundEntry(entries, "PrimaryButton")).toBe(entries[0]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns undefined for unknown name", () => {
|
|
22
|
+
expect(resolvePlaygroundEntry(entries, "Missing")).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("playground catalog id alignment", () => {
|
|
27
|
+
it("report playgrounds use export_name as id", () => {
|
|
28
|
+
const report: WorkspaceReport = {
|
|
29
|
+
root: "/repo",
|
|
30
|
+
files: [],
|
|
31
|
+
findings: [],
|
|
32
|
+
scores: {
|
|
33
|
+
system_health: 0,
|
|
34
|
+
token_adoption: 0,
|
|
35
|
+
component_adoption: 0,
|
|
36
|
+
ux_consistency: 0,
|
|
37
|
+
},
|
|
38
|
+
ownership: [],
|
|
39
|
+
duplicate_components: [],
|
|
40
|
+
usage_by_component: [],
|
|
41
|
+
playgrounds: [
|
|
42
|
+
{
|
|
43
|
+
id: "Card",
|
|
44
|
+
export_name: "Card",
|
|
45
|
+
rel_path: "src/components/nested/DuplicateCardA.tsx",
|
|
46
|
+
declared_props: [],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
expect(report.playgrounds?.[0]?.id).toBe("Card");
|
|
51
|
+
expect(report.playgrounds?.[0]?.export_name).toBe("Card");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { createElement, type ComponentType } from "react";
|
|
2
|
+
import type { PlaygroundArgs, PlaygroundControl } from "../types/controls";
|
|
3
|
+
import type { DeclaredPropKind, PlaygroundSpec, WorkspaceReport } from "../types/report";
|
|
4
|
+
import type { PlaygroundEntry, PlaygroundMeta, PlaygroundPreviewComponent } from "../types/playground";
|
|
5
|
+
import {
|
|
6
|
+
defaultEmbedGlobKeyFromRelPath,
|
|
7
|
+
diagnosePlaygroundJoinSkips,
|
|
8
|
+
logPlaygroundJoinSkips,
|
|
9
|
+
type PlaygroundJoinSkip,
|
|
10
|
+
} from "./playgroundJoin";
|
|
11
|
+
|
|
12
|
+
export type BuildPlaygroundModules = Record<string, Record<string, unknown>>;
|
|
13
|
+
|
|
14
|
+
export type BuildPlaygroundOptions = {
|
|
15
|
+
/** Maps report `rel_path` to a key in `modules` (from `import.meta.glob`). */
|
|
16
|
+
globKeyFromRelPath?: (relPath: string) => string;
|
|
17
|
+
controlOverrides?: Record<string, PlaygroundControl[]>;
|
|
18
|
+
staticDefaults?: Record<string, Record<string, unknown>>;
|
|
19
|
+
/** When true (default in Vite dev), log specs that failed to join to `modules`. */
|
|
20
|
+
logJoinSkips?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type BuildPlaygroundResult = {
|
|
24
|
+
entries: PlaygroundEntry[];
|
|
25
|
+
skipped: PlaygroundJoinSkip[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function defaultGlobKeyFromRelPath(relPath: string): string {
|
|
29
|
+
return defaultEmbedGlobKeyFromRelPath(relPath);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type { PlaygroundJoinSkip, PlaygroundJoinSkipReason } from "./playgroundJoin";
|
|
33
|
+
export {
|
|
34
|
+
defaultConsumerGlobKeyFromRelPath,
|
|
35
|
+
defaultEmbedGlobKeyFromRelPath,
|
|
36
|
+
diagnosePlaygroundJoinSkips,
|
|
37
|
+
findPlaygroundJoinSkip,
|
|
38
|
+
findPlaygroundSpec,
|
|
39
|
+
logPlaygroundJoinSkips,
|
|
40
|
+
} from "./playgroundJoin";
|
|
41
|
+
|
|
42
|
+
function coerceDeclaredPropKind(v: unknown): DeclaredPropKind | undefined {
|
|
43
|
+
if (v === "boolean" || v === "string" || v === "number" || v === "unknown")
|
|
44
|
+
return v;
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizedPropKinds(
|
|
49
|
+
raw: PlaygroundSpec["declared_prop_kinds"],
|
|
50
|
+
): Partial<Record<string, DeclaredPropKind>> | undefined {
|
|
51
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
52
|
+
const out: Partial<Record<string, DeclaredPropKind>> = {};
|
|
53
|
+
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
54
|
+
const ck = coerceDeclaredPropKind(v);
|
|
55
|
+
if (ck && ck !== "unknown") out[k] = ck;
|
|
56
|
+
}
|
|
57
|
+
return Object.keys(out).length ? out : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isLikelyBooleanProp(name: string): boolean {
|
|
61
|
+
const n = name.toLowerCase();
|
|
62
|
+
if (n === "disabled" || n === "loading") return true;
|
|
63
|
+
if (n.startsWith("is") || n.startsWith("has")) return true;
|
|
64
|
+
if (n.startsWith("show") || n.startsWith("hide")) return true;
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function defaultStringForProp(key: string): string {
|
|
69
|
+
if (key === "href") return "#!/governance";
|
|
70
|
+
const k = key.toLowerCase();
|
|
71
|
+
if (
|
|
72
|
+
k === "title" ||
|
|
73
|
+
k === "label" ||
|
|
74
|
+
k === "text" ||
|
|
75
|
+
k === "name" ||
|
|
76
|
+
k === "heading"
|
|
77
|
+
) {
|
|
78
|
+
return "Label";
|
|
79
|
+
}
|
|
80
|
+
return key;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function controlsFromDeclaredProps(
|
|
84
|
+
declaredProps: string[],
|
|
85
|
+
propKinds?: Partial<Record<string, DeclaredPropKind>>,
|
|
86
|
+
): PlaygroundControl[] {
|
|
87
|
+
const skip = new Set(["key", "ref"]);
|
|
88
|
+
const out: PlaygroundControl[] = [];
|
|
89
|
+
for (const key of declaredProps) {
|
|
90
|
+
if (skip.has(key)) continue;
|
|
91
|
+
if (key === "children") {
|
|
92
|
+
out.push({
|
|
93
|
+
key: "children",
|
|
94
|
+
label: "children",
|
|
95
|
+
type: "string",
|
|
96
|
+
default: "",
|
|
97
|
+
placeholder: "Preview if empty",
|
|
98
|
+
});
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const kind = propKinds?.[key];
|
|
102
|
+
if (kind === "boolean") {
|
|
103
|
+
out.push({ key, label: key, type: "boolean", default: false });
|
|
104
|
+
} else if (kind === "number") {
|
|
105
|
+
out.push({ key, label: key, type: "number", default: 0 });
|
|
106
|
+
} else if (kind === "string") {
|
|
107
|
+
out.push({
|
|
108
|
+
key,
|
|
109
|
+
label: key,
|
|
110
|
+
type: "string",
|
|
111
|
+
default: defaultStringForProp(key),
|
|
112
|
+
placeholder: key,
|
|
113
|
+
});
|
|
114
|
+
} else if (isLikelyBooleanProp(key)) {
|
|
115
|
+
out.push({ key, label: key, type: "boolean", default: false });
|
|
116
|
+
} else {
|
|
117
|
+
out.push({
|
|
118
|
+
key,
|
|
119
|
+
label: key,
|
|
120
|
+
type: "string",
|
|
121
|
+
default: defaultStringForProp(key),
|
|
122
|
+
placeholder: key,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function controlsForSpec(
|
|
130
|
+
catalogId: string,
|
|
131
|
+
declaredProps: string[],
|
|
132
|
+
propKinds: Partial<Record<string, DeclaredPropKind>> | undefined,
|
|
133
|
+
controlOverrides: Record<string, PlaygroundControl[]>,
|
|
134
|
+
): PlaygroundControl[] {
|
|
135
|
+
const override = controlOverrides[catalogId];
|
|
136
|
+
if (override) return override;
|
|
137
|
+
return controlsFromDeclaredProps(declaredProps, propKinds);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function valuesToComponentProps(
|
|
141
|
+
declaredProps: string[],
|
|
142
|
+
values: PlaygroundArgs,
|
|
143
|
+
propKinds?: Partial<Record<string, DeclaredPropKind>>,
|
|
144
|
+
): Record<string, unknown> {
|
|
145
|
+
const o: Record<string, unknown> = {};
|
|
146
|
+
for (const key of declaredProps) {
|
|
147
|
+
if (key === "key" || key === "ref") continue;
|
|
148
|
+
if (key === "children") {
|
|
149
|
+
const v = values.children;
|
|
150
|
+
o[key] =
|
|
151
|
+
v !== undefined && v !== null && String(v).length > 0
|
|
152
|
+
? String(v)
|
|
153
|
+
: "Preview";
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const kind = propKinds?.[key];
|
|
157
|
+
if (kind === "boolean") {
|
|
158
|
+
o[key] = Boolean(values[key]);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (kind === "number") {
|
|
162
|
+
const raw = values[key];
|
|
163
|
+
const n = typeof raw === "number" ? raw : Number(raw);
|
|
164
|
+
o[key] = Number.isFinite(n) ? n : 0;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (kind === "string") {
|
|
168
|
+
o[key] = values[key];
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (isLikelyBooleanProp(key)) {
|
|
172
|
+
o[key] = Boolean(values[key]);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
o[key] = values[key];
|
|
176
|
+
}
|
|
177
|
+
return o;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function mergeStaticDefaults(
|
|
181
|
+
fromValues: Record<string, unknown>,
|
|
182
|
+
staticDefaults: Record<string, unknown>,
|
|
183
|
+
): Record<string, unknown> {
|
|
184
|
+
const o = { ...fromValues };
|
|
185
|
+
for (const [k, v] of Object.entries(staticDefaults)) {
|
|
186
|
+
const cur = o[k];
|
|
187
|
+
if (cur === undefined || cur === "") o[k] = v;
|
|
188
|
+
}
|
|
189
|
+
return o;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function getExport(
|
|
193
|
+
mod: Record<string, unknown>,
|
|
194
|
+
exportName: string,
|
|
195
|
+
): ComponentType<Record<string, unknown>> | undefined {
|
|
196
|
+
const x = mod[exportName];
|
|
197
|
+
if (typeof x === "function")
|
|
198
|
+
return x as ComponentType<Record<string, unknown>>;
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function jsxTextOrStringifyExpression(text: string): string {
|
|
203
|
+
if (!/[<>{}&]/.test(text)) return text;
|
|
204
|
+
return `{JSON.stringify(${JSON.stringify(text)})}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function valueMatchesPlaygroundDefault(
|
|
208
|
+
control: PlaygroundControl,
|
|
209
|
+
value: string | number | boolean | undefined,
|
|
210
|
+
): boolean {
|
|
211
|
+
switch (control.type) {
|
|
212
|
+
case "boolean":
|
|
213
|
+
return Boolean(value) === control.default;
|
|
214
|
+
case "number": {
|
|
215
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
216
|
+
return Number.isFinite(n) && n === control.default;
|
|
217
|
+
}
|
|
218
|
+
case "string":
|
|
219
|
+
case "select":
|
|
220
|
+
return String(value ?? "") === String(control.default);
|
|
221
|
+
default:
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function genericUsageSnippet(
|
|
227
|
+
exportName: string,
|
|
228
|
+
values: PlaygroundArgs,
|
|
229
|
+
controls: PlaygroundControl[],
|
|
230
|
+
): string {
|
|
231
|
+
const controlByKey = new Map(controls.map((c) => [c.key, c] as const));
|
|
232
|
+
|
|
233
|
+
const emitPropKey = (key: string): boolean => {
|
|
234
|
+
const c = controlByKey.get(key);
|
|
235
|
+
if (!c) return true;
|
|
236
|
+
return !valueMatchesPlaygroundDefault(c, values[key]);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const hasChildrenKey = Object.prototype.hasOwnProperty.call(
|
|
240
|
+
values,
|
|
241
|
+
"children",
|
|
242
|
+
);
|
|
243
|
+
const childVal = hasChildrenKey ? values.children : undefined;
|
|
244
|
+
|
|
245
|
+
const propKeys = Object.keys(values)
|
|
246
|
+
.filter((k) => k !== "children")
|
|
247
|
+
.filter(emitPropKey)
|
|
248
|
+
.sort((a, b) => a.localeCompare(b));
|
|
249
|
+
const propsStr = propKeys
|
|
250
|
+
.map((k) => `${k}={${JSON.stringify(values[k])}}`)
|
|
251
|
+
.join(" ");
|
|
252
|
+
|
|
253
|
+
const openWithProps =
|
|
254
|
+
propKeys.length === 0 ? `<${exportName}` : `<${exportName} ${propsStr}`;
|
|
255
|
+
|
|
256
|
+
if (!hasChildrenKey) {
|
|
257
|
+
return propKeys.length === 0 ? `<${exportName} />` : `${openWithProps} />`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (typeof childVal === "boolean") {
|
|
261
|
+
const allKeys = Object.keys(values)
|
|
262
|
+
.filter(emitPropKey)
|
|
263
|
+
.sort((a, b) => a.localeCompare(b));
|
|
264
|
+
const allProps = allKeys
|
|
265
|
+
.map((k) => `${k}={${JSON.stringify(values[k])}}`)
|
|
266
|
+
.join(" ");
|
|
267
|
+
return allKeys.length === 0
|
|
268
|
+
? `<${exportName} />`
|
|
269
|
+
: `<${exportName} ${allProps} />`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const asText =
|
|
273
|
+
typeof childVal === "number" ? String(childVal) : String(childVal ?? "");
|
|
274
|
+
if (asText.length === 0) {
|
|
275
|
+
return propKeys.length === 0 ? `<${exportName} />` : `${openWithProps} />`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const body = jsxTextOrStringifyExpression(asText);
|
|
279
|
+
return propKeys.length === 0
|
|
280
|
+
? `<${exportName}>${body}</${exportName}>`
|
|
281
|
+
: `${openWithProps}>${body}</${exportName}>`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Sidebar / URL id — matches catalog component names (`export_name`). */
|
|
285
|
+
export function playgroundCatalogId(spec: PlaygroundSpec): string {
|
|
286
|
+
return spec.export_name;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Resolve a catalog name to a loaded playground entry. */
|
|
290
|
+
export function resolvePlaygroundEntry(
|
|
291
|
+
entries: PlaygroundEntry[],
|
|
292
|
+
catalogName: string,
|
|
293
|
+
): PlaygroundEntry | undefined {
|
|
294
|
+
const byId = entries.find((e) => e.id === catalogName);
|
|
295
|
+
if (byId) return byId;
|
|
296
|
+
return entries.find(
|
|
297
|
+
(e) =>
|
|
298
|
+
e.meta.title === catalogName ||
|
|
299
|
+
e.modulePath.includes(`/${catalogName}.`) ||
|
|
300
|
+
e.modulePath.endsWith(`/${catalogName}.tsx`) ||
|
|
301
|
+
e.modulePath.endsWith(`/${catalogName}.jsx`),
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Build playground entries from report specs + eager Vite modules. */
|
|
306
|
+
export function buildPlaygroundEntriesFromReportWithSkips(
|
|
307
|
+
report: WorkspaceReport | null | undefined,
|
|
308
|
+
modules: BuildPlaygroundModules,
|
|
309
|
+
options: BuildPlaygroundOptions = {},
|
|
310
|
+
): BuildPlaygroundResult {
|
|
311
|
+
const specs = report?.playgrounds;
|
|
312
|
+
if (!specs?.length) return { entries: [], skipped: [] };
|
|
313
|
+
|
|
314
|
+
const globKeyFromRelPath =
|
|
315
|
+
options.globKeyFromRelPath ?? defaultGlobKeyFromRelPath;
|
|
316
|
+
const controlOverrides = options.controlOverrides ?? {};
|
|
317
|
+
const staticDefaultsMap = options.staticDefaults ?? {};
|
|
318
|
+
|
|
319
|
+
const skipped = diagnosePlaygroundJoinSkips(report, modules, {
|
|
320
|
+
globKeyFromRelPath,
|
|
321
|
+
});
|
|
322
|
+
const shouldLog =
|
|
323
|
+
options.logJoinSkips ??
|
|
324
|
+
(typeof import.meta !== "undefined" && Boolean(import.meta.env?.DEV));
|
|
325
|
+
if (shouldLog) logPlaygroundJoinSkips(skipped);
|
|
326
|
+
|
|
327
|
+
const out: PlaygroundEntry[] = [];
|
|
328
|
+
for (const spec of specs) {
|
|
329
|
+
const globKey = globKeyFromRelPath(spec.rel_path);
|
|
330
|
+
const mod = modules[globKey];
|
|
331
|
+
if (!mod) continue;
|
|
332
|
+
const Cmp = getExport(mod, spec.export_name);
|
|
333
|
+
if (!Cmp) continue;
|
|
334
|
+
|
|
335
|
+
const catalogId = playgroundCatalogId(spec);
|
|
336
|
+
const declared = spec.declared_props ?? [];
|
|
337
|
+
const propKinds = normalizedPropKinds(spec.declared_prop_kinds);
|
|
338
|
+
const controls = controlsForSpec(
|
|
339
|
+
catalogId,
|
|
340
|
+
declared,
|
|
341
|
+
propKinds,
|
|
342
|
+
controlOverrides,
|
|
343
|
+
);
|
|
344
|
+
const staticDefaults =
|
|
345
|
+
staticDefaultsMap[catalogId] ??
|
|
346
|
+
staticDefaultsMap[spec.id] ??
|
|
347
|
+
{};
|
|
348
|
+
|
|
349
|
+
function Preview({ values }: { values: PlaygroundArgs }) {
|
|
350
|
+
const fromValues = valuesToComponentProps(declared, values, propKinds);
|
|
351
|
+
const merged = mergeStaticDefaults(fromValues, staticDefaults);
|
|
352
|
+
return createElement(Cmp, merged);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const meta: PlaygroundMeta = {
|
|
356
|
+
id: catalogId,
|
|
357
|
+
title: catalogId,
|
|
358
|
+
...(spec.group ? { group: spec.group } : {}),
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
out.push({
|
|
362
|
+
id: catalogId,
|
|
363
|
+
meta,
|
|
364
|
+
modulePath: globKey,
|
|
365
|
+
controls,
|
|
366
|
+
usageSnippet: (values) =>
|
|
367
|
+
genericUsageSnippet(spec.export_name, values, controls),
|
|
368
|
+
Preview: Preview as PlaygroundPreviewComponent,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
out.sort((a, b) => {
|
|
373
|
+
const ga = a.meta.group ?? "";
|
|
374
|
+
const gb = b.meta.group ?? "";
|
|
375
|
+
if (ga !== gb) return ga.localeCompare(gb);
|
|
376
|
+
return a.meta.title.localeCompare(b.meta.title);
|
|
377
|
+
});
|
|
378
|
+
return { entries: out, skipped };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Build playground entries from report specs + eager Vite modules. */
|
|
382
|
+
export function buildPlaygroundEntriesFromReport(
|
|
383
|
+
report: WorkspaceReport | null | undefined,
|
|
384
|
+
modules: BuildPlaygroundModules,
|
|
385
|
+
options: BuildPlaygroundOptions = {},
|
|
386
|
+
): PlaygroundEntry[] {
|
|
387
|
+
return buildPlaygroundEntriesFromReportWithSkips(report, modules, options)
|
|
388
|
+
.entries;
|
|
389
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { PlaygroundEntry } from "../types/playground";
|
|
2
|
+
import type { WorkspaceReport } from "../types/report";
|
|
3
|
+
import {
|
|
4
|
+
buildPlaygroundEntriesFromReportWithSkips,
|
|
5
|
+
type BuildPlaygroundModules,
|
|
6
|
+
type BuildPlaygroundOptions,
|
|
7
|
+
type BuildPlaygroundResult,
|
|
8
|
+
} from "./buildPlaygroundEntriesFromReport";
|
|
9
|
+
import { defaultConsumerGlobKeyFromRelPath } from "./playgroundJoin";
|
|
10
|
+
|
|
11
|
+
export type CreatePlaygroundRegistryOptions = Omit<
|
|
12
|
+
BuildPlaygroundOptions,
|
|
13
|
+
"globKeyFromRelPath"
|
|
14
|
+
> & {
|
|
15
|
+
/** Defaults to {@link defaultConsumerGlobKeyFromRelPath} (`src/playground` → `src/components`). */
|
|
16
|
+
globKeyFromRelPath?: (relPath: string) => string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Factory for consumer apps: pass eager `import.meta.glob` modules from your Vite app.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* const modules = import.meta.glob("../components/**\/*.{tsx,jsx}", { eager: true });
|
|
25
|
+
* export const buildPlaygroundEntries = createPlaygroundRegistry(modules);
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function createPlaygroundRegistry(
|
|
29
|
+
modules: BuildPlaygroundModules,
|
|
30
|
+
options: CreatePlaygroundRegistryOptions = {},
|
|
31
|
+
): (report: WorkspaceReport | null | undefined) => BuildPlaygroundResult {
|
|
32
|
+
const globKeyFromRelPath =
|
|
33
|
+
options.globKeyFromRelPath ?? defaultConsumerGlobKeyFromRelPath;
|
|
34
|
+
return (report) =>
|
|
35
|
+
buildPlaygroundEntriesFromReportWithSkips(report, modules, {
|
|
36
|
+
...options,
|
|
37
|
+
globKeyFromRelPath,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Convenience when you only need entries (skips are still logged in dev). */
|
|
42
|
+
export function createPlaygroundRegistryEntriesOnly(
|
|
43
|
+
modules: BuildPlaygroundModules,
|
|
44
|
+
options: CreatePlaygroundRegistryOptions = {},
|
|
45
|
+
): (report: WorkspaceReport | null | undefined) => PlaygroundEntry[] {
|
|
46
|
+
const build = createPlaygroundRegistry(modules, options);
|
|
47
|
+
return (report) => build(report).entries;
|
|
48
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { WorkspaceReport } from "../types/report";
|
|
3
|
+
import {
|
|
4
|
+
defaultConsumerGlobKeyFromRelPath,
|
|
5
|
+
diagnosePlaygroundJoinSkips,
|
|
6
|
+
} from "./playgroundJoin";
|
|
7
|
+
import { buildPlaygroundEntriesFromReportWithSkips } from "./buildPlaygroundEntriesFromReport";
|
|
8
|
+
|
|
9
|
+
describe("defaultConsumerGlobKeyFromRelPath", () => {
|
|
10
|
+
it("maps nested ui paths for src/playground registry", () => {
|
|
11
|
+
expect(defaultConsumerGlobKeyFromRelPath("src/components/ui/button.tsx")).toBe(
|
|
12
|
+
"../components/ui/button.tsx",
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("maps flat component paths", () => {
|
|
17
|
+
expect(defaultConsumerGlobKeyFromRelPath("src/components/Button.tsx")).toBe(
|
|
18
|
+
"../components/Button.tsx",
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("diagnosePlaygroundJoinSkips", () => {
|
|
24
|
+
const report: WorkspaceReport = {
|
|
25
|
+
root: "/repo",
|
|
26
|
+
files: [],
|
|
27
|
+
findings: [],
|
|
28
|
+
scores: {
|
|
29
|
+
system_health: 0,
|
|
30
|
+
token_adoption: 0,
|
|
31
|
+
component_adoption: 0,
|
|
32
|
+
ux_consistency: 0,
|
|
33
|
+
},
|
|
34
|
+
ownership: [],
|
|
35
|
+
duplicate_components: [],
|
|
36
|
+
usage_by_component: [],
|
|
37
|
+
playgrounds: [
|
|
38
|
+
{
|
|
39
|
+
id: "Button",
|
|
40
|
+
export_name: "Button",
|
|
41
|
+
rel_path: "src/components/ui/button.tsx",
|
|
42
|
+
declared_props: [],
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
it("flags module_not_found when glob key is missing", () => {
|
|
48
|
+
const skipped = diagnosePlaygroundJoinSkips(report, {}, {
|
|
49
|
+
globKeyFromRelPath: defaultConsumerGlobKeyFromRelPath,
|
|
50
|
+
});
|
|
51
|
+
expect(skipped).toHaveLength(1);
|
|
52
|
+
expect(skipped[0]?.reason).toBe("module_not_found");
|
|
53
|
+
expect(skipped[0]?.globKey).toBe("../components/ui/button.tsx");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("joins when module and export exist", () => {
|
|
57
|
+
const key = defaultConsumerGlobKeyFromRelPath("src/components/ui/button.tsx");
|
|
58
|
+
const modules = {
|
|
59
|
+
[key]: {
|
|
60
|
+
Button: function Button() {
|
|
61
|
+
return null;
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
const { entries, skipped } = buildPlaygroundEntriesFromReportWithSkips(
|
|
66
|
+
report,
|
|
67
|
+
modules,
|
|
68
|
+
{ globKeyFromRelPath: defaultConsumerGlobKeyFromRelPath, logJoinSkips: false },
|
|
69
|
+
);
|
|
70
|
+
expect(skipped).toHaveLength(0);
|
|
71
|
+
expect(entries).toHaveLength(1);
|
|
72
|
+
expect(entries[0]?.id).toBe("Button");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { PlaygroundSpec, WorkspaceReport } from "../types/report";
|
|
2
|
+
import type { BuildPlaygroundModules, BuildPlaygroundOptions } from "./buildPlaygroundEntriesFromReport";
|
|
3
|
+
|
|
4
|
+
export type PlaygroundJoinSkipReason = "module_not_found" | "export_not_found";
|
|
5
|
+
|
|
6
|
+
export type PlaygroundJoinSkip = {
|
|
7
|
+
export_name: string;
|
|
8
|
+
rel_path: string;
|
|
9
|
+
globKey: string;
|
|
10
|
+
reason: PlaygroundJoinSkipReason;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Maps report `rel_path` to a Vite `import.meta.glob` key when the registry lives in
|
|
15
|
+
* `src/playground/` and components live under `src/components/`.
|
|
16
|
+
*
|
|
17
|
+
* Example: `src/components/ui/button.tsx` → `../components/ui/button.tsx`
|
|
18
|
+
*/
|
|
19
|
+
export function defaultConsumerGlobKeyFromRelPath(relPath: string): string {
|
|
20
|
+
const trimmed = relPath.replace(/^\/+/, "").replace(/^src\//, "");
|
|
21
|
+
return `../${trimmed}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function defaultEmbedGlobKeyFromRelPath(relPath: string): string {
|
|
25
|
+
const trimmed = relPath.replace(/^\/+/, "");
|
|
26
|
+
return `@dslint-scan/${trimmed}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getExport(
|
|
30
|
+
mod: Record<string, unknown>,
|
|
31
|
+
exportName: string,
|
|
32
|
+
): unknown {
|
|
33
|
+
const x = mod[exportName];
|
|
34
|
+
return typeof x === "function" ? x : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* For each playground spec, explain why it would not join to `modules`.
|
|
39
|
+
*/
|
|
40
|
+
export function diagnosePlaygroundJoinSkips(
|
|
41
|
+
report: WorkspaceReport | null | undefined,
|
|
42
|
+
modules: BuildPlaygroundModules,
|
|
43
|
+
options: Pick<BuildPlaygroundOptions, "globKeyFromRelPath"> = {},
|
|
44
|
+
): PlaygroundJoinSkip[] {
|
|
45
|
+
const specs = report?.playgrounds;
|
|
46
|
+
if (!specs?.length) return [];
|
|
47
|
+
|
|
48
|
+
const globKeyFromRelPath =
|
|
49
|
+
options.globKeyFromRelPath ?? defaultEmbedGlobKeyFromRelPath;
|
|
50
|
+
|
|
51
|
+
const skipped: PlaygroundJoinSkip[] = [];
|
|
52
|
+
for (const spec of specs) {
|
|
53
|
+
const globKey = globKeyFromRelPath(spec.rel_path);
|
|
54
|
+
const mod = modules[globKey];
|
|
55
|
+
if (!mod) {
|
|
56
|
+
skipped.push({
|
|
57
|
+
export_name: spec.export_name,
|
|
58
|
+
rel_path: spec.rel_path,
|
|
59
|
+
globKey,
|
|
60
|
+
reason: "module_not_found",
|
|
61
|
+
});
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!getExport(mod, spec.export_name)) {
|
|
65
|
+
skipped.push({
|
|
66
|
+
export_name: spec.export_name,
|
|
67
|
+
rel_path: spec.rel_path,
|
|
68
|
+
globKey,
|
|
69
|
+
reason: "export_not_found",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return skipped;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Dev-only: log skipped playground joins (module glob / export name mismatches).
|
|
78
|
+
*/
|
|
79
|
+
export function logPlaygroundJoinSkips(
|
|
80
|
+
skipped: PlaygroundJoinSkip[],
|
|
81
|
+
options?: { label?: string },
|
|
82
|
+
): void {
|
|
83
|
+
if (!skipped.length) return;
|
|
84
|
+
if (typeof import.meta !== "undefined" && !import.meta.env?.DEV) return;
|
|
85
|
+
|
|
86
|
+
const label = options?.label ?? "[dslinter] playground preview";
|
|
87
|
+
console.warn(
|
|
88
|
+
`${label}: ${skipped.length} component(s) have a scan row but no live preview.`,
|
|
89
|
+
);
|
|
90
|
+
for (const s of skipped.slice(0, 12)) {
|
|
91
|
+
const hint =
|
|
92
|
+
s.reason === "module_not_found"
|
|
93
|
+
? `add import.meta.glob key "${s.globKey}" (from rel_path "${s.rel_path}")`
|
|
94
|
+
: `export function ${s.export_name} from "${s.rel_path}"`;
|
|
95
|
+
console.warn(` - ${s.export_name}: ${hint}`);
|
|
96
|
+
}
|
|
97
|
+
if (skipped.length > 12) {
|
|
98
|
+
console.warn(` … and ${skipped.length - 12} more`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function findPlaygroundSpec(
|
|
103
|
+
report: WorkspaceReport | null | undefined,
|
|
104
|
+
componentId: string,
|
|
105
|
+
): PlaygroundSpec | undefined {
|
|
106
|
+
return report?.playgrounds?.find(
|
|
107
|
+
(p) => p.export_name === componentId || p.id === componentId,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function findPlaygroundJoinSkip(
|
|
112
|
+
skipped: PlaygroundJoinSkip[] | undefined,
|
|
113
|
+
componentId: string,
|
|
114
|
+
): PlaygroundJoinSkip | undefined {
|
|
115
|
+
return skipped?.find((s) => s.export_name === componentId);
|
|
116
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { aggregateDefinitions } from "../dashboard/aggregate";
|
|
2
|
+
import type { LintFinding, WorkspaceReport } from "../types/report";
|
|
3
|
+
import { pathsMatch } from "./modulePathMatch";
|
|
4
|
+
|
|
5
|
+
/** Findings on any file where the component is defined. */
|
|
6
|
+
export function findingsForComponent(
|
|
7
|
+
report: WorkspaceReport | null | undefined,
|
|
8
|
+
componentName: string,
|
|
9
|
+
): LintFinding[] {
|
|
10
|
+
if (!report) return [];
|
|
11
|
+
const defs = aggregateDefinitions(report);
|
|
12
|
+
const sites = defs.get(componentName) ?? [];
|
|
13
|
+
if (sites.length === 0) return [];
|
|
14
|
+
|
|
15
|
+
const rows = report.findings.filter((f) =>
|
|
16
|
+
sites.some((site) => pathsMatch(f.path, site.path)),
|
|
17
|
+
);
|
|
18
|
+
return [...rows].sort(
|
|
19
|
+
(a, b) =>
|
|
20
|
+
a.path.localeCompare(b.path) ||
|
|
21
|
+
(a.line ?? 0) - (b.line ?? 0) ||
|
|
22
|
+
a.rule_id.localeCompare(b.rule_id),
|
|
23
|
+
);
|
|
24
|
+
}
|