dslinter 0.0.32 → 0.0.34

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.
@@ -1,13 +1,17 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useMemo, useState } from "react";
2
2
  import { IconMoon, IconSearch, IconSun } from "@/components/icons";
3
3
  import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
4
4
 
5
- import type { PlaygroundEntry } from "../types/playground";
5
+ import { componentCatalogNamesFromReport } from "../dashboard/aggregate";
6
+ import type { WorkspaceReport } from "../types/report";
6
7
  import type { HashRoute } from "../shell/hashRoute";
7
8
  import type { DashboardThemePreference } from "../shell/DashboardLayout";
8
9
 
9
10
  type Props = {
10
- entries: PlaygroundEntry[];
11
+ report: WorkspaceReport | null;
12
+ playgroundIds: Set<string>;
13
+ reportLoading: boolean;
14
+ reportError: string | null;
11
15
  route: HashRoute;
12
16
  onNavigate: (next: HashRoute) => void;
13
17
  onOpenCommandPalette: () => void;
@@ -45,13 +49,20 @@ function sectionLabel(text: string) {
45
49
  }
46
50
 
47
51
  export function Sidebar({
48
- entries,
52
+ report,
53
+ playgroundIds,
54
+ reportLoading,
55
+ reportError,
49
56
  route,
50
57
  onNavigate,
51
58
  onOpenCommandPalette,
52
59
  theme,
53
60
  onThemeChange,
54
61
  }: Props) {
62
+ const catalogNames = useMemo(
63
+ () => componentCatalogNamesFromReport(report),
64
+ [report],
65
+ );
55
66
  const tokensActive = route.view === "tokens";
56
67
  const governanceActive = route.view === "governance";
57
68
 
@@ -139,28 +150,44 @@ export function Sidebar({
139
150
  Design tokens
140
151
  </button>
141
152
 
142
- {sectionLabel("Components")}
153
+ {sectionLabel(
154
+ catalogNames.length > 0
155
+ ? `Components (${catalogNames.length})`
156
+ : "Components",
157
+ )}
143
158
  <div className="space-y-0.5">
144
- {entries.map((e, i) => {
159
+ {reportLoading && catalogNames.length === 0 ? (
160
+ <p className="px-2.5 py-1.5 text-sm text-sidebar-foreground/50">
161
+ Loading components…
162
+ </p>
163
+ ) : null}
164
+ {reportError && catalogNames.length === 0 ? (
165
+ <p className="px-2.5 py-1.5 text-sm text-sidebar-foreground/50">
166
+ Could not load report
167
+ </p>
168
+ ) : null}
169
+ {!reportLoading && !reportError && catalogNames.length === 0 ? (
170
+ <p className="px-2.5 py-1.5 text-sm text-sidebar-foreground/50">
171
+ No components in scan
172
+ </p>
173
+ ) : null}
174
+ {catalogNames.map((name) => {
145
175
  const active =
146
- route.view === "component" && route.componentId === e.id;
147
- const prev = entries[i - 1];
148
- const showGroup =
149
- Boolean(e.meta.group) &&
150
- (!prev || prev.meta.group !== e.meta.group);
176
+ (route.view === "component" && route.componentId === name) ||
177
+ (route.view === "governance" && route.catalog === name);
151
178
  return (
152
- <div key={e.id}>
153
- {showGroup && e.meta.group ? sectionLabel(e.meta.group) : null}
154
- <button
155
- type="button"
156
- onClick={() =>
157
- onNavigate({ view: "component", componentId: e.id })
158
- }
159
- className={navButtonClass(active)}
160
- >
161
- {e.meta.title}
162
- </button>
163
- </div>
179
+ <button
180
+ key={name}
181
+ type="button"
182
+ onClick={() =>
183
+ playgroundIds.has(name)
184
+ ? onNavigate({ view: "component", componentId: name })
185
+ : onNavigate({ view: "governance", catalog: name })
186
+ }
187
+ className={navButtonClass(active)}
188
+ >
189
+ {name}
190
+ </button>
164
191
  );
165
192
  })}
166
193
  </div>
@@ -1,4 +1,4 @@
1
- import { useMemo } from "react";
1
+ import { useEffect, useMemo } from "react";
2
2
  import {
3
3
  HoverCard,
4
4
  HoverCardContent,
@@ -16,6 +16,7 @@ import {
16
16
  aggregateDeclaredProps,
17
17
  aggregateDefinitions,
18
18
  catalogComponentNames,
19
+ catalogRowDomId,
19
20
  usageMap,
20
21
  } from "./aggregate";
21
22
  import { shortPath } from "./paths";
@@ -146,7 +147,13 @@ function CatalogAppUsageHover({
146
147
  );
147
148
  }
148
149
 
149
- export function ComponentCatalog({ report }: { report: WorkspaceReport }) {
150
+ export function ComponentCatalog({
151
+ report,
152
+ focusName,
153
+ }: {
154
+ report: WorkspaceReport;
155
+ focusName?: string;
156
+ }) {
150
157
  const defs = aggregateDefinitions(report);
151
158
  const usages = usageMap(report);
152
159
  const names = catalogComponentNames(defs, usages);
@@ -156,6 +163,12 @@ export function ComponentCatalog({ report }: { report: WorkspaceReport }) {
156
163
  [report],
157
164
  );
158
165
 
166
+ useEffect(() => {
167
+ if (!focusName || !names.includes(focusName)) return;
168
+ const el = document.getElementById(catalogRowDomId(focusName));
169
+ el?.scrollIntoView({ block: "nearest", behavior: "smooth" });
170
+ }, [focusName, names]);
171
+
159
172
  return (
160
173
  <Table>
161
174
  <TableHeader>
@@ -174,7 +187,7 @@ export function ComponentCatalog({ report }: { report: WorkspaceReport }) {
174
187
  ).length;
175
188
 
176
189
  return (
177
- <TableRow key={name}>
190
+ <TableRow key={name} id={catalogRowDomId(name)}>
178
191
  <TableCell>{name}</TableCell>
179
192
 
180
193
  <TableCell>
@@ -12,7 +12,13 @@ import { ComponentCatalog } from "./ComponentCatalog";
12
12
  import { FindingsList } from "./FindingsList";
13
13
  import { ScoreStrip } from "./ScoreStrip";
14
14
 
15
- export function DashboardBody({ report }: { report: WorkspaceReport }) {
15
+ export function DashboardBody({
16
+ report,
17
+ focusName,
18
+ }: {
19
+ report: WorkspaceReport;
20
+ focusName?: string;
21
+ }) {
16
22
  return (
17
23
  <div className="space-y-10">
18
24
  <ScoreStrip scores={report.scores} />
@@ -56,7 +62,7 @@ export function DashboardBody({ report }: { report: WorkspaceReport }) {
56
62
  title="Components"
57
63
  description="Definitions and JSX usage from the latest snapshot."
58
64
  >
59
- <ComponentCatalog report={report} />
65
+ <ComponentCatalog report={report} focusName={focusName} />
60
66
  </Section>
61
67
 
62
68
  <Section
@@ -10,8 +10,8 @@ const HIDDEN_COMPONENTS = new Set(["App", "React.StrictMode"]);
10
10
 
11
11
  export function aggregateDefinitions(report: WorkspaceReport): Map<string, DefinitionSite[]> {
12
12
  const map = new Map<string, DefinitionSite[]>();
13
- for (const file of report.files) {
14
- for (const d of file.definitions) {
13
+ for (const file of report.files ?? []) {
14
+ for (const d of file.definitions ?? []) {
15
15
  if (HIDDEN_COMPONENTS.has(d.name)) continue;
16
16
  const list = map.get(d.name) ?? [];
17
17
  list.push({ kind: d.kind, path: file.path, line: d.line });
@@ -55,7 +55,7 @@ export function aggregateDeclaredProps(report: WorkspaceReport): Map<string, str
55
55
 
56
56
  export function usageMap(report: WorkspaceReport): Map<string, UsageSummary> {
57
57
  const m = new Map<string, UsageSummary>();
58
- for (const row of report.usage_by_component) {
58
+ for (const row of report.usage_by_component ?? []) {
59
59
  if (HIDDEN_COMPONENTS.has(row.component)) continue;
60
60
  m.set(row.component, row);
61
61
  }
@@ -71,3 +71,16 @@ export function catalogComponentNames(
71
71
  for (const k of usages.keys()) names.add(k);
72
72
  return [...names].sort((a, b) => a.localeCompare(b));
73
73
  }
74
+
75
+ /** Stable DOM id for a catalog table row (used for hash deep-links). */
76
+ export function catalogRowDomId(name: string): string {
77
+ return `catalog-row-${encodeURIComponent(name)}`;
78
+ }
79
+
80
+ /** Unique component names for sidebar / command palette (definitions ∪ usage). */
81
+ export function componentCatalogNamesFromReport(
82
+ report: WorkspaceReport | null | undefined,
83
+ ): string[] {
84
+ if (!report) return [];
85
+ return catalogComponentNames(aggregateDefinitions(report), usageMap(report));
86
+ }
@@ -52,9 +52,16 @@ export function useWorkspaceReport(
52
52
  const etagRef = useRef<string | null>(null);
53
53
 
54
54
  // Core fetch function.
55
- const fetchReport = (url: string, cancelled: { value: boolean }) => {
56
- setError(null);
57
- setLoading(true);
55
+ const fetchReport = (
56
+ url: string,
57
+ cancelled: { value: boolean },
58
+ options?: { showLoading?: boolean },
59
+ ) => {
60
+ const showLoading = options?.showLoading !== false;
61
+ if (showLoading) {
62
+ setError(null);
63
+ setLoading(true);
64
+ }
58
65
  loadReport(url)
59
66
  .then((r) => {
60
67
  if (!cancelled.value) setReport(r);
@@ -63,14 +70,14 @@ export function useWorkspaceReport(
63
70
  if (!cancelled.value) setError(e instanceof Error ? e.message : "Failed to load report");
64
71
  })
65
72
  .finally(() => {
66
- if (!cancelled.value) setLoading(false);
73
+ if (!cancelled.value && showLoading) setLoading(false);
67
74
  });
68
75
  };
69
76
 
70
77
  // Initial load.
71
78
  useEffect(() => {
72
79
  const cancelled = { value: false };
73
- fetchReport(reportUrl, cancelled);
80
+ fetchReport(reportUrl, cancelled, { showLoading: true });
74
81
  return () => {
75
82
  cancelled.value = true;
76
83
  };
@@ -86,7 +93,7 @@ export function useWorkspaceReport(
86
93
  source.onmessage = (e) => {
87
94
  if (cancelled.value) return;
88
95
  if (e.data === "updated") {
89
- fetchReport(reportUrl, cancelled);
96
+ fetchReport(reportUrl, cancelled, { showLoading: false });
90
97
  }
91
98
  };
92
99
 
@@ -118,7 +125,7 @@ export function useWorkspaceReport(
118
125
  return;
119
126
  }
120
127
  etagRef.current = etag;
121
- fetchReport(reportUrl, cancelled);
128
+ fetchReport(reportUrl, cancelled, { showLoading: false });
122
129
  } catch {
123
130
  // Network error during poll — ignore silently; the user will see the
124
131
  // previous state.
@@ -10,10 +10,13 @@ export function resolveModuleSourcePath(reportRoot: string, modulePath: string):
10
10
  return `${root}/${withSrc}`;
11
11
  }
12
12
 
13
+ const SRC_COMPONENTS = "src/components/";
14
+
13
15
  /** Match finding path to source file even when `report.root` differs from machine that generated JSON. */
14
16
  function tailSrcComponents(p: string): string | null {
15
- const m = norm(p).match(/(src\/components\/.+)$/);
16
- return m ? m[1] : null;
17
+ const normalized = norm(p);
18
+ const idx = normalized.indexOf(SRC_COMPONENTS);
19
+ return idx === -1 ? null : normalized.slice(idx);
17
20
  }
18
21
 
19
22
  export function pathsMatch(reportPath: string, candidate: string): boolean {
@@ -17,6 +17,7 @@ import { GovernancePane } from "../components/GovernancePane";
17
17
  import { Sidebar } from "../components/Sidebar";
18
18
  import { TokensPane } from "../components/TokensPane";
19
19
  import { DashboardCommandPalette } from "../components/DashboardCommandPalette";
20
+ import { componentCatalogNamesFromReport } from "../dashboard/aggregate";
20
21
  import { useHashRoute } from "./useHashRoute";
21
22
 
22
23
  const STORAGE_KEY = "dslinter-dashboard-theme";
@@ -144,8 +145,28 @@ function DashboardLayoutInner({
144
145
  const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
145
146
  const { theme, setTheme, resolvedTheme } = useDashboardTheme();
146
147
 
148
+ const catalogNames = useMemo(
149
+ () => componentCatalogNamesFromReport(dslinterReport.report),
150
+ [dslinterReport.report],
151
+ );
152
+
153
+ const playgroundIds = useMemo(
154
+ () => new Set(playgroundEntries.map((e) => e.id)),
155
+ [playgroundEntries],
156
+ );
157
+
158
+ const focusName =
159
+ route.view === "governance" ? route.catalog : undefined;
160
+
147
161
  const getEntry = (id: string) => playgroundEntries.find((e) => e.id === id);
148
162
 
163
+ useEffect(() => {
164
+ if (route.view !== "component") return;
165
+ if (getEntry(route.componentId)) return;
166
+ if (!catalogNames.includes(route.componentId)) return;
167
+ navigate({ view: "governance", catalog: route.componentId });
168
+ }, [route, catalogNames, playgroundEntries, navigate]);
169
+
149
170
  let main: ReactNode;
150
171
  if (route.view === "tokens") {
151
172
  main = <TokensPane tokenCatalog={tokenCatalog} />;
@@ -156,6 +177,7 @@ function DashboardLayoutInner({
156
177
  reportUrl={reportUrl}
157
178
  dslinterReportHint={dslinterReportHint}
158
179
  dslinterReport={dslinterReport}
180
+ focusName={focusName}
159
181
  />
160
182
  );
161
183
  } else {
@@ -201,13 +223,17 @@ function DashboardLayoutInner({
201
223
  )}
202
224
  >
203
225
  <DashboardCommandPalette
204
- entries={playgroundEntries}
226
+ catalogNames={catalogNames}
227
+ playgroundIds={playgroundIds}
205
228
  onNavigate={navigate}
206
229
  open={commandPaletteOpen}
207
230
  onOpenChange={setCommandPaletteOpen}
208
231
  />
209
232
  <Sidebar
210
- entries={playgroundEntries}
233
+ report={dslinterReport.report}
234
+ playgroundIds={playgroundIds}
235
+ reportLoading={dslinterReport.loading}
236
+ reportError={dslinterReport.error}
211
237
  route={route}
212
238
  onNavigate={navigate}
213
239
  onOpenCommandPalette={() => setCommandPaletteOpen(true)}
@@ -1,6 +1,6 @@
1
1
  export type HashRoute =
2
2
  | { view: "tokens" }
3
- | { view: "governance" }
3
+ | { view: "governance"; catalog?: string }
4
4
  | { view: "component"; componentId: string };
5
5
 
6
6
  const PREFIX = "#!/";
@@ -26,6 +26,12 @@ export function parseHashRoute(hash: string): HashRoute {
26
26
  if (raw === "governance") {
27
27
  return { view: "governance" };
28
28
  }
29
+ if (raw.startsWith("governance/")) {
30
+ const catalog = decodeURIComponent(raw.slice("governance/".length));
31
+ if (catalog.length > 0) {
32
+ return { view: "governance", catalog };
33
+ }
34
+ }
29
35
  if (raw.startsWith("component/")) {
30
36
  const componentId = decodeURIComponent(raw.slice("component/".length));
31
37
  if (componentId.length > 0) {
@@ -40,7 +46,9 @@ export function formatHashRoute(route: HashRoute): string {
40
46
  case "tokens":
41
47
  return `${PREFIX}tokens`;
42
48
  case "governance":
43
- return `${PREFIX}governance`;
49
+ return route.catalog
50
+ ? `${PREFIX}governance/${encodeURIComponent(route.catalog)}`
51
+ : `${PREFIX}governance`;
44
52
  case "component":
45
53
  return `${PREFIX}component/${encodeURIComponent(route.componentId)}`;
46
54
  default: