dslinter 0.0.6

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/LICENSE +201 -0
  3. package/README.md +104 -0
  4. package/bin/dslinter.mjs +29 -0
  5. package/components.json +20 -0
  6. package/package.json +90 -0
  7. package/src/components/InlineCode.tsx +5 -0
  8. package/src/components/icons.tsx +121 -0
  9. package/src/components/ui/badge.tsx +52 -0
  10. package/src/components/ui/button.tsx +57 -0
  11. package/src/components/ui/checkbox.tsx +25 -0
  12. package/src/components/ui/command.tsx +183 -0
  13. package/src/components/ui/dialog.tsx +156 -0
  14. package/src/components/ui/hover-card.tsx +42 -0
  15. package/src/components/ui/input.tsx +22 -0
  16. package/src/components/ui/label.tsx +19 -0
  17. package/src/components/ui/select.tsx +149 -0
  18. package/src/components/ui/table.tsx +118 -0
  19. package/src/components/ui/toggle-group.tsx +83 -0
  20. package/src/components/ui/toggle.tsx +45 -0
  21. package/src/dashboard/ComponentCatalog.tsx +210 -0
  22. package/src/dashboard/ComponentUsageDetails.tsx +109 -0
  23. package/src/dashboard/DashboardBody.tsx +71 -0
  24. package/src/dashboard/FindingsList.tsx +151 -0
  25. package/src/dashboard/ScoreStrip.tsx +28 -0
  26. package/src/dashboard/TokenWall.tsx +241 -0
  27. package/src/dashboard/aggregate.ts +73 -0
  28. package/src/dashboard/paths.ts +10 -0
  29. package/src/dashboard/useWorkspaceReport.ts +136 -0
  30. package/src/index.ts +67 -0
  31. package/src/lib/utils.ts +6 -0
  32. package/src/playground/definePlayground.tsx +99 -0
  33. package/src/playground/enumerateControlCombinations.test.ts +112 -0
  34. package/src/playground/enumerateControlCombinations.ts +74 -0
  35. package/src/report/a11yForModule.ts +35 -0
  36. package/src/report/codeScoreForModule.ts +41 -0
  37. package/src/report/modulePathMatch.ts +27 -0
  38. package/src/report/tokenStyleFindingsForModule.ts +24 -0
  39. package/src/shell/ComponentPlaygroundPane.tsx +438 -0
  40. package/src/shell/DashboardCommandPalette.tsx +134 -0
  41. package/src/shell/DashboardLayout.tsx +230 -0
  42. package/src/shell/EmptyCard.tsx +21 -0
  43. package/src/shell/GovernancePane.tsx +77 -0
  44. package/src/shell/PlaygroundA11yAndCode.tsx +387 -0
  45. package/src/shell/PlaygroundControlField.tsx +213 -0
  46. package/src/shell/PlaygroundControls.tsx +66 -0
  47. package/src/shell/PlaygroundUsageCode.tsx +51 -0
  48. package/src/shell/PlaygroundVariantMatrix.tsx +68 -0
  49. package/src/shell/Section.tsx +34 -0
  50. package/src/shell/Sidebar.tsx +203 -0
  51. package/src/shell/TokensPane.tsx +26 -0
  52. package/src/shell/controlApiTable.ts +53 -0
  53. package/src/shell/hashRoute.ts +49 -0
  54. package/src/shell/playgroundUsageHighlight.ts +53 -0
  55. package/src/shell/playgroundUsageTwoslash.ts +69 -0
  56. package/src/shell/useHashRoute.ts +29 -0
  57. package/src/styles/dashboard-theme.css +188 -0
  58. package/src/types/controls.ts +62 -0
  59. package/src/types/defaultTailwindTypography.ts +55 -0
  60. package/src/types/playground.ts +21 -0
  61. package/src/types/preview.ts +8 -0
  62. package/src/types/report.ts +116 -0
  63. package/src/types/tokenCatalog.ts +54 -0
