dslinter 0.1.5 → 0.2.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 +112 -0
- package/README.md +54 -27
- package/bin/dslinter.mjs +26 -5
- package/bin/lib/config-hide-component.mjs +44 -0
- package/bin/lib/config-hide-component.test.mjs +33 -0
- package/bin/lib/constants.mjs +20 -0
- package/bin/lib/dev-banner.mjs +16 -51
- package/bin/lib/dev-banner.test.mjs +20 -18
- package/bin/lib/enrich-playgrounds-from-ts.mjs +201 -0
- package/bin/lib/enrich-playgrounds-from-ts.test.mjs +74 -0
- package/bin/lib/enrich-report-cli.mjs +14 -0
- package/bin/lib/env.mjs +20 -0
- package/bin/lib/infer-prop-types-from-ts.mjs +381 -0
- package/bin/lib/infer-prop-types-from-ts.test.mjs +174 -0
- package/bin/lib/parse-args.mjs +13 -1
- package/bin/lib/parse-args.test.mjs +7 -1
- package/bin/lib/paths.mjs +8 -0
- package/bin/lib/project-root.mjs +92 -24
- package/bin/lib/project-root.test.mjs +52 -0
- package/bin/lib/prompt.mjs +31 -0
- package/bin/lib/resolve-project.mjs +78 -0
- package/bin/lib/resolve-project.test.mjs +74 -0
- package/bin/lib/run-scanner.mjs +40 -6
- package/bin/lib/scaffold-config.mjs +163 -0
- package/bin/lib/scaffold-config.test.mjs +43 -0
- package/bin/lib/scan-host.mjs +44 -0
- package/bin/lib/scan-host.test.mjs +41 -0
- package/bin/lib/setup-readiness.mjs +153 -0
- package/bin/lib/setup-readiness.test.mjs +32 -0
- package/bin/modes/build.mjs +31 -6
- package/bin/modes/dev.mjs +56 -13
- package/bin/modes/init.mjs +35 -47
- package/bin/modes/init.test.mjs +16 -0
- package/bin/modes/mcp.mjs +49 -0
- package/bin/modes/report.mjs +29 -4
- package/bin/modes/watch.mjs +85 -0
- package/dashboard-dist/assets/DashboardLayoutAuto-Bja3BuZZ.css +1 -0
- package/dashboard-dist/assets/DashboardLayoutAuto-h0gP_iKd.js +1 -0
- package/dashboard-dist/assets/axe-DDaE9JTN.js +20 -0
- package/dashboard-dist/assets/index-B9sZ6wHm.css +1 -0
- package/dashboard-dist/assets/index-DIDBt5ed.js +218 -0
- package/dashboard-dist/index.html +2 -2
- package/index.cjs +53 -52
- package/index.d.ts +3 -0
- package/package.json +18 -12
- package/shared/env.ts +15 -0
- package/shared/paths.ts +8 -0
- package/shared/reportPath.test.ts +19 -0
- package/shared/reportPath.ts +12 -0
- package/shared/servePort.ts +16 -0
- package/src/components/ComponentInspectPane.tsx +67 -19
- package/src/components/ComponentPlaygroundPane.tsx +262 -113
- package/src/components/DashboardCommandPalette.tsx +6 -11
- package/src/components/GovernancePane.tsx +2 -2
- package/src/components/HideFromCatalogButton.tsx +44 -0
- package/src/components/OpenInEditorButton.tsx +36 -0
- package/src/components/PlaygroundA11yAndCode.tsx +53 -53
- package/src/components/PlaygroundAppThemeWrapper.tsx +82 -0
- package/src/components/PlaygroundControls.tsx +5 -11
- package/src/components/PlaygroundPreviewErrorBoundary.tsx +54 -0
- package/src/components/PlaygroundUsageCode.tsx +6 -4
- package/src/components/PlaygroundVariantMatrix.tsx +101 -34
- package/src/components/Section.tsx +5 -2
- package/src/components/Sidebar.tsx +131 -46
- package/src/components/TruncatedPath.tsx +44 -0
- package/src/components/controlApiTable.test.ts +29 -0
- package/src/components/controlApiTable.ts +3 -0
- package/src/components/playgroundUsageHighlight.ts +14 -3
- package/src/components/ui/badge.tsx +1 -1
- package/src/components/ui/table.tsx +2 -2
- package/src/dashboard/ComponentCatalog.tsx +16 -23
- package/src/dashboard/ComponentUsageDetails.tsx +6 -15
- package/src/dashboard/DashboardBody.tsx +0 -35
- package/src/dashboard/FindingsList.tsx +65 -55
- package/src/dashboard/ScannedTokenWall.tsx +3 -3
- package/src/dashboard/aggregate.test.ts +74 -0
- package/src/dashboard/aggregate.ts +145 -21
- package/src/dashboard/catalogVisibility.test.ts +93 -0
- package/src/dashboard/catalogVisibility.ts +108 -0
- package/src/dashboard/editorLink.test.ts +57 -0
- package/src/dashboard/editorLink.ts +71 -0
- package/src/dashboard/paths.test.ts +49 -0
- package/src/dashboard/paths.ts +51 -3
- package/src/dashboard/updateDslintConfig.ts +22 -0
- package/src/dashboard/useWorkspaceReport.ts +21 -17
- package/src/index.ts +26 -0
- package/src/mcp/agent-context.ts +148 -0
- package/src/mcp/agent-query.test.ts +89 -0
- package/src/mcp/agent-query.ts +373 -0
- package/src/mcp/config.ts +53 -0
- package/src/mcp/index.ts +18 -0
- package/src/mcp/normalize-paths.ts +65 -0
- package/src/mcp/report-cache.ts +209 -0
- package/src/mcp/rule-catalog.json +156 -0
- package/src/mcp/rule-catalog.ts +33 -0
- package/src/mcp/schemas.ts +54 -0
- package/src/mcp/server.test.ts +44 -0
- package/src/mcp/server.ts +343 -0
- package/src/mcp/start.ts +29 -0
- package/src/mcp/verify-loop.test.ts +49 -0
- package/src/mcp/verify-loop.ts +149 -0
- package/src/playground/appPreviewTheme.test.ts +148 -0
- package/src/playground/appPreviewTheme.ts +137 -0
- package/src/playground/buildCompoundPlaygroundEntries.test.ts +348 -0
- package/src/playground/buildCompoundPlaygroundEntries.ts +625 -0
- package/src/playground/buildPlaygroundEntriesFromReport.test.ts +420 -6
- package/src/playground/buildPlaygroundEntriesFromReport.ts +206 -285
- package/src/playground/catalogIdFromPlaygroundExport.test.ts +15 -0
- package/src/playground/catalogIdFromPlaygroundExport.ts +8 -0
- package/src/playground/collectDefinedPlaygrounds.test.ts +59 -0
- package/src/playground/collectDefinedPlaygrounds.ts +68 -0
- package/src/playground/controls.ts +177 -0
- package/src/playground/createPlaygroundRegistry.ts +1 -1
- package/src/playground/definePlayground.tsx +88 -16
- package/src/playground/definePlaygroundFromKit.ts +17 -0
- package/src/playground/embedGlobKey.ts +8 -0
- package/src/playground/enrichKitControls.test.ts +25 -0
- package/src/playground/enrichKitControls.ts +197 -0
- package/src/playground/expandPlaygroundControls.test.ts +50 -0
- package/src/playground/expandPlaygroundControls.ts +97 -0
- package/src/playground/inferKitJsx.test.ts +77 -0
- package/src/playground/inferKitJsx.ts +165 -0
- package/src/playground/inferKitParams.test.ts +41 -0
- package/src/playground/inferKitParams.ts +113 -0
- package/src/playground/inferPropTypesFromTs.d.mts +47 -0
- package/src/playground/inferPropTypesFromTs.mjs +343 -0
- package/src/playground/inferPropTypesFromTs.test.ts +227 -0
- package/src/playground/inferPropTypesFromTs.ts +17 -0
- package/src/playground/mergePlaygroundEntries.test.ts +32 -0
- package/src/playground/mergePlaygroundEntries.ts +28 -0
- package/src/playground/playgroundJoin.test.ts +79 -19
- package/src/playground/playgroundJoin.ts +47 -22
- package/src/playground/playgroundModuleExport.test.ts +42 -0
- package/src/playground/playgroundModuleExport.ts +22 -0
- package/src/playground/playgroundSpecsKey.ts +8 -0
- package/src/playground/propCoerce.ts +91 -0
- package/src/playground/scanVariantA11y.test.ts +46 -0
- package/src/playground/scanVariantA11y.ts +107 -0
- package/src/playground/snippet.ts +83 -0
- package/src/playground/usePlaygroundFromReport.test.ts +18 -8
- package/src/playground/usePlaygroundFromReport.ts +3 -1
- package/src/report/a11yForModule.ts +2 -7
- package/src/report/a11yScoring.test.ts +24 -0
- package/src/report/a11yScoring.ts +17 -0
- package/src/report/index.ts +6 -0
- package/src/shell/DashboardLayout.tsx +71 -45
- package/src/shell/DashboardLayoutAuto.tsx +0 -4
- package/src/shell/hashRoute.test.ts +7 -15
- package/src/shell/hashRoute.ts +31 -31
- package/src/shell/useHashRoute.ts +38 -13
- package/src/styles/dashboard-theme.css +18 -7
- package/src/types/controls.ts +11 -0
- package/src/types/playground.ts +4 -0
- package/src/types/report.ts +32 -9
- package/templates/playground/buildRegistry.ts +1 -1
- package/templates/vite.dslinter.snippet.ts +15 -4
- package/vite/collectScanModules.test.ts +51 -3
- package/vite/collectScanModules.ts +85 -29
- package/vite/consumer.config.mjs +6 -3
- package/vite/consumerAlias.test.ts +47 -0
- package/vite/consumerAlias.ts +114 -0
- package/vite/embedTailwindSources.test.ts +74 -0
- package/vite/embedTailwindSources.ts +97 -0
- package/vite/loadConsumerAliases.test.ts +131 -0
- package/vite/loadConsumerAliases.ts +155 -0
- package/vite/openFileInEditor.mjs +196 -0
- package/vite/openFileInEditor.test.mjs +87 -0
- package/vite/plugin.resolve.test.ts +72 -0
- package/vite/plugin.ts +216 -19
- package/vite/reportPath.test.ts +19 -0
- package/vite/resolveWayfinderImport.ts +56 -0
- package/vite/shims/inertia-react.tsx +85 -0
- package/vite/shims/wayfinder-actions.ts +33 -0
- package/vite/shims/wayfinder-routes.ts +30 -0
- package/vite/shims/ziggy-js.ts +12 -0
- package/dashboard-dist/assets/DashboardLayoutAuto-BPPtPsYh.css +0 -1
- package/dashboard-dist/assets/DashboardLayoutAuto-Dp3bAQxH.js +0 -1
- package/dashboard-dist/assets/index-DsjwnDdX.js +0 -206
- package/dashboard-dist/assets/index-jaCmZJlW.css +0 -1
- package/src/components/playgroundUsageTwoslash.ts +0 -69
- package/templates/vite.dslint-scan-alias.snippet.ts +0 -4
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import type { WorkspaceReport } from "../types/report";
|
|
3
|
-
import {
|
|
4
|
-
defaultConsumerGlobKeyFromRelPath,
|
|
5
|
-
diagnosePlaygroundJoinSkips,
|
|
6
|
-
} from "./playgroundJoin";
|
|
3
|
+
import { defaultConsumerGlobKeyFromRelPath, diagnosePlaygroundJoinSkips } from "./playgroundJoin";
|
|
7
4
|
import { buildPlaygroundEntriesFromReportWithSkips } from "./buildPlaygroundEntriesFromReport";
|
|
8
5
|
|
|
9
6
|
describe("defaultConsumerGlobKeyFromRelPath", () => {
|
|
@@ -20,11 +17,9 @@ describe("defaultConsumerGlobKeyFromRelPath", () => {
|
|
|
20
17
|
});
|
|
21
18
|
|
|
22
19
|
it("maps Laravel resources/js paths", () => {
|
|
23
|
-
expect(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
),
|
|
27
|
-
).toBe("../Components/Icons/Activity.tsx");
|
|
20
|
+
expect(defaultConsumerGlobKeyFromRelPath("resources/js/Components/Icons/Activity.tsx")).toBe(
|
|
21
|
+
"../Components/Icons/Activity.tsx",
|
|
22
|
+
);
|
|
28
23
|
});
|
|
29
24
|
});
|
|
30
25
|
|
|
@@ -34,12 +29,11 @@ describe("diagnosePlaygroundJoinSkips", () => {
|
|
|
34
29
|
files: [],
|
|
35
30
|
findings: [],
|
|
36
31
|
scores: {
|
|
37
|
-
|
|
38
|
-
token_adoption: 0,
|
|
39
|
-
component_adoption: 0,
|
|
32
|
+
design_system_health: 0,
|
|
40
33
|
ux_consistency: 0,
|
|
34
|
+
accessibility: 0,
|
|
35
|
+
maintainability: 0,
|
|
41
36
|
},
|
|
42
|
-
ownership: [],
|
|
43
37
|
duplicate_components: [],
|
|
44
38
|
usage_by_component: [],
|
|
45
39
|
playgrounds: [
|
|
@@ -53,9 +47,13 @@ describe("diagnosePlaygroundJoinSkips", () => {
|
|
|
53
47
|
};
|
|
54
48
|
|
|
55
49
|
it("flags module_not_found when glob key is missing", () => {
|
|
56
|
-
const skipped = diagnosePlaygroundJoinSkips(
|
|
57
|
-
|
|
58
|
-
|
|
50
|
+
const skipped = diagnosePlaygroundJoinSkips(
|
|
51
|
+
report,
|
|
52
|
+
{},
|
|
53
|
+
{
|
|
54
|
+
globKeyFromRelPath: defaultConsumerGlobKeyFromRelPath,
|
|
55
|
+
},
|
|
56
|
+
);
|
|
59
57
|
expect(skipped).toHaveLength(1);
|
|
60
58
|
expect(skipped[0]?.reason).toBe("module_not_found");
|
|
61
59
|
expect(skipped[0]?.globKey).toBe("../components/ui/button.tsx");
|
|
@@ -70,13 +68,75 @@ describe("diagnosePlaygroundJoinSkips", () => {
|
|
|
70
68
|
},
|
|
71
69
|
},
|
|
72
70
|
};
|
|
71
|
+
const { entries, skipped } = buildPlaygroundEntriesFromReportWithSkips(report, modules, {
|
|
72
|
+
globKeyFromRelPath: defaultConsumerGlobKeyFromRelPath,
|
|
73
|
+
logJoinSkips: false,
|
|
74
|
+
});
|
|
75
|
+
expect(skipped).toHaveLength(0);
|
|
76
|
+
expect(entries).toHaveLength(1);
|
|
77
|
+
expect(entries[0]?.id).toBe("Button");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("joins default-exported components by export_name", () => {
|
|
81
|
+
const defaultExportReport: WorkspaceReport = {
|
|
82
|
+
...report,
|
|
83
|
+
playgrounds: [
|
|
84
|
+
{
|
|
85
|
+
id: "AppLogoIcon",
|
|
86
|
+
export_name: "AppLogoIcon",
|
|
87
|
+
rel_path: "resources/js/components/app-logo-icon.tsx",
|
|
88
|
+
declared_props: [],
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
const key = defaultConsumerGlobKeyFromRelPath(
|
|
93
|
+
"resources/js/components/app-logo-icon.tsx",
|
|
94
|
+
);
|
|
95
|
+
const modules = {
|
|
96
|
+
[key]: {
|
|
97
|
+
default: function AppLogoIcon() {
|
|
98
|
+
return null;
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
};
|
|
73
102
|
const { entries, skipped } = buildPlaygroundEntriesFromReportWithSkips(
|
|
74
|
-
|
|
103
|
+
defaultExportReport,
|
|
75
104
|
modules,
|
|
76
|
-
{
|
|
105
|
+
{
|
|
106
|
+
globKeyFromRelPath: defaultConsumerGlobKeyFromRelPath,
|
|
107
|
+
logJoinSkips: false,
|
|
108
|
+
},
|
|
77
109
|
);
|
|
78
110
|
expect(skipped).toHaveLength(0);
|
|
79
111
|
expect(entries).toHaveLength(1);
|
|
80
|
-
expect(entries[0]?.id).toBe("
|
|
112
|
+
expect(entries[0]?.id).toBe("AppLogoIcon");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("joins bare rel_path via unique suffix when subdirectory scan shortened the path", () => {
|
|
116
|
+
const subdirReport: WorkspaceReport = {
|
|
117
|
+
...report,
|
|
118
|
+
playgrounds: [
|
|
119
|
+
{
|
|
120
|
+
id: "Button",
|
|
121
|
+
export_name: "Button",
|
|
122
|
+
rel_path: "Button.tsx",
|
|
123
|
+
declared_props: [],
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
const modules = {
|
|
128
|
+
"../Components/Button.tsx": {
|
|
129
|
+
Button: function Button() {
|
|
130
|
+
return null;
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
const { entries, skipped } = buildPlaygroundEntriesFromReportWithSkips(subdirReport, modules, {
|
|
135
|
+
globKeyFromRelPath: defaultConsumerGlobKeyFromRelPath,
|
|
136
|
+
logJoinSkips: false,
|
|
137
|
+
});
|
|
138
|
+
expect(skipped).toHaveLength(0);
|
|
139
|
+
expect(entries).toHaveLength(1);
|
|
140
|
+
expect(entries[0]?.modulePath).toBe("../Components/Button.tsx");
|
|
81
141
|
});
|
|
82
142
|
});
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { PlaygroundSpec, WorkspaceReport } from "../types/report";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
BuildPlaygroundModules,
|
|
4
|
+
BuildPlaygroundOptions,
|
|
5
|
+
} from "./buildPlaygroundEntriesFromReport";
|
|
6
|
+
import { defaultEmbedGlobKeyFromRelPath } from "./embedGlobKey";
|
|
7
|
+
import { getModuleExport } from "./playgroundModuleExport";
|
|
3
8
|
|
|
4
9
|
export type PlaygroundJoinSkipReason = "module_not_found" | "export_not_found";
|
|
5
10
|
|
|
@@ -12,6 +17,10 @@ export type PlaygroundJoinSkip = {
|
|
|
12
17
|
|
|
13
18
|
const DEFAULT_CONSUMER_STRIP_PREFIXES = ["src/", "resources/js/"] as const;
|
|
14
19
|
|
|
20
|
+
export function viteDevMode(): boolean {
|
|
21
|
+
return Boolean((import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV);
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
/**
|
|
16
25
|
* Build `globKeyFromRelPath` for a registry file one level below a source root
|
|
17
26
|
* (e.g. `src/playground/` or `resources/js/playground/`).
|
|
@@ -50,17 +59,36 @@ export function defaultConsumerGlobKeyFromRelPath(relPath: string): string {
|
|
|
50
59
|
return createConsumerGlobKeyFromRelPath()(relPath);
|
|
51
60
|
}
|
|
52
61
|
|
|
53
|
-
export
|
|
62
|
+
export { defaultEmbedGlobKeyFromRelPath } from "./embedGlobKey";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* When `rel_path` is a bare filename (subdirectory scan), find a unique module key
|
|
66
|
+
* whose path ends with `/${relPath}` (consumer glob or embed keys).
|
|
67
|
+
*/
|
|
68
|
+
export function findUniqueModuleKeyBySuffix(
|
|
69
|
+
relPath: string,
|
|
70
|
+
modules: BuildPlaygroundModules,
|
|
71
|
+
): string | undefined {
|
|
54
72
|
const trimmed = relPath.replace(/^\/+/, "");
|
|
55
|
-
return
|
|
73
|
+
if (trimmed.includes("/")) return undefined;
|
|
74
|
+
|
|
75
|
+
const suffix = `/${trimmed}`;
|
|
76
|
+
const candidates = Object.keys(modules).filter((key) => key.endsWith(suffix));
|
|
77
|
+
const unique = [...new Set(candidates)];
|
|
78
|
+
return unique.length === 1 ? unique[0] : undefined;
|
|
56
79
|
}
|
|
57
80
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Resolve a module map key for a report `rel_path` (exact glob key, then suffix fallback).
|
|
83
|
+
*/
|
|
84
|
+
export function resolveModuleKeyForRelPath(
|
|
85
|
+
relPath: string,
|
|
86
|
+
modules: BuildPlaygroundModules,
|
|
87
|
+
globKeyFromRelPath: (relPath: string) => string,
|
|
88
|
+
): string | undefined {
|
|
89
|
+
const primary = globKeyFromRelPath(relPath);
|
|
90
|
+
if (modules[primary]) return primary;
|
|
91
|
+
return findUniqueModuleKeyBySuffix(relPath, modules);
|
|
64
92
|
}
|
|
65
93
|
|
|
66
94
|
/**
|
|
@@ -74,13 +102,13 @@ export function diagnosePlaygroundJoinSkips(
|
|
|
74
102
|
const specs = report?.playgrounds;
|
|
75
103
|
if (!specs?.length) return [];
|
|
76
104
|
|
|
77
|
-
const globKeyFromRelPath =
|
|
78
|
-
options.globKeyFromRelPath ?? defaultEmbedGlobKeyFromRelPath;
|
|
105
|
+
const globKeyFromRelPath = options.globKeyFromRelPath ?? defaultEmbedGlobKeyFromRelPath;
|
|
79
106
|
|
|
80
107
|
const skipped: PlaygroundJoinSkip[] = [];
|
|
81
108
|
for (const spec of specs) {
|
|
82
109
|
const globKey = globKeyFromRelPath(spec.rel_path);
|
|
83
|
-
const
|
|
110
|
+
const resolvedKey = resolveModuleKeyForRelPath(spec.rel_path, modules, globKeyFromRelPath);
|
|
111
|
+
const mod = resolvedKey ? modules[resolvedKey] : undefined;
|
|
84
112
|
if (!mod) {
|
|
85
113
|
skipped.push({
|
|
86
114
|
export_name: spec.export_name,
|
|
@@ -90,11 +118,11 @@ export function diagnosePlaygroundJoinSkips(
|
|
|
90
118
|
});
|
|
91
119
|
continue;
|
|
92
120
|
}
|
|
93
|
-
if (!
|
|
121
|
+
if (!getModuleExport(mod, spec.export_name)) {
|
|
94
122
|
skipped.push({
|
|
95
123
|
export_name: spec.export_name,
|
|
96
124
|
rel_path: spec.rel_path,
|
|
97
|
-
globKey,
|
|
125
|
+
globKey: resolvedKey ?? globKey,
|
|
98
126
|
reason: "export_not_found",
|
|
99
127
|
});
|
|
100
128
|
}
|
|
@@ -103,19 +131,18 @@ export function diagnosePlaygroundJoinSkips(
|
|
|
103
131
|
}
|
|
104
132
|
|
|
105
133
|
/**
|
|
106
|
-
*
|
|
134
|
+
* Log skipped playground joins (module glob / export name mismatches).
|
|
135
|
+
* Opt-in via `logJoinSkips: true` on {@link buildPlaygroundEntriesFromReportWithSkips}.
|
|
107
136
|
*/
|
|
108
137
|
export function logPlaygroundJoinSkips(
|
|
109
138
|
skipped: PlaygroundJoinSkip[],
|
|
110
139
|
options?: { label?: string },
|
|
111
140
|
): void {
|
|
112
141
|
if (!skipped.length) return;
|
|
113
|
-
if (
|
|
142
|
+
if (!viteDevMode()) return;
|
|
114
143
|
|
|
115
144
|
const label = options?.label ?? "[dslinter] playground preview";
|
|
116
|
-
console.warn(
|
|
117
|
-
`${label}: ${skipped.length} component(s) have a scan row but no live preview.`,
|
|
118
|
-
);
|
|
145
|
+
console.warn(`${label}: ${skipped.length} component(s) have a scan row but no live preview.`);
|
|
119
146
|
for (const s of skipped.slice(0, 12)) {
|
|
120
147
|
const hint =
|
|
121
148
|
s.reason === "module_not_found"
|
|
@@ -132,9 +159,7 @@ export function findPlaygroundSpec(
|
|
|
132
159
|
report: WorkspaceReport | null | undefined,
|
|
133
160
|
componentId: string,
|
|
134
161
|
): PlaygroundSpec | undefined {
|
|
135
|
-
return report?.playgrounds?.find(
|
|
136
|
-
(p) => p.export_name === componentId || p.id === componentId,
|
|
137
|
-
);
|
|
162
|
+
return report?.playgrounds?.find((p) => p.export_name === componentId || p.id === componentId);
|
|
138
163
|
}
|
|
139
164
|
|
|
140
165
|
export function findPlaygroundJoinSkip(
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { getModuleExport, isPlaygroundComponent } from "./playgroundModuleExport";
|
|
4
|
+
|
|
5
|
+
describe("playgroundModuleExport", () => {
|
|
6
|
+
it("accepts forwardRef components", () => {
|
|
7
|
+
const Forward = forwardRef(() => null);
|
|
8
|
+
expect(typeof Forward).toBe("object");
|
|
9
|
+
expect(isPlaygroundComponent(Forward)).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("getModuleExport returns forwardRef export", () => {
|
|
13
|
+
const Forward = forwardRef(() => null);
|
|
14
|
+
Forward.displayName = "DropdownMenu";
|
|
15
|
+
const mod = { DropdownMenu: Forward };
|
|
16
|
+
expect(getModuleExport(mod, "DropdownMenu")).toBe(Forward);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("getModuleExport falls back to default export when named export is missing", () => {
|
|
20
|
+
function AppLogoIcon() {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const mod = { default: AppLogoIcon };
|
|
24
|
+
expect(getModuleExport(mod, "AppLogoIcon")).toBe(AppLogoIcon);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("getModuleExport prefers named export over default", () => {
|
|
28
|
+
function Named() {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
function DefaultOnly() {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const mod = { Button: Named, default: DefaultOnly };
|
|
35
|
+
expect(getModuleExport(mod, "Button")).toBe(Named);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("rejects non-components", () => {
|
|
39
|
+
expect(isPlaygroundComponent(null)).toBe(false);
|
|
40
|
+
expect(isPlaygroundComponent({ foo: 1 })).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
|
|
3
|
+
/** True for function components and exotic types (forwardRef, memo). */
|
|
4
|
+
export function isPlaygroundComponent(value: unknown): value is ComponentType<
|
|
5
|
+
Record<string, unknown>
|
|
6
|
+
> {
|
|
7
|
+
if (typeof value === "function") return true;
|
|
8
|
+
if (typeof value !== "object" || value === null) return false;
|
|
9
|
+
const o = value as Record<string, unknown>;
|
|
10
|
+
return typeof o.render === "function" || typeof o.$$typeof === "symbol";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getModuleExport(
|
|
14
|
+
mod: Record<string, unknown>,
|
|
15
|
+
exportName: string,
|
|
16
|
+
): ComponentType<Record<string, unknown>> | undefined {
|
|
17
|
+
const named = mod[exportName];
|
|
18
|
+
if (isPlaygroundComponent(named)) return named;
|
|
19
|
+
const fallback = mod.default;
|
|
20
|
+
if (isPlaygroundComponent(fallback)) return fallback;
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { WorkspaceReport } from "../types/report";
|
|
2
|
+
|
|
3
|
+
/** Stable memo key — ignores report object identity when playground specs are unchanged. */
|
|
4
|
+
export function playgroundSpecsKey(
|
|
5
|
+
report: WorkspaceReport | null | undefined,
|
|
6
|
+
): string {
|
|
7
|
+
return JSON.stringify(report?.playgrounds ?? []);
|
|
8
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { PlaygroundArgs, PlaygroundControl } from "../types/controls";
|
|
2
|
+
import type { DeclaredPropKind, PlaygroundSpec } from "../types/report";
|
|
3
|
+
import {
|
|
4
|
+
childrenPropForPreview,
|
|
5
|
+
isLikelyBooleanProp,
|
|
6
|
+
isPassthroughStringProp,
|
|
7
|
+
SKIP_PLAYGROUND_PROPS,
|
|
8
|
+
} from "./controls";
|
|
9
|
+
|
|
10
|
+
export function coerceDeclaredPropKind(v: unknown): DeclaredPropKind | undefined {
|
|
11
|
+
if (v === "boolean" || v === "string" || v === "number" || v === "unknown")
|
|
12
|
+
return v;
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function normalizedPropKinds(
|
|
17
|
+
raw: PlaygroundSpec["declared_prop_kinds"],
|
|
18
|
+
): Partial<Record<string, DeclaredPropKind>> | undefined {
|
|
19
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
20
|
+
const out: Partial<Record<string, DeclaredPropKind>> = {};
|
|
21
|
+
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
22
|
+
const ck = coerceDeclaredPropKind(v);
|
|
23
|
+
if (ck && ck !== "unknown") out[k] = ck;
|
|
24
|
+
}
|
|
25
|
+
return Object.keys(out).length ? out : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function propKeysForPreview(
|
|
29
|
+
controls: PlaygroundControl[],
|
|
30
|
+
declaredProps: string[],
|
|
31
|
+
): string[] {
|
|
32
|
+
if (controls.length > 0) return controls.map((c) => c.key);
|
|
33
|
+
return declaredProps.filter((k) => k !== "key" && k !== "ref");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function valuesToComponentProps(
|
|
37
|
+
controls: PlaygroundControl[],
|
|
38
|
+
declaredProps: string[],
|
|
39
|
+
values: PlaygroundArgs,
|
|
40
|
+
propKinds?: Partial<Record<string, DeclaredPropKind>>,
|
|
41
|
+
exportName?: string,
|
|
42
|
+
): Record<string, unknown> {
|
|
43
|
+
const o: Record<string, unknown> = {};
|
|
44
|
+
for (const key of propKeysForPreview(controls, declaredProps)) {
|
|
45
|
+
if (SKIP_PLAYGROUND_PROPS.has(key)) continue;
|
|
46
|
+
if (isPassthroughStringProp(key)) {
|
|
47
|
+
const raw = values[key];
|
|
48
|
+
if (raw === undefined || raw === null || String(raw).length === 0) continue;
|
|
49
|
+
o[key] = String(raw);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (key === "children") {
|
|
53
|
+
const coerced = childrenPropForPreview(exportName, values.children);
|
|
54
|
+
if (coerced !== undefined) o[key] = coerced;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const kind = propKinds?.[key];
|
|
58
|
+
if (kind === "boolean") {
|
|
59
|
+
o[key] = Boolean(values[key]);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (kind === "number") {
|
|
63
|
+
const raw = values[key];
|
|
64
|
+
const n = typeof raw === "number" ? raw : Number(raw);
|
|
65
|
+
o[key] = Number.isFinite(n) ? n : 0;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (kind === "string") {
|
|
69
|
+
o[key] = values[key];
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (isLikelyBooleanProp(key)) {
|
|
73
|
+
o[key] = Boolean(values[key]);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
o[key] = values[key];
|
|
77
|
+
}
|
|
78
|
+
return o;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function mergeStaticDefaults(
|
|
82
|
+
fromValues: Record<string, unknown>,
|
|
83
|
+
staticDefaults: Record<string, unknown>,
|
|
84
|
+
): Record<string, unknown> {
|
|
85
|
+
const o = { ...fromValues };
|
|
86
|
+
for (const [k, v] of Object.entries(staticDefaults)) {
|
|
87
|
+
const cur = o[k];
|
|
88
|
+
if (cur === undefined || (cur === "" && k !== "children")) o[k] = v;
|
|
89
|
+
}
|
|
90
|
+
return o;
|
|
91
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
formatVariantLabel,
|
|
4
|
+
mergePlaygroundA11yFindings,
|
|
5
|
+
playgroundA11yScore,
|
|
6
|
+
} from "./scanVariantA11y";
|
|
7
|
+
|
|
8
|
+
describe("formatVariantLabel", () => {
|
|
9
|
+
it("joins axis keys and values", () => {
|
|
10
|
+
expect(
|
|
11
|
+
formatVariantLabel(
|
|
12
|
+
{ variant: "destructive", size: "default", asChild: false },
|
|
13
|
+
["variant", "size"],
|
|
14
|
+
),
|
|
15
|
+
).toBe("variant=destructive size=default");
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("playgroundA11yScore", () => {
|
|
20
|
+
it("combines static and variant findings", () => {
|
|
21
|
+
const staticFindings = [
|
|
22
|
+
{
|
|
23
|
+
rule_id: "a11y-button-name",
|
|
24
|
+
message: "missing name",
|
|
25
|
+
path: "Button.tsx",
|
|
26
|
+
line: 1,
|
|
27
|
+
severity: "warning" as const,
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
const variantFindings = [
|
|
31
|
+
{
|
|
32
|
+
rule_id: "a11y-playground-color-contrast",
|
|
33
|
+
message: "contrast too low",
|
|
34
|
+
path: "",
|
|
35
|
+
line: null,
|
|
36
|
+
severity: "error" as const,
|
|
37
|
+
variant_label: "variant=destructive size=default",
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
expect(
|
|
42
|
+
mergePlaygroundA11yFindings(staticFindings, variantFindings),
|
|
43
|
+
).toHaveLength(2);
|
|
44
|
+
expect(playgroundA11yScore(staticFindings, variantFindings)).toBe(65);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { PlaygroundArgs } from "../types/controls";
|
|
2
|
+
import type { LintFinding, Severity } from "../types/report";
|
|
3
|
+
import { a11yScoreFromFindings } from "../report/a11yScoring";
|
|
4
|
+
|
|
5
|
+
export type PlaygroundA11yFinding = LintFinding;
|
|
6
|
+
|
|
7
|
+
export type VariantPreviewTarget = {
|
|
8
|
+
element: Element;
|
|
9
|
+
combo: PlaygroundArgs;
|
|
10
|
+
axisKeys: string[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const SCAN_CONCURRENCY = 4;
|
|
14
|
+
|
|
15
|
+
function formatAxisValue(value: string | number | boolean | undefined): string {
|
|
16
|
+
if (value === undefined) return "?";
|
|
17
|
+
if (typeof value === "string") return value;
|
|
18
|
+
return JSON.stringify(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatVariantLabel(
|
|
22
|
+
combo: PlaygroundArgs,
|
|
23
|
+
axisKeys: string[],
|
|
24
|
+
): string {
|
|
25
|
+
return axisKeys
|
|
26
|
+
.map((key) => `${key}=${formatAxisValue(combo[key])}`)
|
|
27
|
+
.join(" ");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function axeImpactToSeverity(
|
|
31
|
+
impact: string | null | undefined,
|
|
32
|
+
): Severity {
|
|
33
|
+
if (impact === "critical" || impact === "serious") return "error";
|
|
34
|
+
if (impact === "moderate") return "warning";
|
|
35
|
+
return "info";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function loadAxe() {
|
|
39
|
+
const mod = await import("axe-core");
|
|
40
|
+
return mod.default;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function scanElementA11y(
|
|
44
|
+
element: Element,
|
|
45
|
+
variantLabel: string,
|
|
46
|
+
): Promise<PlaygroundA11yFinding[]> {
|
|
47
|
+
const axe = await loadAxe();
|
|
48
|
+
const results = await axe.run(element, {
|
|
49
|
+
runOnly: {
|
|
50
|
+
type: "rule",
|
|
51
|
+
values: ["color-contrast"],
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const findings: PlaygroundA11yFinding[] = [];
|
|
56
|
+
for (const violation of results.violations) {
|
|
57
|
+
const severity = axeImpactToSeverity(violation.impact);
|
|
58
|
+
const summary = violation.help || violation.description;
|
|
59
|
+
for (const node of violation.nodes) {
|
|
60
|
+
findings.push({
|
|
61
|
+
rule_id: `a11y-playground-${violation.id}`,
|
|
62
|
+
message: node.failureSummary
|
|
63
|
+
? `${summary} (${node.failureSummary})`
|
|
64
|
+
: summary,
|
|
65
|
+
path: "",
|
|
66
|
+
line: null,
|
|
67
|
+
severity,
|
|
68
|
+
variant_label: variantLabel,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return findings;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function scanVariantPreviews(
|
|
76
|
+
targets: VariantPreviewTarget[],
|
|
77
|
+
): Promise<PlaygroundA11yFinding[]> {
|
|
78
|
+
const findings: PlaygroundA11yFinding[] = [];
|
|
79
|
+
|
|
80
|
+
for (let i = 0; i < targets.length; i += SCAN_CONCURRENCY) {
|
|
81
|
+
const chunk = targets.slice(i, i + SCAN_CONCURRENCY);
|
|
82
|
+
const chunkFindings = await Promise.all(
|
|
83
|
+
chunk.map(({ element, combo, axisKeys }) =>
|
|
84
|
+
scanElementA11y(element, formatVariantLabel(combo, axisKeys)),
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
findings.push(...chunkFindings.flat());
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return findings;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function mergePlaygroundA11yFindings(
|
|
94
|
+
staticFindings: LintFinding[],
|
|
95
|
+
variantFindings: PlaygroundA11yFinding[],
|
|
96
|
+
): PlaygroundA11yFinding[] {
|
|
97
|
+
return [...staticFindings, ...variantFindings];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function playgroundA11yScore(
|
|
101
|
+
staticFindings: LintFinding[],
|
|
102
|
+
variantFindings: PlaygroundA11yFinding[],
|
|
103
|
+
): number {
|
|
104
|
+
return a11yScoreFromFindings(
|
|
105
|
+
mergePlaygroundA11yFindings(staticFindings, variantFindings),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { PlaygroundArgs, PlaygroundControl } from "../types/controls";
|
|
2
|
+
|
|
3
|
+
function jsxTextOrStringifyExpression(text: string): string {
|
|
4
|
+
if (!/[<>{}&]/.test(text)) return text;
|
|
5
|
+
return `{JSON.stringify(${JSON.stringify(text)})}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function valueMatchesPlaygroundDefault(
|
|
9
|
+
control: PlaygroundControl,
|
|
10
|
+
value: string | number | boolean | undefined,
|
|
11
|
+
): boolean {
|
|
12
|
+
switch (control.type) {
|
|
13
|
+
case "boolean":
|
|
14
|
+
return Boolean(value) === control.default;
|
|
15
|
+
case "number": {
|
|
16
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
17
|
+
return Number.isFinite(n) && n === control.default;
|
|
18
|
+
}
|
|
19
|
+
case "string":
|
|
20
|
+
case "select":
|
|
21
|
+
return String(value ?? "") === String(control.default);
|
|
22
|
+
default:
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function genericUsageSnippet(
|
|
28
|
+
exportName: string,
|
|
29
|
+
values: PlaygroundArgs,
|
|
30
|
+
controls: PlaygroundControl[],
|
|
31
|
+
): string {
|
|
32
|
+
const controlByKey = new Map(controls.map((c) => [c.key, c] as const));
|
|
33
|
+
|
|
34
|
+
const emitPropKey = (key: string): boolean => {
|
|
35
|
+
const c = controlByKey.get(key);
|
|
36
|
+
if (!c) return true;
|
|
37
|
+
return !valueMatchesPlaygroundDefault(c, values[key]);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const hasChildrenKey = Object.prototype.hasOwnProperty.call(
|
|
41
|
+
values,
|
|
42
|
+
"children",
|
|
43
|
+
);
|
|
44
|
+
const childVal = hasChildrenKey ? values.children : undefined;
|
|
45
|
+
|
|
46
|
+
const propKeys = Object.keys(values)
|
|
47
|
+
.filter((k) => k !== "children")
|
|
48
|
+
.filter(emitPropKey)
|
|
49
|
+
.sort((a, b) => a.localeCompare(b));
|
|
50
|
+
const propsStr = propKeys
|
|
51
|
+
.map((k) => `${k}={${JSON.stringify(values[k])}}`)
|
|
52
|
+
.join(" ");
|
|
53
|
+
|
|
54
|
+
const openWithProps =
|
|
55
|
+
propKeys.length === 0 ? `<${exportName}` : `<${exportName} ${propsStr}`;
|
|
56
|
+
|
|
57
|
+
if (!hasChildrenKey) {
|
|
58
|
+
return propKeys.length === 0 ? `<${exportName} />` : `${openWithProps} />`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (typeof childVal === "boolean") {
|
|
62
|
+
const allKeys = Object.keys(values)
|
|
63
|
+
.filter(emitPropKey)
|
|
64
|
+
.sort((a, b) => a.localeCompare(b));
|
|
65
|
+
const allProps = allKeys
|
|
66
|
+
.map((k) => `${k}={${JSON.stringify(values[k])}}`)
|
|
67
|
+
.join(" ");
|
|
68
|
+
return allKeys.length === 0
|
|
69
|
+
? `<${exportName} />`
|
|
70
|
+
: `<${exportName} ${allProps} />`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const asText =
|
|
74
|
+
typeof childVal === "number" ? String(childVal) : String(childVal ?? "");
|
|
75
|
+
if (asText.length === 0) {
|
|
76
|
+
return propKeys.length === 0 ? `<${exportName} />` : `${openWithProps} />`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const body = jsxTextOrStringifyExpression(asText);
|
|
80
|
+
return propKeys.length === 0
|
|
81
|
+
? `<${exportName}>${body}</${exportName}>`
|
|
82
|
+
: `${openWithProps}>${body}</${exportName}>`;
|
|
83
|
+
}
|