@checkstack/frontend 0.6.7 → 0.7.1
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 +83 -0
- package/package.json +32 -27
- package/src/App.tsx +123 -47
- package/src/components/Sidebar.tsx +248 -0
- package/src/plugin-loader.test.ts +32 -11
- package/src/plugin-registry.test.ts +58 -1
- package/tailwind-preset.js +87 -0
- package/tailwind.config.js +5 -66
- package/vite.config.ts +55 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,88 @@
|
|
|
1
1
|
# @checkstack/frontend
|
|
2
2
|
|
|
3
|
+
## 0.7.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [13373ce]
|
|
8
|
+
- @checkstack/common@0.14.0
|
|
9
|
+
- @checkstack/about-frontend@0.3.1
|
|
10
|
+
- @checkstack/announcement-frontend@0.4.1
|
|
11
|
+
- @checkstack/auth-frontend@0.7.1
|
|
12
|
+
- @checkstack/catalog-frontend@0.11.1
|
|
13
|
+
- @checkstack/command-frontend@0.3.1
|
|
14
|
+
- @checkstack/dependency-frontend@0.5.1
|
|
15
|
+
- @checkstack/frontend-api@0.7.1
|
|
16
|
+
- @checkstack/signal-common@0.2.7
|
|
17
|
+
- @checkstack/ui@1.13.1
|
|
18
|
+
- @checkstack/signal-frontend@0.2.1
|
|
19
|
+
|
|
20
|
+
## 0.7.0
|
|
21
|
+
|
|
22
|
+
### Minor Changes
|
|
23
|
+
|
|
24
|
+
- 9dcc848: Cut initial-load JS: lazy plugin contributions, a hardened lazy-by-default contribution contract, on-demand Monaco, and a lighter icon/chart load.
|
|
25
|
+
|
|
26
|
+
- Lazy plugin route pages: each plugin's route `element` references a `React.lazy`-wrapped page rendered inside a shared `<Suspense>` boundary. Plugins still register synchronously, so nav, slots, commands, API factories, and `foreignSignals` are available on first paint. This moves ~37 route-page chunks (~600 KB) out of the entry; the entry chunk drops from ~2.4 MB to ~190 KB. Auth flow pages stay eager. The `@checkstack/scripts` scaffold template generates lazy route pages too.
|
|
27
|
+
- Hardened contribution contract (BREAKING, frontend plugin contract): plugins declare contributions lazily and let the framework own code-splitting, Suspense, and per-plugin error isolation. Routes use `load: () => import("./Page").then((m) => ({ default: m.Page }))` instead of `element: <Page />` (`element` is still accepted for the rare page that must paint without a chunk fetch; provide exactly one). Slot extensions accept either an eager `component` or a lazy `load`; new `getLazyContribution` + `ExtensionComponent` exports from `@checkstack/frontend-api` render either kind. This also fixes runtime-installed plugins: `ExtensionSlot` subscribes to the plugin registry, and the API registry rebuilds when the plugin set changes (`getPlugins()` returns an immutable snapshot via `useSyncExternalStore`). A per-plugin error boundary contains a bad contribution.
|
|
28
|
+
- On-demand Monaco: the `@checkstack/ui` barrel no longer pulls the `@codingame/*` / `monaco-languageclient` stack into the initial load. `CodeEditor` lazy-loads its Monaco-backed editor behind `React.lazy` + Suspense, `validateTypeScriptSources` imports the editor API via in-body `await import(...)`, and the "vscode services ready" signal moved to a Monaco-free module. The ~10 MB editor body loads only when a `CodeEditor` mounts. A `react-vendor` `manualChunks` split was added for stable vendor caching.
|
|
29
|
+
- lucide-react 1.x + lighter icons/charts (BREAKING for icon consumers): lucide-react unified from three drifting ranges to `^1.17.0`. lucide v1 removed brand icons, so the GitHub/GitLab marks are vendored in `@checkstack/ui` (`GithubIcon`, `GitlabIcon`, `brandIcons`); a new `IconName` type (`LucideIconName | BrandIconName`) in `@checkstack/common` is canonical, accepted by `AuthStrategy.icon` and the card components, so data-driven brand names keep working. `DynamicIcon` no longer eagerly imports lucide's ~1600-icon map (~1 MB) - it lives in a `React.lazy` `iconRegistry` chunk fetched on first data-driven render, while statically named-imported icons tree-shake normally. The recharts-backed health-check charts (~300 KB) and the `HealthCheckSystemOverview` drawer leave the initial load.
|
|
30
|
+
|
|
31
|
+
BREAKING CHANGES:
|
|
32
|
+
|
|
33
|
+
- Frontend plugin contract: routes/slot contributions are lazy-by-default (`load` instead of `element`/eager elements) as described above.
|
|
34
|
+
- Any external consumer importing a brand icon from `lucide-react` (e.g. `import { Github } from "lucide-react"`) must switch to the vendored `@checkstack/ui` brand icons or a custom SVG.
|
|
35
|
+
|
|
36
|
+
This is a beta minor.
|
|
37
|
+
|
|
38
|
+
- 9dcc848: Move primary navigation into a left sidebar, and serve the user guide in-app.
|
|
39
|
+
|
|
40
|
+
Feature navigation (a ~20-item user-menu dropdown) now lives in a persistent left sidebar (a slide-over drawer on mobile), grouped by section with the active route highlighted; the user menu keeps only account actions. A route opts into the sidebar with new `nav` metadata (`{ group, icon, label?, order?, accessRule? }`) on its registration, co-located with path + access + title. The sidebar filters entries with the same access check as page guards. `@checkstack/common` gains `isAccessRuleSatisfied` and a centralized set of in-app doc slugs (`APP_DOC_SLUGS` + `docsPath`, with a test asserting each resolves to a real docs page); `@checkstack/auth-frontend` exports `useAccessRules`.
|
|
41
|
+
|
|
42
|
+
The backend now serves the Astro Starlight docs build same-origin at `/checkstack/*` (the same artifact deployed to GitHub Pages), so the user guide is available inside the app including for self-hosted / air-gapped installs (served verbatim, no rebuild, no link rewriting; from `CHECKSTACK_DOCS_DIST`, before the SPA catch-all, degrading gracefully when absent; the Docker image builds and ships `docs/dist`; Vite proxies `/checkstack` in dev). The "Docs" link is a shell-owned external sidebar entry under the Documentation group (book icon), opening `/checkstack/user-guide/` in a new tab; the group renders even when no plugin route contributes to it.
|
|
43
|
+
|
|
44
|
+
BREAKING (plugin authors): `UserMenuItemsSlot` is no longer the way to add navigation - registering a top user-menu item no longer surfaces it anywhere. Add `nav` to the page's route instead. `UserMenuItemsBottomSlot` (account items) is unchanged. All bundled plugins have been migrated.
|
|
45
|
+
|
|
46
|
+
This is a beta minor.
|
|
47
|
+
|
|
48
|
+
- 9dcc848: Align workspace dependency versions and migrate React Router to v7.
|
|
49
|
+
|
|
50
|
+
BREAKING CHANGES (React Router v7): All frontend packages now depend on `react-router-dom@^7.16.0`. Previously the workspace declared four divergent ranges (`^6.20.0`, `^6.22.0`, `^7.1.1`, `^7.14.2`), which resolved both `react-router@6` and `react-router@7` into a single bundle. Everything is now unified on v7. The public imports the app uses (`BrowserRouter`, `Routes`, `Route`, `Link`, `NavLink`, `MemoryRouter`, `useNavigate`, `useParams`, `useSearchParams`, `useLocation`) are unchanged between v6 and v7, so no source rewrites were required - but any out-of-tree plugin still on react-router v6 should upgrade to v7 (see the React Router v6 -> v7 upgrade guide) to share the host's single router instance via the import map.
|
|
51
|
+
|
|
52
|
+
Other unified ranges (no API change): `react` -> `^18.3.1`, the `@orpc/*` family (`contract`, `server`, `client`, `tanstack-query`, `openapi`, `zod`) -> `^1.14.4`, and `better-auth` -> `^1.6.13`.
|
|
53
|
+
|
|
54
|
+
Removed the pre-rename `@orpc/react-query` leftover from `@checkstack/frontend-api`; its `createRouterUtils` / `RouterUtils` / `ProcedureUtils` now come from `@orpc/tanstack-query` (the package already in use).
|
|
55
|
+
|
|
56
|
+
Stale in-range runtime deps pulled up to current published versions: `hono` `^4.12.23`, `@tanstack/react-query` (+devtools) `^5.100.14`, `date-fns` `^4.4.0`, `jose` `^6.2.3`, `tar` `^7.5.16`, `semver` `^7.8.1`, `@xyflow/react` `^12.11.0`.
|
|
57
|
+
|
|
58
|
+
### Patch Changes
|
|
59
|
+
|
|
60
|
+
- Updated dependencies [9dcc848]
|
|
61
|
+
- Updated dependencies [9dcc848]
|
|
62
|
+
- Updated dependencies [9dcc848]
|
|
63
|
+
- Updated dependencies [9dcc848]
|
|
64
|
+
- Updated dependencies [9dcc848]
|
|
65
|
+
- Updated dependencies [9dcc848]
|
|
66
|
+
- Updated dependencies [9dcc848]
|
|
67
|
+
- Updated dependencies [9dcc848]
|
|
68
|
+
- Updated dependencies [9dcc848]
|
|
69
|
+
- Updated dependencies [9dcc848]
|
|
70
|
+
- Updated dependencies [9dcc848]
|
|
71
|
+
- Updated dependencies [9dcc848]
|
|
72
|
+
- Updated dependencies [9dcc848]
|
|
73
|
+
- Updated dependencies [9dcc848]
|
|
74
|
+
- @checkstack/ui@1.13.0
|
|
75
|
+
- @checkstack/auth-frontend@0.7.0
|
|
76
|
+
- @checkstack/catalog-frontend@0.11.0
|
|
77
|
+
- @checkstack/common@0.13.0
|
|
78
|
+
- @checkstack/dependency-frontend@0.5.0
|
|
79
|
+
- @checkstack/frontend-api@0.7.0
|
|
80
|
+
- @checkstack/about-frontend@0.3.0
|
|
81
|
+
- @checkstack/announcement-frontend@0.4.0
|
|
82
|
+
- @checkstack/command-frontend@0.3.0
|
|
83
|
+
- @checkstack/signal-frontend@0.2.0
|
|
84
|
+
- @checkstack/signal-common@0.2.6
|
|
85
|
+
|
|
3
86
|
## 0.6.7
|
|
4
87
|
|
|
5
88
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/frontend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"checkstack": {
|
|
6
6
|
"type": "frontend"
|
|
7
7
|
},
|
|
8
8
|
"type": "module",
|
|
9
|
+
"exports": {
|
|
10
|
+
"./package.json": "./package.json",
|
|
11
|
+
"./tailwind-preset": "./tailwind-preset.js",
|
|
12
|
+
"./*": "./*"
|
|
13
|
+
},
|
|
9
14
|
"scripts": {
|
|
10
15
|
"dev": "vite",
|
|
11
16
|
"build:vendor": "vite build --config vite.config.vendor.ts",
|
|
@@ -16,39 +21,39 @@
|
|
|
16
21
|
"lint:code": "eslint . --max-warnings 0"
|
|
17
22
|
},
|
|
18
23
|
"dependencies": {
|
|
19
|
-
"@checkstack/about-frontend": "0.
|
|
20
|
-
"@checkstack/announcement-frontend": "0.
|
|
21
|
-
"@checkstack/auth-frontend": "0.
|
|
22
|
-
"@checkstack/catalog-frontend": "0.
|
|
23
|
-
"@checkstack/command-frontend": "0.
|
|
24
|
-
"@checkstack/common": "0.
|
|
25
|
-
"@checkstack/dependency-frontend": "0.
|
|
26
|
-
"@checkstack/frontend-api": "0.
|
|
27
|
-
"@checkstack/signal-common": "0.2.
|
|
28
|
-
"@checkstack/signal-frontend": "0.
|
|
29
|
-
"@checkstack/ui": "1.
|
|
30
|
-
"@orpc/client": "^1.
|
|
31
|
-
"@tanstack/react-query": "^5.
|
|
32
|
-
"@tanstack/react-query-devtools": "^5.
|
|
33
|
-
"
|
|
24
|
+
"@checkstack/about-frontend": "0.3.0",
|
|
25
|
+
"@checkstack/announcement-frontend": "0.4.0",
|
|
26
|
+
"@checkstack/auth-frontend": "0.7.0",
|
|
27
|
+
"@checkstack/catalog-frontend": "0.11.0",
|
|
28
|
+
"@checkstack/command-frontend": "0.3.0",
|
|
29
|
+
"@checkstack/common": "0.13.0",
|
|
30
|
+
"@checkstack/dependency-frontend": "0.5.0",
|
|
31
|
+
"@checkstack/frontend-api": "0.7.0",
|
|
32
|
+
"@checkstack/signal-common": "0.2.6",
|
|
33
|
+
"@checkstack/signal-frontend": "0.2.0",
|
|
34
|
+
"@checkstack/ui": "1.13.0",
|
|
35
|
+
"@orpc/client": "^1.14.4",
|
|
36
|
+
"@tanstack/react-query": "^5.100.14",
|
|
37
|
+
"@tanstack/react-query-devtools": "^5.100.14",
|
|
38
|
+
"autoprefixer": "^10.4.18",
|
|
39
|
+
"better-auth": "^1.6.13",
|
|
34
40
|
"class-variance-authority": "^0.7.0",
|
|
35
41
|
"clsx": "^2.1.0",
|
|
36
|
-
"lucide-react": "^
|
|
37
|
-
"react": "^18.
|
|
42
|
+
"lucide-react": "^1.17.0",
|
|
43
|
+
"react": "^18.3.1",
|
|
38
44
|
"react-dom": "^18.2.0",
|
|
39
|
-
"react-router-dom": "^
|
|
40
|
-
"tailwind-merge": "^2.2.0"
|
|
45
|
+
"react-router-dom": "^7.16.0",
|
|
46
|
+
"tailwind-merge": "^2.2.0",
|
|
47
|
+
"tailwindcss": "^3.4.1",
|
|
48
|
+
"tailwindcss-animate": "^1.0.7"
|
|
41
49
|
},
|
|
42
50
|
"devDependencies": {
|
|
43
|
-
"@checkstack/scripts": "0.
|
|
51
|
+
"@checkstack/scripts": "0.4.0",
|
|
44
52
|
"@checkstack/tsconfig": "0.0.7",
|
|
45
53
|
"@types/react": "^18.2.64",
|
|
46
54
|
"@types/react-dom": "^18.2.21",
|
|
47
|
-
"@vitejs/plugin-react": "^6.0.
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"tailwindcss": "^3.4.1",
|
|
51
|
-
"tailwindcss-animate": "^1.0.7",
|
|
52
|
-
"vite": "^8.0.8"
|
|
55
|
+
"@vitejs/plugin-react": "^6.0.2",
|
|
56
|
+
"postcss": "^8.5.15",
|
|
57
|
+
"vite": "^8.0.16"
|
|
53
58
|
}
|
|
54
59
|
}
|
package/src/App.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMemo } from "react";
|
|
1
|
+
import { useMemo, useState, useSyncExternalStore } from "react";
|
|
2
2
|
import {
|
|
3
3
|
BrowserRouter,
|
|
4
4
|
Routes,
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
Link,
|
|
7
7
|
useNavigate,
|
|
8
8
|
} from "react-router-dom";
|
|
9
|
+
import { Menu } from "lucide-react";
|
|
9
10
|
import {
|
|
10
11
|
ApiProvider,
|
|
11
12
|
ApiRegistryBuilder,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
rpcApiRef,
|
|
16
17
|
useApi,
|
|
17
18
|
ExtensionSlot,
|
|
19
|
+
getLazyContribution,
|
|
18
20
|
pluginRegistry,
|
|
19
21
|
DashboardSlot,
|
|
20
22
|
NavbarRightSlot,
|
|
@@ -44,6 +46,7 @@ import {
|
|
|
44
46
|
import { SignalProvider } from "@checkstack/signal-frontend";
|
|
45
47
|
import { SignalAutoInvalidator } from "./components/SignalAutoInvalidator";
|
|
46
48
|
import { SessionProvider } from "@checkstack/auth-frontend";
|
|
49
|
+
import { Sidebar } from "./components/Sidebar";
|
|
47
50
|
import { usePluginLifecycle } from "./hooks/usePluginLifecycle";
|
|
48
51
|
import { useCommands, useGlobalShortcuts } from "@checkstack/command-frontend";
|
|
49
52
|
import { AnnouncementBanner } from "@checkstack/announcement-frontend";
|
|
@@ -81,6 +84,26 @@ const queryClient = new QueryClient({
|
|
|
81
84
|
},
|
|
82
85
|
});
|
|
83
86
|
|
|
87
|
+
// Stable references for `useSyncExternalStore` subscription to the plugin
|
|
88
|
+
// registry (module scope so they don't change identity across renders).
|
|
89
|
+
const subscribePluginRegistry = (onChange: () => void) =>
|
|
90
|
+
pluginRegistry.subscribe(onChange);
|
|
91
|
+
const getRegisteredPlugins = () => pluginRegistry.getPlugins();
|
|
92
|
+
|
|
93
|
+
// Shared fallbacks for lazily-loaded plugin route pages. The loading state
|
|
94
|
+
// mirrors RouteGuard's access-loading look (usePerformance-aware spinner); the
|
|
95
|
+
// error state contains a failed page load instead of white-screening the shell.
|
|
96
|
+
const ROUTE_SUSPENSE_FALLBACK = (
|
|
97
|
+
<div className="h-full flex items-center justify-center p-8">
|
|
98
|
+
<LoadingSpinner />
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
const ROUTE_ERROR_FALLBACK = (
|
|
102
|
+
<div className="h-full flex items-center justify-center p-8 text-sm text-muted-foreground">
|
|
103
|
+
This page failed to load. Try reloading.
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
|
|
84
107
|
/**
|
|
85
108
|
* Component that registers global keyboard shortcuts for all commands.
|
|
86
109
|
* Uses react-router's navigate for SPA navigation.
|
|
@@ -132,40 +155,61 @@ function AppContent() {
|
|
|
132
155
|
// Enable dynamic plugin loading/unloading via signals
|
|
133
156
|
const { isLowPower } = usePerformance();
|
|
134
157
|
usePluginLifecycle();
|
|
158
|
+
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
|
135
159
|
|
|
136
160
|
return (
|
|
137
161
|
<BrowserRouter>
|
|
138
162
|
{/* Global keyboard shortcuts for commands */}
|
|
139
163
|
<GlobalShortcuts />
|
|
140
164
|
<AmbientBackground className="text-foreground font-sans">
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
<
|
|
147
|
-
{
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
165
|
+
{/* App shell: a full-height column - the header spans the FULL width on
|
|
166
|
+
top, and below it a row holds the left nav (persistent on desktop,
|
|
167
|
+
drawer on mobile) beside the scrollable content. */}
|
|
168
|
+
<div className="flex flex-col h-screen overflow-hidden">
|
|
169
|
+
<AnnouncementBanner />
|
|
170
|
+
<header
|
|
171
|
+
className={cn(
|
|
172
|
+
"shrink-0 p-4 shadow-sm border-b border-border z-40 relative",
|
|
173
|
+
isLowPower ? "bg-card" : "bg-card/80 backdrop-blur-sm",
|
|
174
|
+
)}
|
|
175
|
+
>
|
|
176
|
+
<div className="flex items-center justify-between gap-4">
|
|
177
|
+
{/* Left: hamburger (mobile), logo, optional navbar-left slot */}
|
|
178
|
+
<div className="flex items-center gap-4 md:gap-8 flex-shrink-0">
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
onClick={() => setMobileNavOpen(true)}
|
|
182
|
+
aria-label="Open navigation"
|
|
183
|
+
className="md:hidden inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
184
|
+
>
|
|
185
|
+
<Menu className="h-5 w-5" />
|
|
186
|
+
</button>
|
|
187
|
+
<Link to="/" className="flex items-center gap-2">
|
|
188
|
+
<img src="/favicon.svg" alt="" className="w-7 h-7" />
|
|
189
|
+
<h1 className="text-xl font-bold text-primary">Checkstack</h1>
|
|
190
|
+
</Link>
|
|
191
|
+
<nav className="hidden md:flex gap-1">
|
|
192
|
+
<ExtensionSlot slot={NavbarLeftSlot} />
|
|
193
|
+
</nav>
|
|
194
|
+
</div>
|
|
195
|
+
{/* Center: Search (flexible width, centered) */}
|
|
196
|
+
<div className="flex-1 flex justify-center max-w-md">
|
|
197
|
+
<ExtensionSlot slot={NavbarCenterSlot} />
|
|
198
|
+
</div>
|
|
199
|
+
{/* Right: Other navbar items */}
|
|
200
|
+
<div className="flex items-center gap-2 flex-shrink-0">
|
|
201
|
+
<ExtensionSlot slot={NavbarRightSlot} />
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
</header>
|
|
205
|
+
<div className="flex flex-1 min-h-0">
|
|
206
|
+
<Sidebar
|
|
207
|
+
mobileOpen={mobileNavOpen}
|
|
208
|
+
onMobileOpenChange={setMobileNavOpen}
|
|
209
|
+
/>
|
|
210
|
+
<main className="flex-1 min-w-0 overflow-y-auto">
|
|
211
|
+
<div className="px-3 py-4 md:p-8 w-full max-w-7xl mx-auto">
|
|
212
|
+
<Routes>
|
|
169
213
|
<Route
|
|
170
214
|
path="/"
|
|
171
215
|
element={
|
|
@@ -174,22 +218,46 @@ function AppContent() {
|
|
|
174
218
|
</div>
|
|
175
219
|
}
|
|
176
220
|
/>
|
|
177
|
-
{/* Plugin Routes
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
221
|
+
{/* Plugin Routes. Each plugin declares its page via a `load` thunk;
|
|
222
|
+
the framework (`getLazyContribution`) code-splits it and wraps it
|
|
223
|
+
in Suspense + a per-plugin error boundary, so the page chunk is
|
|
224
|
+
fetched on navigation (not in the initial load) and a failing
|
|
225
|
+
page degrades gracefully instead of crashing the shell. */}
|
|
226
|
+
{pluginRegistry.getAllRoutes().map((route) => {
|
|
227
|
+
// A route is either lazy (`load`, the default — framework wraps it
|
|
228
|
+
// in Suspense + error boundary) or eager (`element`, rare — e.g.
|
|
229
|
+
// the login page on the critical path).
|
|
230
|
+
let element: React.ReactNode;
|
|
231
|
+
if (route.load) {
|
|
232
|
+
const PageElement = getLazyContribution({
|
|
233
|
+
id: route.path,
|
|
234
|
+
load: route.load,
|
|
235
|
+
suspenseFallback: ROUTE_SUSPENSE_FALLBACK,
|
|
236
|
+
errorFallback: ROUTE_ERROR_FALLBACK,
|
|
237
|
+
});
|
|
238
|
+
element = <PageElement />;
|
|
239
|
+
} else {
|
|
240
|
+
element = route.element;
|
|
241
|
+
}
|
|
242
|
+
return (
|
|
243
|
+
<Route
|
|
244
|
+
key={route.path}
|
|
245
|
+
path={route.path}
|
|
246
|
+
element={
|
|
247
|
+
<RouteGuard accessRule={route.accessRule}>
|
|
248
|
+
{element}
|
|
249
|
+
</RouteGuard>
|
|
250
|
+
}
|
|
251
|
+
/>
|
|
252
|
+
);
|
|
253
|
+
})}
|
|
254
|
+
{/* Catch-all: show Not Found for unmatched routes */}
|
|
255
|
+
<Route path="*" element={<NotFound />} />
|
|
256
|
+
</Routes>
|
|
257
|
+
</div>
|
|
258
|
+
</main>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
193
261
|
</AmbientBackground>
|
|
194
262
|
</BrowserRouter>
|
|
195
263
|
);
|
|
@@ -204,6 +272,15 @@ function AppWithApis() {
|
|
|
204
272
|
useRuntimeConfigContext();
|
|
205
273
|
const { baseUrl } = useRuntimeConfig();
|
|
206
274
|
|
|
275
|
+
// Subscribe to the plugin registry so the API registry below rebuilds when
|
|
276
|
+
// plugins register/unregister at runtime (e.g. a remotely-installed plugin),
|
|
277
|
+
// picking up their `apis` factories. `getPlugins()` returns an immutable
|
|
278
|
+
// snapshot whose reference changes on every registry change.
|
|
279
|
+
const plugins = useSyncExternalStore(
|
|
280
|
+
subscribePluginRegistry,
|
|
281
|
+
getRegisteredPlugins,
|
|
282
|
+
);
|
|
283
|
+
|
|
207
284
|
const apiRegistry = useMemo(() => {
|
|
208
285
|
// Initialize API Registry with core apiRefs
|
|
209
286
|
const registryBuilder = new ApiRegistryBuilder()
|
|
@@ -219,7 +296,6 @@ function AppWithApis() {
|
|
|
219
296
|
});
|
|
220
297
|
|
|
221
298
|
// Register API factories from plugins
|
|
222
|
-
const plugins = pluginRegistry.getPlugins();
|
|
223
299
|
for (const plugin of plugins) {
|
|
224
300
|
if (plugin.apis) {
|
|
225
301
|
for (const api of plugin.apis) {
|
|
@@ -235,7 +311,7 @@ function AppWithApis() {
|
|
|
235
311
|
}
|
|
236
312
|
|
|
237
313
|
return registryBuilder.build();
|
|
238
|
-
}, [baseUrl]);
|
|
314
|
+
}, [baseUrl, plugins]);
|
|
239
315
|
|
|
240
316
|
// Show spinner while fetching runtime config and probing baseUrl.
|
|
241
317
|
if (isConfigLoading) {
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import React, { useMemo, useState } from "react";
|
|
2
|
+
import { NavLink, useLocation } from "react-router-dom";
|
|
3
|
+
import { pluginRegistry } from "@checkstack/frontend-api";
|
|
4
|
+
import { useAccessRules } from "@checkstack/auth-frontend";
|
|
5
|
+
import {
|
|
6
|
+
isAccessRuleSatisfied,
|
|
7
|
+
APP_DOC_SLUGS,
|
|
8
|
+
docsPath,
|
|
9
|
+
} from "@checkstack/common";
|
|
10
|
+
import {
|
|
11
|
+
Sheet,
|
|
12
|
+
SheetContent,
|
|
13
|
+
SheetHeader,
|
|
14
|
+
SheetTitle,
|
|
15
|
+
cn,
|
|
16
|
+
usePerformance,
|
|
17
|
+
} from "@checkstack/ui";
|
|
18
|
+
import { LayoutDashboard, ChevronDown, BookOpen } from "lucide-react";
|
|
19
|
+
|
|
20
|
+
const DOCUMENTATION_GROUP = "Documentation";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Fixed display order for the known nav sections. Unknown groups (e.g. from a
|
|
24
|
+
* third-party plugin) are appended after these, alphabetically.
|
|
25
|
+
*/
|
|
26
|
+
const GROUP_ORDER = [
|
|
27
|
+
"Workspace",
|
|
28
|
+
"Reliability",
|
|
29
|
+
"Automation",
|
|
30
|
+
"Configuration",
|
|
31
|
+
"Documentation",
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
const COLLAPSED_GROUPS_KEY = "checkstack.sidebar.collapsedGroups";
|
|
35
|
+
|
|
36
|
+
type AppRoute = ReturnType<typeof pluginRegistry.getAllRoutes>[number];
|
|
37
|
+
type NavRoute = AppRoute & { nav: NonNullable<AppRoute["nav"]> };
|
|
38
|
+
|
|
39
|
+
interface NavGroup {
|
|
40
|
+
group: string;
|
|
41
|
+
items: NavRoute[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Build the access-filtered, grouped, ordered nav model from the route registry. */
|
|
45
|
+
function useNavGroups(): NavGroup[] {
|
|
46
|
+
const { accessRules } = useAccessRules();
|
|
47
|
+
|
|
48
|
+
// getAllRoutes() is recomputed from the registry; plugin load/unload triggers
|
|
49
|
+
// an App re-render so this stays current. Cheap O(routes) work.
|
|
50
|
+
const routes = pluginRegistry.getAllRoutes();
|
|
51
|
+
|
|
52
|
+
return useMemo(() => {
|
|
53
|
+
const visible = routes.filter(
|
|
54
|
+
(route): route is NavRoute =>
|
|
55
|
+
route.nav !== undefined &&
|
|
56
|
+
(route.nav.accessRule === undefined ||
|
|
57
|
+
isAccessRuleSatisfied(accessRules, route.nav.accessRule)),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const byGroup = new Map<string, NavRoute[]>();
|
|
61
|
+
for (const route of visible) {
|
|
62
|
+
const list = byGroup.get(route.nav.group);
|
|
63
|
+
if (list) list.push(route);
|
|
64
|
+
else byGroup.set(route.nav.group, [route]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const orderOf = (group: string): number => {
|
|
68
|
+
const index = GROUP_ORDER.indexOf(group as (typeof GROUP_ORDER)[number]);
|
|
69
|
+
return index === -1 ? GROUP_ORDER.length : index;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return [...byGroup.entries()]
|
|
73
|
+
.toSorted(([a], [b]) => orderOf(a) - orderOf(b) || a.localeCompare(b))
|
|
74
|
+
.map(([group, items]) => ({
|
|
75
|
+
group,
|
|
76
|
+
items: items.toSorted(
|
|
77
|
+
(a, b) =>
|
|
78
|
+
a.nav.order - b.nav.order || a.nav.label.localeCompare(b.nav.label),
|
|
79
|
+
),
|
|
80
|
+
}));
|
|
81
|
+
}, [routes, accessRules]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function loadCollapsedGroups(): Set<string> {
|
|
85
|
+
try {
|
|
86
|
+
const raw = globalThis.localStorage?.getItem(COLLAPSED_GROUPS_KEY);
|
|
87
|
+
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
|
88
|
+
return new Set(Array.isArray(parsed) ? parsed.filter((g) => typeof g === "string") : []);
|
|
89
|
+
} catch {
|
|
90
|
+
return new Set();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function navItemClass(isActive: boolean): string {
|
|
95
|
+
return cn(
|
|
96
|
+
"flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
|
|
97
|
+
isActive
|
|
98
|
+
? "bg-primary/10 text-primary font-medium"
|
|
99
|
+
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface NavListProps {
|
|
104
|
+
/** Invoked after a nav link is clicked (used to close the mobile drawer). */
|
|
105
|
+
onNavigate?: () => void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The shared nav content rendered in both the desktop rail and the mobile drawer. */
|
|
109
|
+
function NavList({ onNavigate }: NavListProps): React.ReactElement {
|
|
110
|
+
const { isLowPower } = usePerformance();
|
|
111
|
+
const groups = useNavGroups();
|
|
112
|
+
const location = useLocation();
|
|
113
|
+
const [collapsed, setCollapsed] = useState<Set<string>>(loadCollapsedGroups);
|
|
114
|
+
|
|
115
|
+
// The in-app user guide is a shell-owned external entry (the backend serves
|
|
116
|
+
// the static Astro docs at /checkstack/* same-origin), so it lives under the
|
|
117
|
+
// Documentation group here rather than as a navbar link. Ensure the group
|
|
118
|
+
// renders even when no plugin route contributes to it.
|
|
119
|
+
const groupsToRender = useMemo(
|
|
120
|
+
() =>
|
|
121
|
+
groups.some((g) => g.group === DOCUMENTATION_GROUP)
|
|
122
|
+
? groups
|
|
123
|
+
: [...groups, { group: DOCUMENTATION_GROUP, items: [] }],
|
|
124
|
+
[groups],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const toggleGroup = (group: string): void => {
|
|
128
|
+
setCollapsed((prev) => {
|
|
129
|
+
const next = new Set(prev);
|
|
130
|
+
if (next.has(group)) next.delete(group);
|
|
131
|
+
else next.add(group);
|
|
132
|
+
try {
|
|
133
|
+
globalThis.localStorage?.setItem(
|
|
134
|
+
COLLAPSED_GROUPS_KEY,
|
|
135
|
+
JSON.stringify([...next]),
|
|
136
|
+
);
|
|
137
|
+
} catch {
|
|
138
|
+
// localStorage unavailable (private mode) - in-memory state is enough.
|
|
139
|
+
}
|
|
140
|
+
return next;
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<nav className="flex flex-col gap-1 p-3" aria-label="Primary">
|
|
146
|
+
{/* Dashboard / home is the one entry the shell owns (route "/"). */}
|
|
147
|
+
<NavLink to="/" end className={({ isActive }) => navItemClass(isActive)} onClick={onNavigate}>
|
|
148
|
+
<LayoutDashboard className="h-4 w-4 shrink-0" />
|
|
149
|
+
<span className="truncate">Dashboard</span>
|
|
150
|
+
</NavLink>
|
|
151
|
+
|
|
152
|
+
{groupsToRender.map(({ group, items }) => {
|
|
153
|
+
const isCollapsed = collapsed.has(group);
|
|
154
|
+
const isDocumentation = group === DOCUMENTATION_GROUP;
|
|
155
|
+
return (
|
|
156
|
+
<div key={group} className="mt-2">
|
|
157
|
+
<button
|
|
158
|
+
type="button"
|
|
159
|
+
onClick={() => toggleGroup(group)}
|
|
160
|
+
aria-expanded={!isCollapsed}
|
|
161
|
+
className="flex w-full items-center justify-between px-3 py-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground/70 hover:text-muted-foreground"
|
|
162
|
+
>
|
|
163
|
+
<span className="truncate">{group}</span>
|
|
164
|
+
<ChevronDown
|
|
165
|
+
className={cn(
|
|
166
|
+
"h-3.5 w-3.5 shrink-0",
|
|
167
|
+
!isLowPower && "transition-transform",
|
|
168
|
+
isCollapsed && "-rotate-90",
|
|
169
|
+
)}
|
|
170
|
+
aria-hidden="true"
|
|
171
|
+
/>
|
|
172
|
+
</button>
|
|
173
|
+
{!isCollapsed && (
|
|
174
|
+
<div className="mt-1 flex flex-col gap-0.5">
|
|
175
|
+
{items.map((route) => {
|
|
176
|
+
const Icon = route.nav.icon;
|
|
177
|
+
const active =
|
|
178
|
+
location.pathname === route.path ||
|
|
179
|
+
location.pathname.startsWith(`${route.path}/`);
|
|
180
|
+
return (
|
|
181
|
+
<NavLink
|
|
182
|
+
key={route.path}
|
|
183
|
+
to={route.path}
|
|
184
|
+
className={navItemClass(active)}
|
|
185
|
+
onClick={onNavigate}
|
|
186
|
+
>
|
|
187
|
+
<Icon className="h-4 w-4 shrink-0" />
|
|
188
|
+
<span className="truncate">{route.nav.label}</span>
|
|
189
|
+
</NavLink>
|
|
190
|
+
);
|
|
191
|
+
})}
|
|
192
|
+
{isDocumentation && (
|
|
193
|
+
<a
|
|
194
|
+
href={docsPath(APP_DOC_SLUGS.userGuideHome)}
|
|
195
|
+
target="_blank"
|
|
196
|
+
rel="noreferrer"
|
|
197
|
+
title="Open the user guide"
|
|
198
|
+
className={navItemClass(false)}
|
|
199
|
+
onClick={onNavigate}
|
|
200
|
+
>
|
|
201
|
+
<BookOpen className="h-4 w-4 shrink-0" />
|
|
202
|
+
<span className="truncate">Docs</span>
|
|
203
|
+
</a>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
})}
|
|
210
|
+
</nav>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface SidebarProps {
|
|
215
|
+
/** Whether the mobile drawer is open. */
|
|
216
|
+
mobileOpen: boolean;
|
|
217
|
+
onMobileOpenChange: (open: boolean) => void;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Primary app navigation. A persistent rail on >= md screens; a slide-over
|
|
222
|
+
* drawer (reusing the UI `Sheet`) on small screens, controlled by the navbar
|
|
223
|
+
* hamburger. Nav entries come from routes that declare `nav` metadata and are
|
|
224
|
+
* filtered by the user's access rules.
|
|
225
|
+
*/
|
|
226
|
+
export function Sidebar({
|
|
227
|
+
mobileOpen,
|
|
228
|
+
onMobileOpenChange,
|
|
229
|
+
}: SidebarProps): React.ReactElement {
|
|
230
|
+
return (
|
|
231
|
+
<>
|
|
232
|
+
{/* Fills the shell row (height comes from the flex parent); scrolls
|
|
233
|
+
independently of the main content. */}
|
|
234
|
+
<aside className="hidden md:flex flex-col w-60 shrink-0 border-r border-border overflow-y-auto">
|
|
235
|
+
<NavList />
|
|
236
|
+
</aside>
|
|
237
|
+
|
|
238
|
+
<Sheet open={mobileOpen} onOpenChange={onMobileOpenChange}>
|
|
239
|
+
<SheetContent className="p-0 overflow-y-auto">
|
|
240
|
+
<SheetHeader className="px-4 py-3 border-b border-border">
|
|
241
|
+
<SheetTitle className="text-base">Navigation</SheetTitle>
|
|
242
|
+
</SheetHeader>
|
|
243
|
+
<NavList onNavigate={() => onMobileOpenChange(false)} />
|
|
244
|
+
</SheetContent>
|
|
245
|
+
</Sheet>
|
|
246
|
+
</>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
@@ -1,11 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
it,
|
|
4
|
+
expect,
|
|
5
|
+
mock,
|
|
6
|
+
beforeAll,
|
|
7
|
+
beforeEach,
|
|
8
|
+
afterAll,
|
|
9
|
+
} from "bun:test";
|
|
2
10
|
import { loadPlugins } from "./plugin-loader";
|
|
3
11
|
|
|
4
12
|
// Note: We don't mock @checkstack/frontend-api module-wide here because
|
|
5
13
|
// it causes test isolation issues with other tests that use the real pluginRegistry.
|
|
6
14
|
// Instead, we just verify behavior based on the function's outputs.
|
|
7
15
|
|
|
8
|
-
// Mock fetch
|
|
16
|
+
// Mock fetch. The `typeof url === "string"` guard means a stray non-string
|
|
17
|
+
// argument can never throw (defensive; the override is also scoped to this
|
|
18
|
+
// suite's lifecycle below).
|
|
9
19
|
const mockFetch = mock((url: string) => {
|
|
10
20
|
if (url === "/api/plugins") {
|
|
11
21
|
return Promise.resolve({
|
|
@@ -14,21 +24,32 @@ const mockFetch = mock((url: string) => {
|
|
|
14
24
|
} as unknown as Response);
|
|
15
25
|
}
|
|
16
26
|
// Mock HEAD request for CSS
|
|
17
|
-
if (url.endsWith(".css")) {
|
|
27
|
+
if (typeof url === "string" && url.endsWith(".css")) {
|
|
18
28
|
return Promise.resolve({ ok: true } as unknown as Response);
|
|
19
29
|
}
|
|
20
30
|
return Promise.resolve({ ok: false } as unknown as Response);
|
|
21
31
|
});
|
|
22
32
|
|
|
23
|
-
|
|
33
|
+
// bun test runs every file in ONE shared process, so leaving these overrides on
|
|
34
|
+
// `global` leaks into other files - e.g. the real-HTTP backend auth tests would
|
|
35
|
+
// hit this mock instead of the real fetch and crash. Scope them to this suite.
|
|
36
|
+
const originalFetch = global.fetch;
|
|
37
|
+
const originalDocument = global.document;
|
|
24
38
|
|
|
25
|
-
|
|
26
|
-
global.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
39
|
+
beforeAll(() => {
|
|
40
|
+
(global as unknown as { fetch: typeof fetch }).fetch = mockFetch as never;
|
|
41
|
+
global.document = {
|
|
42
|
+
createElement: mock(() => ({})),
|
|
43
|
+
head: {
|
|
44
|
+
append: mock(),
|
|
45
|
+
},
|
|
46
|
+
} as unknown as Document;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterAll(() => {
|
|
50
|
+
(global as unknown as { fetch: typeof fetch }).fetch = originalFetch;
|
|
51
|
+
(global as unknown as { document: Document }).document = originalDocument;
|
|
52
|
+
});
|
|
32
53
|
|
|
33
54
|
describe("frontend loadPlugins", () => {
|
|
34
55
|
beforeEach(() => {
|
|
@@ -62,7 +62,12 @@ describe("PluginRegistry", () => {
|
|
|
62
62
|
it("should aggregate all routes from registered plugins", () => {
|
|
63
63
|
const pluginB: FrontendPlugin = {
|
|
64
64
|
metadata: { pluginId: "plugin-b" },
|
|
65
|
-
routes: [
|
|
65
|
+
routes: [
|
|
66
|
+
{
|
|
67
|
+
route: pluginBRoutes.routes.home,
|
|
68
|
+
element: React.createElement("div", null, "Plugin B Route"),
|
|
69
|
+
},
|
|
70
|
+
],
|
|
66
71
|
};
|
|
67
72
|
|
|
68
73
|
pluginRegistry.register(mockPlugin);
|
|
@@ -105,4 +110,56 @@ describe("PluginRegistry", () => {
|
|
|
105
110
|
expect(extensions.map((e) => e.id)).toContain("ext-a");
|
|
106
111
|
expect(extensions.map((e) => e.id)).toContain("ext-b");
|
|
107
112
|
});
|
|
113
|
+
|
|
114
|
+
it("getAllRoutes preserves both eager (element) and lazy (load) routes", () => {
|
|
115
|
+
const loader = () =>
|
|
116
|
+
Promise.resolve({
|
|
117
|
+
default: () => React.createElement("div", null, "Lazy"),
|
|
118
|
+
});
|
|
119
|
+
const plugin: FrontendPlugin = {
|
|
120
|
+
metadata: { pluginId: "test" },
|
|
121
|
+
routes: [
|
|
122
|
+
{ route: testRoutes.routes.home, element: React.createElement("div") },
|
|
123
|
+
{ route: testRoutes.routes.config, load: loader },
|
|
124
|
+
],
|
|
125
|
+
};
|
|
126
|
+
pluginRegistry.register(plugin);
|
|
127
|
+
|
|
128
|
+
const routes = pluginRegistry.getAllRoutes();
|
|
129
|
+
const home = routes.find((r) => r.path === "/test/");
|
|
130
|
+
const config = routes.find((r) => r.path === "/test/config");
|
|
131
|
+
expect(home?.element).toBeDefined();
|
|
132
|
+
expect(home?.load).toBeUndefined();
|
|
133
|
+
expect(config?.load).toBe(loader);
|
|
134
|
+
expect(config?.element).toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("getPlugins returns a NEW snapshot reference on every change", () => {
|
|
138
|
+
// Required for useSyncExternalStore / useMemo consumers to recompute when
|
|
139
|
+
// a plugin is added or removed at runtime.
|
|
140
|
+
const before = pluginRegistry.getPlugins();
|
|
141
|
+
pluginRegistry.register(mockPlugin);
|
|
142
|
+
const afterRegister = pluginRegistry.getPlugins();
|
|
143
|
+
expect(afterRegister).not.toBe(before);
|
|
144
|
+
expect(afterRegister).toContain(mockPlugin);
|
|
145
|
+
|
|
146
|
+
pluginRegistry.unregister("test");
|
|
147
|
+
const afterUnregister = pluginRegistry.getPlugins();
|
|
148
|
+
expect(afterUnregister).not.toBe(afterRegister);
|
|
149
|
+
expect(afterUnregister).not.toContain(mockPlugin);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("notifies subscribers on register and unregister", () => {
|
|
153
|
+
let calls = 0;
|
|
154
|
+
const unsubscribe = pluginRegistry.subscribe(() => {
|
|
155
|
+
calls += 1;
|
|
156
|
+
});
|
|
157
|
+
pluginRegistry.register(mockPlugin);
|
|
158
|
+
pluginRegistry.unregister("test");
|
|
159
|
+
expect(calls).toBe(2);
|
|
160
|
+
|
|
161
|
+
unsubscribe();
|
|
162
|
+
pluginRegistry.register(mockPlugin);
|
|
163
|
+
expect(calls).toBe(2); // no longer notified after unsubscribe
|
|
164
|
+
});
|
|
108
165
|
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import tailwindcssAnimate from "tailwindcss-animate";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared Checkstack Tailwind preset: the platform's design tokens (colors
|
|
5
|
+
* mapped to the `@checkstack/ui` CSS variables, border radii) and the
|
|
6
|
+
* `tailwindcss-animate` plugin. Deliberately carries NO `content` — a
|
|
7
|
+
* preset must not, so each consumer (the frontend app, the standalone dev
|
|
8
|
+
* server, a plugin author's own Tailwind setup) supplies its own content
|
|
9
|
+
* globs.
|
|
10
|
+
*
|
|
11
|
+
* Exported as a public subpath (`@checkstack/frontend/tailwind-preset`) so
|
|
12
|
+
* the standalone dev server can build the dev shell with the platform theme
|
|
13
|
+
* WITHOUT reaching into frontend internals, and so plugin authors can
|
|
14
|
+
* `presets: [require("@checkstack/frontend/tailwind-preset")]` in their own
|
|
15
|
+
* config. `tailwindcss-animate` resolves from `@checkstack/frontend`'s own
|
|
16
|
+
* (runtime) dependency scope, so this loads correctly from a published
|
|
17
|
+
* install.
|
|
18
|
+
*
|
|
19
|
+
* @type {import('tailwindcss').Config}
|
|
20
|
+
*/
|
|
21
|
+
export default {
|
|
22
|
+
darkMode: ["class"],
|
|
23
|
+
theme: {
|
|
24
|
+
extend: {
|
|
25
|
+
colors: {
|
|
26
|
+
border: "hsl(var(--border))",
|
|
27
|
+
input: "hsl(var(--input))",
|
|
28
|
+
ring: "hsl(var(--ring))",
|
|
29
|
+
background: "hsl(var(--background))",
|
|
30
|
+
foreground: "hsl(var(--foreground))",
|
|
31
|
+
primary: {
|
|
32
|
+
DEFAULT: "hsl(var(--primary))",
|
|
33
|
+
foreground: "hsl(var(--primary-foreground))",
|
|
34
|
+
},
|
|
35
|
+
secondary: {
|
|
36
|
+
DEFAULT: "hsl(var(--secondary))",
|
|
37
|
+
foreground: "hsl(var(--secondary-foreground))",
|
|
38
|
+
},
|
|
39
|
+
destructive: {
|
|
40
|
+
DEFAULT: "hsl(var(--destructive))",
|
|
41
|
+
foreground: "hsl(var(--destructive-foreground))",
|
|
42
|
+
},
|
|
43
|
+
muted: {
|
|
44
|
+
DEFAULT: "hsl(var(--muted))",
|
|
45
|
+
foreground: "hsl(var(--muted-foreground))",
|
|
46
|
+
},
|
|
47
|
+
accent: {
|
|
48
|
+
DEFAULT: "hsl(var(--accent))",
|
|
49
|
+
foreground: "hsl(var(--accent-foreground))",
|
|
50
|
+
},
|
|
51
|
+
popover: {
|
|
52
|
+
DEFAULT: "hsl(var(--popover))",
|
|
53
|
+
foreground: "hsl(var(--popover-foreground))",
|
|
54
|
+
},
|
|
55
|
+
card: {
|
|
56
|
+
DEFAULT: "hsl(var(--card))",
|
|
57
|
+
foreground: "hsl(var(--card-foreground))",
|
|
58
|
+
},
|
|
59
|
+
chart: {
|
|
60
|
+
1: "hsl(var(--chart-1))",
|
|
61
|
+
2: "hsl(var(--chart-2))",
|
|
62
|
+
3: "hsl(var(--chart-3))",
|
|
63
|
+
4: "hsl(var(--chart-4))",
|
|
64
|
+
5: "hsl(var(--chart-5))",
|
|
65
|
+
},
|
|
66
|
+
success: {
|
|
67
|
+
DEFAULT: "hsl(var(--success))",
|
|
68
|
+
foreground: "hsl(var(--success-foreground))",
|
|
69
|
+
},
|
|
70
|
+
warning: {
|
|
71
|
+
DEFAULT: "hsl(var(--warning))",
|
|
72
|
+
foreground: "hsl(var(--warning-foreground))",
|
|
73
|
+
},
|
|
74
|
+
info: {
|
|
75
|
+
DEFAULT: "hsl(var(--info))",
|
|
76
|
+
foreground: "hsl(var(--info-foreground))",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
borderRadius: {
|
|
80
|
+
lg: "var(--radius)",
|
|
81
|
+
md: "calc(var(--radius) - 2px)",
|
|
82
|
+
sm: "calc(var(--radius) - 4px)",
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
plugins: [tailwindcssAnimate],
|
|
87
|
+
};
|
package/tailwind.config.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import
|
|
1
|
+
import checkstackPreset from "./tailwind-preset.js";
|
|
2
2
|
|
|
3
3
|
/** @type {import('tailwindcss').Config} */
|
|
4
4
|
export default {
|
|
5
|
-
|
|
5
|
+
// Theme tokens + tailwindcss-animate live in the shared preset (also
|
|
6
|
+
// exported as `@checkstack/frontend/tailwind-preset`). Only `content`
|
|
7
|
+
// is app-specific.
|
|
8
|
+
presets: [checkstackPreset],
|
|
6
9
|
content: [
|
|
7
10
|
"./index.html",
|
|
8
11
|
"./src/**/*.{js,ts,jsx,tsx}",
|
|
@@ -13,68 +16,4 @@ export default {
|
|
|
13
16
|
// Shared UI library
|
|
14
17
|
"../ui/src/**/*.{js,ts,jsx,tsx}",
|
|
15
18
|
],
|
|
16
|
-
theme: {
|
|
17
|
-
extend: {
|
|
18
|
-
colors: {
|
|
19
|
-
border: "hsl(var(--border))",
|
|
20
|
-
input: "hsl(var(--input))",
|
|
21
|
-
ring: "hsl(var(--ring))",
|
|
22
|
-
background: "hsl(var(--background))",
|
|
23
|
-
foreground: "hsl(var(--foreground))",
|
|
24
|
-
primary: {
|
|
25
|
-
DEFAULT: "hsl(var(--primary))",
|
|
26
|
-
foreground: "hsl(var(--primary-foreground))",
|
|
27
|
-
},
|
|
28
|
-
secondary: {
|
|
29
|
-
DEFAULT: "hsl(var(--secondary))",
|
|
30
|
-
foreground: "hsl(var(--secondary-foreground))",
|
|
31
|
-
},
|
|
32
|
-
destructive: {
|
|
33
|
-
DEFAULT: "hsl(var(--destructive))",
|
|
34
|
-
foreground: "hsl(var(--destructive-foreground))",
|
|
35
|
-
},
|
|
36
|
-
muted: {
|
|
37
|
-
DEFAULT: "hsl(var(--muted))",
|
|
38
|
-
foreground: "hsl(var(--muted-foreground))",
|
|
39
|
-
},
|
|
40
|
-
accent: {
|
|
41
|
-
DEFAULT: "hsl(var(--accent))",
|
|
42
|
-
foreground: "hsl(var(--accent-foreground))",
|
|
43
|
-
},
|
|
44
|
-
popover: {
|
|
45
|
-
DEFAULT: "hsl(var(--popover))",
|
|
46
|
-
foreground: "hsl(var(--popover-foreground))",
|
|
47
|
-
},
|
|
48
|
-
card: {
|
|
49
|
-
DEFAULT: "hsl(var(--card))",
|
|
50
|
-
foreground: "hsl(var(--card-foreground))",
|
|
51
|
-
},
|
|
52
|
-
chart: {
|
|
53
|
-
1: "hsl(var(--chart-1))",
|
|
54
|
-
2: "hsl(var(--chart-2))",
|
|
55
|
-
3: "hsl(var(--chart-3))",
|
|
56
|
-
4: "hsl(var(--chart-4))",
|
|
57
|
-
5: "hsl(var(--chart-5))",
|
|
58
|
-
},
|
|
59
|
-
success: {
|
|
60
|
-
DEFAULT: "hsl(var(--success))",
|
|
61
|
-
foreground: "hsl(var(--success-foreground))",
|
|
62
|
-
},
|
|
63
|
-
warning: {
|
|
64
|
-
DEFAULT: "hsl(var(--warning))",
|
|
65
|
-
foreground: "hsl(var(--warning-foreground))",
|
|
66
|
-
},
|
|
67
|
-
info: {
|
|
68
|
-
DEFAULT: "hsl(var(--info))",
|
|
69
|
-
foreground: "hsl(var(--info-foreground))",
|
|
70
|
-
},
|
|
71
|
-
},
|
|
72
|
-
borderRadius: {
|
|
73
|
-
lg: "var(--radius)",
|
|
74
|
-
md: "calc(var(--radius) - 2px)",
|
|
75
|
-
sm: "calc(var(--radius) - 4px)",
|
|
76
|
-
},
|
|
77
|
-
},
|
|
78
|
-
},
|
|
79
|
-
plugins: [tailwindcssAnimate],
|
|
80
19
|
};
|
package/vite.config.ts
CHANGED
|
@@ -59,6 +59,11 @@ export default defineConfig(() => {
|
|
|
59
59
|
// here for dev convenience so e.g.
|
|
60
60
|
// `curl http://localhost:5173/.checkstack/ready` works.
|
|
61
61
|
"/.checkstack": backendUrl,
|
|
62
|
+
// In-app user guide: the backend serves the Astro docs build at
|
|
63
|
+
// /checkstack/* (distinct from /.checkstack above). Proxy it so the
|
|
64
|
+
// dev SPA shows the same docs. Requires the docs to be built once:
|
|
65
|
+
// `bun run --filter @checkstack/docs build`.
|
|
66
|
+
"/checkstack": backendUrl,
|
|
62
67
|
},
|
|
63
68
|
},
|
|
64
69
|
// ============================================================
|
|
@@ -103,6 +108,56 @@ export default defineConfig(() => {
|
|
|
103
108
|
// Don't wipe dist/ — the vendor build (build:vendor) writes to dist/vendor/
|
|
104
109
|
// before this build runs, and we need to preserve those files
|
|
105
110
|
emptyOutDir: false,
|
|
111
|
+
rollupOptions: {
|
|
112
|
+
output: {
|
|
113
|
+
// Split heavy / stable vendor code into dedicated chunks so the
|
|
114
|
+
// initial (login) load stays small and chunks cache independently.
|
|
115
|
+
// This complements the `React.lazy(CodeEditor)` split: it does not by
|
|
116
|
+
// itself keep Monaco off the login page (that's the lazy boundary in
|
|
117
|
+
// CodeEditor.tsx), but it guarantees the whole `@codingame/*` /
|
|
118
|
+
// monaco stack lands in ONE chunk that is only fetched when an editor
|
|
119
|
+
// mounts, rather than smeared across many shared chunks.
|
|
120
|
+
manualChunks(id) {
|
|
121
|
+
if (!id.includes("node_modules")) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// NB: do NOT try to hand-group lucide icon modules here. The same
|
|
125
|
+
// per-icon modules are reached BOTH statically (`import { Plus }
|
|
126
|
+
// from "lucide-react"` across the app, which must stay eager) and
|
|
127
|
+
// dynamically (DynamicIcon's lazy icon registry). A manualChunk
|
|
128
|
+
// keys off module id only, so it can't tell the two apart and ends
|
|
129
|
+
// up pulling the whole icon set into the eager graph. The lazy
|
|
130
|
+
// boundary for the data-driven icon set lives in DynamicIcon /
|
|
131
|
+
// iconRegistry instead (see core/ui).
|
|
132
|
+
// NOTE: we deliberately do NOT hand-group the Monaco / VS Code
|
|
133
|
+
// editor stack here. Rollup's natural code-splitting already
|
|
134
|
+
// isolates it: every `@codingame/*` / `@typefox/*` /
|
|
135
|
+
// monaco-languageclient module is reachable only through the lazy
|
|
136
|
+
// `CodeEditor` / `validateScripts` boundaries (see CodeEditor.tsx),
|
|
137
|
+
// so it lands in dynamically-imported chunks that the initial
|
|
138
|
+
// (login) load never fetches. A manual `monaco` chunk is actively
|
|
139
|
+
// harmful: a tiny `@codingame/*` module is pulled in EAGERLY as a
|
|
140
|
+
// transitive dep of non-editor code, and folding it into one big
|
|
141
|
+
// chunk with the lazy editor body makes the entire ~10 MB chunk a
|
|
142
|
+
// static dependency of the entry, re-shipping Monaco to the login
|
|
143
|
+
// page. Leaving it to natural splitting keeps the heavy editor body
|
|
144
|
+
// lazy while that tiny eager stub stays inlined where it belongs.
|
|
145
|
+
|
|
146
|
+
// The React runtime: one stable chunk shared across the whole app.
|
|
147
|
+
// `dedupe` above already guarantees a single copy; this only
|
|
148
|
+
// controls which output file it lands in.
|
|
149
|
+
if (
|
|
150
|
+
id.includes("/react/") ||
|
|
151
|
+
id.includes("/react-dom/") ||
|
|
152
|
+
id.includes("/react-router") ||
|
|
153
|
+
id.includes("/scheduler/")
|
|
154
|
+
) {
|
|
155
|
+
return "react-vendor";
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
106
161
|
},
|
|
107
162
|
resolve: {
|
|
108
163
|
// Force all monorepo packages to use the same React copy at build time.
|