@@ -0,0 +1,51 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import "@shikijs/twoslash/style-rich.css";
3
+ import { renderPlaygroundUsageHtml } from "./playgroundUsageHighlight";
4
+
5
+ const shellClass =
6
+ "playground-usage-shiki mt-4 overflow-x-auto rounded-lg border bg-gray-900 p-4 text-sm leading-relaxed shadow-xs " +
7
+ "[&_.shiki]:!bg-transparent [&_pre.shiki]:!m-0 [&_pre.shiki]:!bg-transparent [&_pre.shiki]:!p-0";
8
+
9
+ const plainPreClass =
10
+ "m-0 whitespace-pre font-mono text-sm leading-relaxed text-gray-100";
11
+
12
+ type Props = {
13
+ source: string;
14
+ };
15
+
16
+ export function PlaygroundUsageCode({ source }: Props) {
17
+ const [html, setHtml] = useState<string | null>(null);
18
+ const seq = useRef(0);
19
+
20
+ useEffect(() => {
21
+ const id = ++seq.current;
22
+ const ac = new AbortController();
23
+ setHtml(null);
24
+
25
+ void (async () => {
26
+ try {
27
+ const next = await renderPlaygroundUsageHtml(source, ac.signal);
28
+ if (id !== seq.current) return;
29
+ setHtml(next);
30
+ } catch (e) {
31
+ if ((e as { name?: string }).name === "AbortError") return;
32
+ if (id !== seq.current) return;
33
+ setHtml(null);
34
+ }
35
+ })();
36
+
37
+ return () => ac.abort();
38
+ }, [source]);
39
+
40
+ if (html) {
41
+ return (
42
+ <div className={shellClass} dangerouslySetInnerHTML={{ __html: html }} />
43
+ );
44
+ }
45
+
46
+ return (
47
+ <pre className={shellClass}>
48
+ <code className={plainPreClass}>{source}</code>
49
+ </pre>
50
+ );
51
+ }
@@ -0,0 +1,68 @@
1
+ import type { PlaygroundArgs } from "../types/controls";
2
+ import type { PlaygroundPreviewComponent } from "../types/preview";
3
+ import { Badge } from "@/components/ui/badge";
4
+ import { PLAYGROUND_VARIANT_MATRIX_CAP } from "../playground/enumerateControlCombinations";
5
+
6
+ type Props = {
7
+ Preview: PlaygroundPreviewComponent;
8
+ combinations: PlaygroundArgs[];
9
+ finiteAxisKeys: string[];
10
+ totalCount: number;
11
+ capped: boolean;
12
+ };
13
+
14
+ function formatValue(value: string | number | boolean): string {
15
+ if (typeof value === "string") return value;
16
+ return JSON.stringify(value);
17
+ }
18
+
19
+ export function PlaygroundVariantMatrix({
20
+ Preview,
21
+ combinations,
22
+ finiteAxisKeys,
23
+ totalCount,
24
+ capped,
25
+ }: Props) {
26
+ if (combinations.length === 0) return null;
27
+
28
+ return (
29
+ <>
30
+ {capped ? (
31
+ <p className="rounded-md border border-border bg-muted/60 px-3 py-2 text-sm text-muted-foreground">
32
+ Showing {combinations.length} of {totalCount} combinations (limit{" "}
33
+ {PLAYGROUND_VARIANT_MATRIX_CAP}). Reduce select options or split
34
+ controls to preview more here.
35
+ </p>
36
+ ) : null}
37
+ <div className="mt-4 flex flex-col gap-4">
38
+ {combinations.map((combo) => (
39
+ <div
40
+ key={finiteAxisKeys
41
+ .map((k) => `${k}:${formatValue(combo[k] ?? "")}`)
42
+ .join("|")}
43
+ className="flex min-w-0 flex-col overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-xs"
44
+ >
45
+ <div className="flex flex-wrap gap-1 border-b p-2">
46
+ {finiteAxisKeys.map((k) => {
47
+ const v = combo[k];
48
+ return (
49
+ <Badge
50
+ key={k}
51
+ variant="outline"
52
+ size="sm"
53
+ className="font-mono text-xs"
54
+ >
55
+ {k}={v === undefined ? "?" : formatValue(v)}
56
+ </Badge>
57
+ );
58
+ })}
59
+ </div>
60
+ <div className="min-w-0 bg-card p-3">
61
+ <Preview values={combo} />
62
+ </div>
63
+ </div>
64
+ ))}
65
+ </div>
66
+ </>
67
+ );
68
+ }
@@ -0,0 +1,34 @@
1
+ export function Section({
2
+ id,
3
+ children,
4
+ title,
5
+ description,
6
+ actions,
7
+ }: {
8
+ id: string;
9
+ children: React.ReactNode;
10
+ title: string;
11
+ description: string;
12
+ actions?: React.ReactNode;
13
+ }) {
14
+ return (
15
+ <section id={id} className="scroll-mt-20">
16
+ <div className="flex items-center justify-between gap-2">
17
+ <div>
18
+ <h2 className="text-lg/none font-semibold tracking-tight text-foreground">
19
+ {title}
20
+ </h2>
21
+ {description ? (
22
+ <p className="text-sm mt-1.5 text-muted-foreground">
23
+ {description}
24
+ </p>
25
+ ) : null}
26
+ </div>
27
+ {actions ? (
28
+ <div className="flex items-center gap-2">{actions}</div>
29
+ ) : null}
30
+ </div>
31
+ <div className="mt-3">{children}</div>
32
+ </section>
33
+ );
34
+ }
@@ -0,0 +1,203 @@
1
+ import { useEffect, useState } from "react";
2
+ import { IconMoon, IconSearch, IconSun } from "@/components/icons";
3
+ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
4
+
5
+ import type { PlaygroundEntry } from "../types/playground";
6
+ import type { HashRoute } from "./hashRoute";
7
+ import type { DashboardThemePreference } from "./DashboardLayout";
8
+
9
+ type Props = {
10
+ entries: PlaygroundEntry[];
11
+ route: HashRoute;
12
+ onNavigate: (next: HashRoute) => void;
13
+ onOpenCommandPalette: () => void;
14
+ theme: DashboardThemePreference;
15
+ onThemeChange: (next: DashboardThemePreference) => void;
16
+ };
17
+
18
+ function SearchShortcutBadge() {
19
+ const [label, setLabel] = useState("⌘K");
20
+ useEffect(() => {
21
+ const mac = /Mac|iPhone|iPod|iPad/i.test(navigator.userAgent);
22
+ setLabel(mac ? "⌘K" : "Ctrl+K");
23
+ }, []);
24
+ return (
25
+ <kbd className="pointer-events-none select-none rounded border border-sidebar-border bg-sidebar-accent px-1 py-px font-mono text-[10px] font-medium leading-tight text-sidebar-foreground/80 tabular-nums">
26
+ {label}
27
+ </kbd>
28
+ );
29
+ }
30
+
31
+ function navButtonClass(active: boolean) {
32
+ return `w-full rounded-md px-2.5 py-1.5 text-left text-sm transition ${
33
+ active
34
+ ? "bg-sidebar-primary text-sidebar-primary-foreground shadow-xs"
35
+ : "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
36
+ }`;
37
+ }
38
+
39
+ function sectionLabel(text: string) {
40
+ return (
41
+ <p className="mb-1.5 mt-4 px-2.5 text-xs font-semibold uppercase tracking-wider text-sidebar-foreground/50 first:mt-0">
42
+ {text}
43
+ </p>
44
+ );
45
+ }
46
+
47
+ export function Sidebar({
48
+ entries,
49
+ route,
50
+ onNavigate,
51
+ onOpenCommandPalette,
52
+ theme,
53
+ onThemeChange,
54
+ }: Props) {
55
+ const tokensActive = route.view === "tokens";
56
+ const governanceActive = route.view === "governance";
57
+
58
+ const onThemeValueChange = (value: string) => {
59
+ if (value !== "light" && value !== "dark") return;
60
+ onThemeChange(value);
61
+ };
62
+
63
+ return (
64
+ <aside className="fixed flex h-full w-[240px] shrink-0 flex-col overflow-hidden border-r border-sidebar-border bg-sidebar text-sidebar-foreground">
65
+ <div className="sticky top-0 z-10 shrink-0 border-b border-sidebar-border bg-sidebar px-6 py-4">
66
+ <div className="flex items-center justify-between gap-2">
67
+ <div className="flex items-center text-sidebar-foreground">
68
+ <svg
69
+ xmlns="http://www.w3.org/2000/svg"
70
+ width="24"
71
+ height="24"
72
+ viewBox="0 0 24 24"
73
+ >
74
+ <g fill="currentColor">
75
+ <path
76
+ d="m22.346,4.836l-3.182-3.182c-.779-.78-2.049-.78-2.828,0l-3.182,3.182c-.78.78-.78,2.048,0,2.828l3.182,3.182c.39.39.902.585,1.414.585s1.024-.195,1.414-.585l3.182-3.182c.78-.78.78-2.048,0-2.828Z"
77
+ fill="currentColor"
78
+ strokeWidth="0"
79
+ ></path>
80
+ <rect
81
+ x="2"
82
+ y="2"
83
+ width="9"
84
+ height="9"
85
+ rx="2"
86
+ ry="2"
87
+ strokeWidth="0"
88
+ fill="currentColor"
89
+ ></rect>
90
+ <rect
91
+ x="13"
92
+ y="13"
93
+ width="9"
94
+ height="9"
95
+ rx="2"
96
+ ry="2"
97
+ strokeWidth="0"
98
+ fill="currentColor"
99
+ ></rect>
100
+ <rect
101
+ x="2"
102
+ y="13"
103
+ width="9"
104
+ height="9"
105
+ rx="2"
106
+ ry="2"
107
+ strokeWidth="0"
108
+ fill="currentColor"
109
+ ></rect>
110
+ </g>
111
+ </svg>
112
+ </div>
113
+ <button
114
+ type="button"
115
+ onClick={onOpenCommandPalette}
116
+ className="flex shrink-0 items-center gap-1.5 rounded-md px-1.5 py-1 text-sidebar-foreground/70 transition hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
117
+ aria-label="Search components and views"
118
+ >
119
+ <IconSearch className="size-4 shrink-0" aria-hidden />
120
+ <SearchShortcutBadge />
121
+ </button>
122
+ </div>
123
+ </div>
124
+
125
+ <nav className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
126
+ {sectionLabel("Explore")}
127
+ <button
128
+ type="button"
129
+ onClick={() => onNavigate({ view: "governance" })}
130
+ className={navButtonClass(governanceActive)}
131
+ >
132
+ Governance
133
+ </button>
134
+ <button
135
+ type="button"
136
+ onClick={() => onNavigate({ view: "tokens" })}
137
+ className={navButtonClass(tokensActive)}
138
+ >
139
+ Design tokens
140
+ </button>
141
+
142
+ {sectionLabel("Components")}
143
+ <div className="space-y-0.5">
144
+ {entries.map((e, i) => {
145
+ 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);
151
+ 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>
164
+ );
165
+ })}
166
+ </div>
167
+ </nav>
168
+
169
+ <div className="shrink-0 border-t border-sidebar-border px-3 py-3">
170
+ <p className="mb-2 px-2.5 text-xs font-semibold uppercase tracking-wider text-sidebar-foreground/50">
171
+ Appearance
172
+ </p>
173
+ <ToggleGroup
174
+ type="single"
175
+ variant="outline"
176
+ size="sm"
177
+ spacing={0}
178
+ className="w-full"
179
+ value={theme}
180
+ onValueChange={onThemeValueChange}
181
+ aria-label="Color theme"
182
+ >
183
+ <ToggleGroupItem
184
+ value="light"
185
+ className="flex-1"
186
+ aria-label="Light theme"
187
+ title="Light"
188
+ >
189
+ <IconSun className="size-4" aria-hidden />
190
+ </ToggleGroupItem>
191
+ <ToggleGroupItem
192
+ value="dark"
193
+ className="flex-1"
194
+ aria-label="Dark theme"
195
+ title="Dark"
196
+ >
197
+ <IconMoon className="size-4" aria-hidden />
198
+ </ToggleGroupItem>
199
+ </ToggleGroup>
200
+ </div>
201
+ </aside>
202
+ );
203
+ }
@@ -0,0 +1,26 @@
1
+ import type { TokenCatalog } from "../types/tokenCatalog";
2
+ import { TokenWall } from "../dashboard/TokenWall";
3
+
4
+ export function TokensPane({ tokenCatalog }: { tokenCatalog: TokenCatalog }) {
5
+ return (
6
+ <div className="min-h-0 flex-1 overflow-auto bg-muted/40">
7
+ <header className="border-b border-border bg-card px-8 py-6">
8
+ <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
9
+ System
10
+ </p>
11
+ <h1 className="mt-1 text-lg font-semibold tracking-tight text-foreground">
12
+ Design tokens
13
+ </h1>
14
+ <p className="mt-2 text-sm text-muted-foreground">
15
+ Supply a catalog object (same shape as your Tailwind theme docs) —
16
+ keep it aligned with your design tokens.
17
+ </p>
18
+ </header>
19
+ <div className="min-w-0 w-full px-8 py-8">
20
+ <div className="rounded-ds-lg border border-border bg-card p-6 text-card-foreground shadow-xs">
21
+ <TokenWall catalog={tokenCatalog} />
22
+ </div>
23
+ </div>
24
+ </div>
25
+ );
26
+ }
@@ -0,0 +1,53 @@
1
+ import type { PlaygroundControl } from "../types/controls";
2
+
3
+ export type ApiTableRow = {
4
+ prop: string;
5
+ type: string;
6
+ /** Select option values; Type column renders these as badges instead of a `|` string. */
7
+ unionLiterals: string[] | null;
8
+ default: string;
9
+ };
10
+
11
+ function formatDefault(c: PlaygroundControl): string {
12
+ switch (c.type) {
13
+ case "boolean":
14
+ return String(c.default);
15
+ case "number":
16
+ return String(c.default);
17
+ case "string":
18
+ return c.default === "" ? "—" : JSON.stringify(c.default);
19
+ case "select":
20
+ return c.default === "" ? "—" : c.default;
21
+ default:
22
+ return "—";
23
+ }
24
+ }
25
+
26
+ function formatType(c: PlaygroundControl): string {
27
+ switch (c.type) {
28
+ case "boolean":
29
+ return "boolean";
30
+ case "number":
31
+ return "number";
32
+ case "string":
33
+ return "string";
34
+ case "select":
35
+ return "string";
36
+ default:
37
+ return "—";
38
+ }
39
+ }
40
+
41
+ function unionLiteralsForControl(c: PlaygroundControl): string[] | null {
42
+ if (c.type !== "select") return null;
43
+ return c.options.map((o) => o.value);
44
+ }
45
+
46
+ export function controlsToApiRows(controls: PlaygroundControl[]): ApiTableRow[] {
47
+ return controls.map((c) => ({
48
+ prop: c.key,
49
+ type: formatType(c),
50
+ unionLiterals: unionLiteralsForControl(c),
51
+ default: formatDefault(c),
52
+ }));
53
+ }
@@ -0,0 +1,49 @@
1
+ export type HashRoute =
2
+ | { view: "tokens" }
3
+ | { view: "governance" }
4
+ | { view: "component"; componentId: string };
5
+
6
+ const PREFIX = "#!/";
7
+
8
+ function stripShebang(hash: string): string {
9
+ if (hash.startsWith(PREFIX)) {
10
+ return hash.slice(PREFIX.length);
11
+ }
12
+ if (hash.startsWith("#")) {
13
+ return hash.slice(1);
14
+ }
15
+ return hash;
16
+ }
17
+
18
+ export function parseHashRoute(hash: string): HashRoute {
19
+ const raw = stripShebang(hash).trim();
20
+ if (raw === "" || raw === "overview") {
21
+ return { view: "governance" };
22
+ }
23
+ if (raw === "tokens") {
24
+ return { view: "tokens" };
25
+ }
26
+ if (raw === "governance") {
27
+ return { view: "governance" };
28
+ }
29
+ if (raw.startsWith("component/")) {
30
+ const componentId = decodeURIComponent(raw.slice("component/".length));
31
+ if (componentId.length > 0) {
32
+ return { view: "component", componentId };
33
+ }
34
+ }
35
+ return { view: "governance" };
36
+ }
37
+
38
+ export function formatHashRoute(route: HashRoute): string {
39
+ switch (route.view) {
40
+ case "tokens":
41
+ return `${PREFIX}tokens`;
42
+ case "governance":
43
+ return `${PREFIX}governance`;
44
+ case "component":
45
+ return `${PREFIX}component/${encodeURIComponent(route.componentId)}`;
46
+ default:
47
+ return `${PREFIX}governance`;
48
+ }
49
+ }
@@ -0,0 +1,53 @@
1
+ import tsx from "@shikijs/langs/tsx";
2
+ import githubDark from "@shikijs/themes/github-dark";
3
+ import { createHighlighterCore, type HighlighterCore } from "shiki/core";
4
+ import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
5
+
6
+ const THEME = "github-dark";
7
+ const LANG = "tsx";
8
+
9
+ /** When true, run Twoslash (CDN ATA + hover) before highlighting. */
10
+ export function usageSnippetNeedsTwoslash(source: string): boolean {
11
+ return (
12
+ /\^\?/.test(source) ||
13
+ /\/\/\s*@(?:errors|error|log|warn|filename|noErrors)/m.test(source) ||
14
+ /\/\/\s*---cut---/.test(source)
15
+ );
16
+ }
17
+
18
+ let highlighterPromise: Promise<HighlighterCore> | null = null;
19
+
20
+ function getHighlighter(): Promise<HighlighterCore> {
21
+ if (highlighterPromise == null) {
22
+ highlighterPromise = createHighlighterCore({
23
+ themes: [githubDark],
24
+ langs: [tsx],
25
+ engine: createJavaScriptRegexEngine(),
26
+ });
27
+ }
28
+ return highlighterPromise;
29
+ }
30
+
31
+ function assertNotAborted(signal: AbortSignal | undefined): void {
32
+ if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
33
+ }
34
+
35
+ /**
36
+ * Renders usage source to Shiki HTML. Twoslash runs only when {@link usageSnippetNeedsTwoslash} is true
37
+ * (loaded in a separate async chunk so the default bundle stays smaller).
38
+ */
39
+ export async function renderPlaygroundUsageHtml(
40
+ source: string,
41
+ signal?: AbortSignal,
42
+ ): Promise<string> {
43
+ const highlighter = await getHighlighter();
44
+ assertNotAborted(signal);
45
+
46
+ if (!usageSnippetNeedsTwoslash(source)) {
47
+ return highlighter.codeToHtml(source, { lang: LANG, theme: THEME });
48
+ }
49
+
50
+ const { renderUsageWithTwoslash } = await import("./playgroundUsageTwoslash");
51
+ assertNotAborted(signal);
52
+ return renderUsageWithTwoslash(highlighter, source, signal);
53
+ }
@@ -0,0 +1,69 @@
1
+ import { createTransformerFactory, rendererRich } from "@shikijs/twoslash/core";
2
+ import type { ShikiTransformer } from "@shikijs/types";
3
+ import type { HighlighterCore } from "shiki/core";
4
+ import { createTwoslashFromCDN, type TwoslashCdnReturn } from "twoslash-cdn";
5
+ import type { CompilerOptions } from "typescript";
6
+
7
+ const THEME = "github-dark";
8
+ const LANG = "tsx";
9
+
10
+ let twoslashPromise: Promise<TwoslashCdnReturn> | null = null;
11
+
12
+ function getTwoslash(): Promise<TwoslashCdnReturn> {
13
+ twoslashPromise ??= (async () => {
14
+ const compilerOptions = {
15
+ lib: ["esnext", "dom"],
16
+ jsx: "react-jsx",
17
+ moduleResolution: "bundler",
18
+ skipLibCheck: true,
19
+ target: "ESNext",
20
+ module: "ESNext",
21
+ allowSyntheticDefaultImports: true,
22
+ esModuleInterop: true,
23
+ } as unknown as CompilerOptions;
24
+
25
+ const tw = createTwoslashFromCDN({
26
+ compilerOptions,
27
+ });
28
+ await tw.init();
29
+ return tw;
30
+ })();
31
+ return twoslashPromise;
32
+ }
33
+
34
+ let twoslashTransformerPromise: Promise<ShikiTransformer> | null = null;
35
+
36
+ function getTwoslashTransformer(): Promise<ShikiTransformer> {
37
+ twoslashTransformerPromise ??= getTwoslash().then((tw) =>
38
+ createTransformerFactory(tw.runSync)({
39
+ renderer: rendererRich(),
40
+ throws: false,
41
+ langs: ["ts", "tsx"],
42
+ }),
43
+ );
44
+ return twoslashTransformerPromise;
45
+ }
46
+
47
+ function assertNotAborted(signal: AbortSignal | undefined): void {
48
+ if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
49
+ }
50
+
51
+ export async function renderUsageWithTwoslash(
52
+ highlighter: HighlighterCore,
53
+ source: string,
54
+ signal?: AbortSignal,
55
+ ): Promise<string> {
56
+ const tw = await getTwoslash();
57
+ assertNotAborted(signal);
58
+ await tw.prepareTypes(source);
59
+ assertNotAborted(signal);
60
+
61
+ const transformer = await getTwoslashTransformer();
62
+ assertNotAborted(signal);
63
+
64
+ return highlighter.codeToHtml(source, {
65
+ lang: LANG,
66
+ theme: THEME,
67
+ transformers: [transformer],
68
+ });
69
+ }
@@ -0,0 +1,29 @@
1
+ import { useCallback, useSyncExternalStore } from "react";
2
+ import { formatHashRoute, parseHashRoute, type HashRoute } from "./hashRoute";
3
+
4
+ function subscribe(onStoreChange: () => void) {
5
+ window.addEventListener("hashchange", onStoreChange);
6
+ return () => window.removeEventListener("hashchange", onStoreChange);
7
+ }
8
+
9
+ function getHashSnapshot() {
10
+ return window.location.hash || "#!/governance";
11
+ }
12
+
13
+ function getServerHashSnapshot() {
14
+ return "#!/governance";
15
+ }
16
+
17
+ export function useHashRoute(): [HashRoute, (next: HashRoute) => void] {
18
+ const hash = useSyncExternalStore(subscribe, getHashSnapshot, getServerHashSnapshot);
19
+ const route = parseHashRoute(hash);
20
+
21
+ const navigate = useCallback((next: HashRoute) => {
22
+ const nextHash = formatHashRoute(next);
23
+ if (nextHash !== window.location.hash) {
24
+ window.location.hash = nextHash;
25
+ }
26
+ }, []);
27
+
28
+ return [route, navigate];
29
+ }