@git-snitch/renderer 0.0.3

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 (74) hide show
  1. package/dist/build.d.ts +7 -0
  2. package/dist/build.d.ts.map +1 -0
  3. package/dist/build.js +53 -0
  4. package/dist/charts.d.ts +106 -0
  5. package/dist/charts.d.ts.map +1 -0
  6. package/dist/charts.js +212 -0
  7. package/dist/custom-templates.d.ts +3 -0
  8. package/dist/custom-templates.d.ts.map +1 -0
  9. package/dist/custom-templates.js +1 -0
  10. package/dist/data.d.ts +24 -0
  11. package/dist/data.d.ts.map +1 -0
  12. package/dist/data.js +30 -0
  13. package/dist/empty-state.d.ts +13 -0
  14. package/dist/empty-state.d.ts.map +1 -0
  15. package/dist/empty-state.js +9 -0
  16. package/dist/export.d.ts +15 -0
  17. package/dist/export.d.ts.map +1 -0
  18. package/dist/export.js +53 -0
  19. package/dist/index.d.ts +15 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +9 -0
  22. package/dist/inline-plugin.d.ts +13 -0
  23. package/dist/inline-plugin.d.ts.map +1 -0
  24. package/dist/inline-plugin.js +81 -0
  25. package/dist/layout.d.ts +43 -0
  26. package/dist/layout.d.ts.map +1 -0
  27. package/dist/layout.js +25 -0
  28. package/dist/remote-urls.d.ts +6 -0
  29. package/dist/remote-urls.d.ts.map +1 -0
  30. package/dist/remote-urls.js +82 -0
  31. package/dist/serialization.d.ts +5 -0
  32. package/dist/serialization.d.ts.map +1 -0
  33. package/dist/serialization.js +46 -0
  34. package/dist/tables.d.ts +50 -0
  35. package/dist/tables.d.ts.map +1 -0
  36. package/dist/tables.js +228 -0
  37. package/dist/template/report-template.html +135 -0
  38. package/dist/template.d.ts +21 -0
  39. package/dist/template.d.ts.map +1 -0
  40. package/dist/template.js +1 -0
  41. package/dist/theme-toggle.d.ts +2 -0
  42. package/dist/theme-toggle.d.ts.map +1 -0
  43. package/dist/theme-toggle.js +9 -0
  44. package/dist/theme.d.ts +16 -0
  45. package/dist/theme.d.ts.map +1 -0
  46. package/dist/theme.js +70 -0
  47. package/package.json +57 -0
  48. package/report-template.html +15 -0
  49. package/src/app.tsx +351 -0
  50. package/src/build.ts +68 -0
  51. package/src/charts-route.tsx +158 -0
  52. package/src/charts.tsx +482 -0
  53. package/src/custom-template-module.d.ts +5 -0
  54. package/src/custom-templates.ts +3 -0
  55. package/src/data.ts +52 -0
  56. package/src/empty-state.tsx +31 -0
  57. package/src/export.ts +77 -0
  58. package/src/index.ts +52 -0
  59. package/src/inline-plugin.ts +123 -0
  60. package/src/layout.tsx +152 -0
  61. package/src/main.tsx +17 -0
  62. package/src/overview.tsx +253 -0
  63. package/src/quality-hotspots-routes.tsx +340 -0
  64. package/src/remote-urls.ts +97 -0
  65. package/src/repo-routes.tsx +285 -0
  66. package/src/scan-routes.tsx +393 -0
  67. package/src/serialization.ts +58 -0
  68. package/src/styles.css +2 -0
  69. package/src/tables.tsx +467 -0
  70. package/src/template.ts +30 -0
  71. package/src/theme-toggle.tsx +24 -0
  72. package/src/theme.tsx +108 -0
  73. package/src/vite-env.d.ts +1 -0
  74. package/vite.config.ts +41 -0
@@ -0,0 +1,21 @@
1
+ import type { RepoTemplateContext, ScanProjectTemplateContext, ScanTemplateContext, TemplateRouteId } from "@git-snitch/core";
2
+ import type { ReactNode } from "react";
3
+ export type TemplateComponent<Props> = (props: Props) => ReactNode;
4
+ export interface RouteTemplatePropsById {
5
+ readonly overview: RepoTemplateContext;
6
+ readonly commits: RepoTemplateContext;
7
+ readonly contributors: RepoTemplateContext;
8
+ readonly charts: RepoTemplateContext;
9
+ readonly quality: RepoTemplateContext;
10
+ readonly hotspots: RepoTemplateContext;
11
+ readonly scanOverview: ScanTemplateContext;
12
+ readonly scanProject: ScanProjectTemplateContext;
13
+ }
14
+ export type RouteTemplateOverrides = {
15
+ readonly [RouteId in TemplateRouteId]?: TemplateComponent<RouteTemplatePropsById[RouteId]>;
16
+ };
17
+ export interface TemplateModule {
18
+ readonly templates: RouteTemplateOverrides;
19
+ }
20
+ export type { RepoTemplateContext, ScanProjectTemplateContext, ScanTemplateContext, TemplateRouteId } from "@git-snitch/core";
21
+ //# sourceMappingURL=template.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"template.d.ts","sourceRoot":"","sources":["../src/template.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,mBAAmB,EACnB,0BAA0B,EAC1B,mBAAmB,EACnB,eAAe,EAChB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC,MAAM,MAAM,iBAAiB,CAAC,KAAK,IAAI,CAAC,KAAK,EAAE,KAAK,KAAK,SAAS,CAAC;AAEnE,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,QAAQ,EAAE,mBAAmB,CAAC;IACvC,QAAQ,CAAC,OAAO,EAAE,mBAAmB,CAAC;IACtC,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,QAAQ,CAAC,MAAM,EAAE,mBAAmB,CAAC;IACrC,QAAQ,CAAC,OAAO,EAAE,mBAAmB,CAAC;IACtC,QAAQ,CAAC,QAAQ,EAAE,mBAAmB,CAAC;IACvC,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,QAAQ,CAAC,WAAW,EAAE,0BAA0B,CAAC;CAClD;AAED,MAAM,MAAM,sBAAsB,GAAG;IACnC,QAAQ,EAAE,OAAO,IAAI,eAAe,CAAC,CAAC,EAAE,iBAAiB,CAAC,sBAAsB,CAAC,OAAO,CAAC,CAAC;CAC3F,CAAC;AAEF,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,SAAS,EAAE,sBAAsB,CAAC;CAC5C;AAED,YAAY,EAAE,mBAAmB,EAAE,0BAA0B,EAAE,mBAAmB,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ export declare function ThemeToggle(): import("react/jsx-runtime").JSX.Element;
2
+ //# sourceMappingURL=theme-toggle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme-toggle.d.ts","sourceRoot":"","sources":["../src/theme-toggle.tsx"],"names":[],"mappings":"AAIA,wBAAgB,WAAW,4CAmB1B"}
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button } from "@git-snitch/ui/components/button";
3
+ import { useTheme } from "./theme.js";
4
+ export function ThemeToggle() {
5
+ const { theme, toggleTheme } = useTheme();
6
+ const isDark = theme === "dark";
7
+ const label = isDark ? "Switch to light theme" : "Switch to dark theme";
8
+ return (_jsxs(Button, { type: "button", variant: "outline", size: "sm", "aria-label": label, "aria-pressed": isDark, onClick: toggleTheme, className: "border-foreground/15 bg-background/80 text-foreground shadow-sm backdrop-blur transition-colors hover:bg-muted", children: [_jsx("span", { "aria-hidden": "true", className: "size-2 rounded-full bg-current" }), _jsx("span", { children: isDark ? "Light" : "Dark" })] }));
9
+ }
@@ -0,0 +1,16 @@
1
+ import type { ReactNode } from "react";
2
+ export type Theme = "light" | "dark";
3
+ type ThemeContextValue = {
4
+ theme: Theme;
5
+ setTheme: (theme: Theme) => void;
6
+ toggleTheme: () => void;
7
+ };
8
+ type ThemeProviderProps = {
9
+ children: ReactNode;
10
+ defaultTheme?: Theme;
11
+ storageKey?: string;
12
+ };
13
+ export declare function ThemeProvider({ children, defaultTheme, storageKey, }: ThemeProviderProps): import("react/jsx-runtime").JSX.Element;
14
+ export declare function useTheme(): ThemeContextValue;
15
+ export {};
16
+ //# sourceMappingURL=theme.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../src/theme.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC,MAAM,MAAM,KAAK,GAAG,OAAO,GAAG,MAAM,CAAC;AAErC,KAAK,iBAAiB,GAAG;IACvB,KAAK,EAAE,KAAK,CAAC;IACb,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IACjC,WAAW,EAAE,MAAM,IAAI,CAAC;CACzB,CAAC;AAEF,KAAK,kBAAkB,GAAG;IACxB,QAAQ,EAAE,SAAS,CAAC;IACpB,YAAY,CAAC,EAAE,KAAK,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAqDF,wBAAgB,aAAa,CAAC,EAC5B,QAAQ,EACR,YAA4B,EAC5B,UAAgC,GACjC,EAAE,kBAAkB,2CAyBpB;AAED,wBAAgB,QAAQ,sBAQvB"}
package/dist/theme.js ADDED
@@ -0,0 +1,70 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useEffect, useMemo, useState } from "react";
3
+ const DEFAULT_THEME = "light";
4
+ const DEFAULT_STORAGE_KEY = "git-snitch-theme";
5
+ const ThemeContext = createContext(null);
6
+ function isTheme(value) {
7
+ return value === "light" || value === "dark";
8
+ }
9
+ function getStoredTheme(storageKey) {
10
+ if (typeof window === "undefined") {
11
+ return null;
12
+ }
13
+ try {
14
+ const storedTheme = window.localStorage.getItem(storageKey);
15
+ return isTheme(storedTheme) ? storedTheme : null;
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ function getSystemTheme() {
22
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
23
+ return null;
24
+ }
25
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
26
+ }
27
+ function persistTheme(storageKey, theme) {
28
+ if (typeof window === "undefined") {
29
+ return;
30
+ }
31
+ try {
32
+ window.localStorage.setItem(storageKey, theme);
33
+ }
34
+ catch {
35
+ return;
36
+ }
37
+ }
38
+ function applyThemeClass(theme) {
39
+ if (typeof document === "undefined") {
40
+ return;
41
+ }
42
+ document.documentElement.classList.toggle("dark", theme === "dark");
43
+ document.documentElement.style.colorScheme = theme;
44
+ }
45
+ export function ThemeProvider({ children, defaultTheme = DEFAULT_THEME, storageKey = DEFAULT_STORAGE_KEY, }) {
46
+ const [theme, setThemeState] = useState(defaultTheme);
47
+ useEffect(() => {
48
+ const initialTheme = getStoredTheme(storageKey) ?? getSystemTheme() ?? defaultTheme;
49
+ setThemeState(initialTheme);
50
+ }, [defaultTheme, storageKey]);
51
+ useEffect(() => {
52
+ applyThemeClass(theme);
53
+ persistTheme(storageKey, theme);
54
+ }, [storageKey, theme]);
55
+ const value = useMemo(() => ({
56
+ theme,
57
+ setTheme: setThemeState,
58
+ toggleTheme: () => {
59
+ setThemeState((currentTheme) => (currentTheme === "dark" ? "light" : "dark"));
60
+ },
61
+ }), [theme]);
62
+ return _jsx(ThemeContext.Provider, { value: value, children: children });
63
+ }
64
+ export function useTheme() {
65
+ const context = useContext(ThemeContext);
66
+ if (!context) {
67
+ throw new Error("useTheme must be used within ThemeProvider.");
68
+ }
69
+ return context;
70
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@git-snitch/renderer",
3
+ "version": "0.0.3",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "type": "module",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./template": {
14
+ "types": "./src/template.ts",
15
+ "default": "./dist/template.js"
16
+ },
17
+ "./build": {
18
+ "types": "./src/build.ts",
19
+ "default": "./dist/build.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "report-template.html",
25
+ "src",
26
+ "vite.config.ts",
27
+ "package.json"
28
+ ],
29
+ "scripts": {
30
+ "build": "tsc -p tsconfig.build.json && vite build",
31
+ "typecheck": "tsc --noEmit",
32
+ "check-types": "tsc --noEmit",
33
+ "test": "vitest run"
34
+ },
35
+ "dependencies": {
36
+ "@git-snitch/core": "workspace:*",
37
+ "@git-snitch/ui": "workspace:*",
38
+ "@tailwindcss/vite": "^4.2.2",
39
+ "@tanstack/react-router": "^1.168.22",
40
+ "@tanstack/react-table": "^8.21.3",
41
+ "@vitejs/plugin-react": "^6.0.1",
42
+ "react": "^19.2.5",
43
+ "react-dom": "^19.2.5",
44
+ "recharts": "3.8.0",
45
+ "vite": "^8.0.8"
46
+ },
47
+ "devDependencies": {
48
+ "@git-snitch/config": "workspace:*",
49
+ "@testing-library/dom": "^10.4.1",
50
+ "@testing-library/react": "^16.3.2",
51
+ "@types/react": "^19.2.14",
52
+ "@types/react-dom": "catalog:",
53
+ "jsdom": "^29.0.2",
54
+ "typescript": "catalog:",
55
+ "vitest": "catalog:"
56
+ }
57
+ }
@@ -0,0 +1,15 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>git-snitch report</title>
7
+ <script>
8
+ window.__GIT_SNITCH_REPORT_DATA__ = "__GIT_SNITCH_REPORT_DATA__";
9
+ </script>
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ <script type="module" src="/src/main.tsx"></script>
14
+ </body>
15
+ </html>
package/src/app.tsx ADDED
@@ -0,0 +1,351 @@
1
+ import {
2
+ createHashHistory,
3
+ createMemoryHistory,
4
+ createRootRoute,
5
+ createRoute,
6
+ createRouter,
7
+ Navigate,
8
+ Outlet,
9
+ RouterProvider,
10
+ useRouterState,
11
+ } from "@tanstack/react-router";
12
+
13
+ import type { RepoReportData, ScanReportData, TemplateExportHelpers } from "@git-snitch/core";
14
+
15
+ import { templates as customTemplates } from "virtual:git-snitch-custom-templates";
16
+ import { useReportData } from "./data.js";
17
+ import { EmptyState } from "./empty-state.js";
18
+ import { downloadCsv, downloadJson } from "./export.js";
19
+ import { AppShell, StatsGrid } from "./layout.js";
20
+ import { ChartsRoute } from "./charts-route.js";
21
+ import { RepoOverview } from "./overview.js";
22
+ import { HotspotsRoute, QualityRoute } from "./quality-hotspots-routes.js";
23
+ import { CommitsRoute, ContributorsRoute } from "./repo-routes.js";
24
+ import { ScanOverview, ScanProjectRoute, deriveScanProjectRouteEntries } from "./scan-routes.js";
25
+ import { ThemeProvider } from "./theme.js";
26
+
27
+ const rootRoute = createRootRoute({
28
+ component: ReportShell,
29
+ });
30
+
31
+ const indexRoute = createRoute({
32
+ getParentRoute: () => rootRoute,
33
+ path: "/",
34
+ component: IndexRoute,
35
+ });
36
+
37
+ const overviewRoute = createRoute({
38
+ getParentRoute: () => rootRoute,
39
+ path: "/overview",
40
+ component: OverviewRoute,
41
+ });
42
+
43
+ const commitsRoute = createRoute({
44
+ getParentRoute: () => rootRoute,
45
+ path: "/commits",
46
+ component: CommitsRouteContainer,
47
+ });
48
+
49
+ const contributorsRoute = createRoute({
50
+ getParentRoute: () => rootRoute,
51
+ path: "/contributors",
52
+ component: ContributorsRouteContainer,
53
+ });
54
+
55
+ const chartsRoute = createRoute({
56
+ getParentRoute: () => rootRoute,
57
+ path: "/charts",
58
+ component: ChartsRouteContainer,
59
+ });
60
+
61
+ const qualityRoute = createRoute({
62
+ getParentRoute: () => rootRoute,
63
+ path: "/quality",
64
+ component: QualityRouteContainer,
65
+ });
66
+
67
+ const hotspotsRoute = createRoute({
68
+ getParentRoute: () => rootRoute,
69
+ path: "/hotspots",
70
+ component: HotspotsRouteContainer,
71
+ });
72
+
73
+ const scanOverviewRoute = createRoute({
74
+ getParentRoute: () => rootRoute,
75
+ path: "/scan",
76
+ component: ScanOverviewRouteContainer,
77
+ });
78
+
79
+ const scanProjectRoute = createRoute({
80
+ getParentRoute: () => rootRoute,
81
+ path: "/scan/projects/$projectSlug",
82
+ component: ScanProjectRouteContainer,
83
+ });
84
+
85
+ const routeTree = rootRoute.addChildren([
86
+ indexRoute,
87
+ overviewRoute,
88
+ commitsRoute,
89
+ contributorsRoute,
90
+ chartsRoute,
91
+ qualityRoute,
92
+ hotspotsRoute,
93
+ scanOverviewRoute,
94
+ scanProjectRoute,
95
+ ]);
96
+
97
+ function createRouterHistory() {
98
+ if (typeof window === "undefined") {
99
+ return createMemoryHistory({ initialEntries: ["/"] });
100
+ }
101
+
102
+ return createHashHistory();
103
+ }
104
+
105
+ export const router = createRouter({
106
+ history: createRouterHistory(),
107
+ routeTree,
108
+ });
109
+
110
+ declare module "@tanstack/react-router" {
111
+ interface Register {
112
+ router: typeof router;
113
+ }
114
+ }
115
+
116
+ function normalizeGitRemote(remoteUrl: string): string | undefined {
117
+ // SSH format: git@github.com:org/repo.git
118
+ const scpMatch = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/.exec(remoteUrl);
119
+ if (scpMatch?.[1] !== undefined && scpMatch[2] !== undefined) {
120
+ return `https://github.com/${scpMatch[1]}/${scpMatch[2]}`;
121
+ }
122
+
123
+ // SSH format: ssh://git@github.com/org/repo.git
124
+ const sshMatch = /^ssh:\/\/git@github\.com\/([^/]+)\/(.+?)(?:\.git)?$/.exec(remoteUrl);
125
+ if (sshMatch?.[1] !== undefined && sshMatch[2] !== undefined) {
126
+ return `https://github.com/${sshMatch[1]}/${sshMatch[2]}`;
127
+ }
128
+
129
+ // HTTPS: only return if it starts with http:// or https://
130
+ if (remoteUrl.startsWith("http://") || remoteUrl.startsWith("https://")) {
131
+ return remoteUrl.replace(/\.git$/, "");
132
+ }
133
+
134
+ return undefined;
135
+ }
136
+
137
+ function ReportShell() {
138
+ return (
139
+ <ThemeProvider>
140
+ <ReportRootLayout />
141
+ </ThemeProvider>
142
+ );
143
+ }
144
+
145
+ function IndexRoute() {
146
+ const state = useReportData();
147
+ return <Navigate to={state.status === "ready" && state.report.kind === "scan" ? "/scan" : "/overview"} replace />;
148
+ }
149
+
150
+ const repoNavigationItems = [
151
+ { label: "Overview", href: "#/overview", path: "/overview", disabled: false },
152
+ { label: "Commits", href: "#/commits", path: "/commits", disabled: false },
153
+ { label: "Contributors", href: "#/contributors", path: "/contributors", disabled: false },
154
+ { label: "Charts", href: "#/charts", path: "/charts", disabled: false },
155
+ { label: "Quality", href: "#/quality", path: "/quality", disabled: false },
156
+ { label: "Hotspots", href: "#/hotspots", path: "/hotspots", disabled: false },
157
+ ] as const;
158
+
159
+ function ReportRootLayout() {
160
+ const state = useReportData();
161
+ const pathname = useRouterState({ select: (routerState) => routerState.location.pathname });
162
+
163
+ if (state.status === "missing") {
164
+ return (
165
+ <AppShell title="git-snitch report template" description="A standalone renderer shell for CLI-generated git activity reports.">
166
+ <EmptyState
167
+ title="Report data has not been injected"
168
+ description="Build the report through the CLI pipeline so the standalone HTML receives a JSON-safe report payload."
169
+ />
170
+ </AppShell>
171
+ );
172
+ }
173
+
174
+ if (state.status === "invalid") {
175
+ return (
176
+ <AppShell title="Report data could not be loaded" description="The injected payload failed the renderer contract checks.">
177
+ <EmptyState title="Invalid report data" description={state.reason} />
178
+ </AppShell>
179
+ );
180
+ }
181
+
182
+ const report = state.report;
183
+ const isAnonymized = report.anonymization?.applied === true;
184
+ const title = report.kind === "repo" ? report.repository.name : "Scan report";
185
+ const titleHref = report.kind === "repo" && !isAnonymized && report.repository.remoteUrl
186
+ ? normalizeGitRemote(report.repository.remoteUrl)
187
+ : undefined;
188
+ const scanNavigationItems = report.kind === "scan"
189
+ ? [
190
+ { label: "Scan Overview", href: "#/scan", current: pathname === "/scan", disabled: false },
191
+ ...deriveScanProjectRouteEntries(report).map((entry) => ({
192
+ label: entry.label,
193
+ href: entry.href,
194
+ current: pathname === `/scan/projects/${entry.slug}`,
195
+ disabled: false,
196
+ })),
197
+ ]
198
+ : [{ label: "Scan Overview", href: "#/scan", current: pathname === "/scan", disabled: false }];
199
+ const navigationItems = report.kind === "scan"
200
+ ? scanNavigationItems
201
+ : [
202
+ ...repoNavigationItems.map((item) => ({
203
+ label: item.label,
204
+ href: item.href,
205
+ current: item.path === pathname,
206
+ disabled: item.disabled,
207
+ })),
208
+ ...scanNavigationItems,
209
+ ];
210
+
211
+ const headerActions = isAnonymized
212
+ ? <span className="inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium text-muted-foreground">Anonymized</span>
213
+ : undefined;
214
+
215
+ return (
216
+ <AppShell
217
+ title={title}
218
+ titleHref={titleHref}
219
+ eyebrow="Standalone git activity report"
220
+ description={`Renderer pipeline loaded a ${report.kind} report generated at ${report.generatedAt}.`}
221
+ navigationItems={navigationItems}
222
+ headerActions={headerActions}
223
+ >
224
+ <Outlet />
225
+ </AppShell>
226
+ );
227
+ }
228
+
229
+ function OverviewRoute() {
230
+ const state = useReportData();
231
+
232
+ if (state.status !== "ready") {
233
+ return <StatsGrid stats={[]} />;
234
+ }
235
+
236
+ if (state.report.kind === "repo" && customTemplates.overview) {
237
+ return customTemplates.overview({ report: state.report, helpers: createTemplateHelpers(state.report) });
238
+ }
239
+
240
+ return <RepoOverview report={state.report} />;
241
+ }
242
+
243
+ function CommitsRouteContainer() {
244
+ const state = useReportData();
245
+
246
+ if (state.status !== "ready") {
247
+ return <StatsGrid stats={[]} />;
248
+ }
249
+
250
+ if (state.report.kind === "repo" && customTemplates.commits) {
251
+ return customTemplates.commits({ report: state.report, helpers: createTemplateHelpers(state.report) });
252
+ }
253
+
254
+ return <CommitsRoute report={state.report} />;
255
+ }
256
+
257
+ function ContributorsRouteContainer() {
258
+ const state = useReportData();
259
+
260
+ if (state.status !== "ready") {
261
+ return <StatsGrid stats={[]} />;
262
+ }
263
+
264
+ if (state.report.kind === "repo" && customTemplates.contributors) {
265
+ return customTemplates.contributors({ report: state.report, helpers: createTemplateHelpers(state.report) });
266
+ }
267
+
268
+ return <ContributorsRoute report={state.report} />;
269
+ }
270
+
271
+ function ChartsRouteContainer() {
272
+ const state = useReportData();
273
+
274
+ if (state.status !== "ready") {
275
+ return <StatsGrid stats={[]} />;
276
+ }
277
+
278
+ if (state.report.kind === "repo" && customTemplates.charts) {
279
+ return customTemplates.charts({ report: state.report, helpers: createTemplateHelpers(state.report) });
280
+ }
281
+
282
+ return <ChartsRoute report={state.report} />;
283
+ }
284
+
285
+ function QualityRouteContainer() {
286
+ const state = useReportData();
287
+
288
+ if (state.status !== "ready") {
289
+ return <StatsGrid stats={[]} />;
290
+ }
291
+
292
+ if (state.report.kind === "repo" && customTemplates.quality) {
293
+ return customTemplates.quality({ report: state.report, helpers: createTemplateHelpers(state.report) });
294
+ }
295
+
296
+ return <QualityRoute report={state.report} />;
297
+ }
298
+
299
+ function HotspotsRouteContainer() {
300
+ const state = useReportData();
301
+
302
+ if (state.status !== "ready") {
303
+ return <StatsGrid stats={[]} />;
304
+ }
305
+
306
+ if (state.report.kind === "repo" && customTemplates.hotspots) {
307
+ return customTemplates.hotspots({ report: state.report, helpers: createTemplateHelpers(state.report) });
308
+ }
309
+
310
+ return <HotspotsRoute report={state.report} />;
311
+ }
312
+
313
+ function ScanOverviewRouteContainer() {
314
+ const state = useReportData();
315
+
316
+ if (state.status !== "ready") {
317
+ return <StatsGrid stats={[]} />;
318
+ }
319
+
320
+ if (state.report.kind === "scan" && customTemplates.scanOverview) {
321
+ return customTemplates.scanOverview({ report: state.report, helpers: createTemplateHelpers(state.report) });
322
+ }
323
+
324
+ return <ScanOverview report={state.report} />;
325
+ }
326
+
327
+ function ScanProjectRouteContainer() {
328
+ const state = useReportData();
329
+ const params = scanProjectRoute.useParams();
330
+
331
+ if (state.status !== "ready") {
332
+ return <StatsGrid stats={[]} />;
333
+ }
334
+
335
+ if (state.report.kind === "scan" && customTemplates.scanProject) {
336
+ return customTemplates.scanProject({ report: state.report, helpers: createTemplateHelpers(state.report), projectId: params.projectSlug });
337
+ }
338
+
339
+ return <ScanProjectRoute report={state.report} projectSlug={params.projectSlug} />;
340
+ }
341
+
342
+ function createTemplateHelpers(report: RepoReportData | ScanReportData): TemplateExportHelpers {
343
+ return {
344
+ downloadJson: (fileName) => downloadJson(fileName, report),
345
+ downloadCsv: (fileName, rows) => downloadCsv(fileName, rows),
346
+ };
347
+ }
348
+
349
+ export function App() {
350
+ return <RouterProvider router={router} />;
351
+ }
package/src/build.ts ADDED
@@ -0,0 +1,68 @@
1
+ import { mkdir, readFile, rm } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import type { ReportData } from "@git-snitch/core";
6
+
7
+ import { build as viteBuild } from "vite";
8
+
9
+ import { injectReportDataIntoHtml } from "./serialization.js";
10
+
11
+ export interface BuildReportHtmlOptions {
12
+ readonly report: ReportData;
13
+ readonly templatePath?: string;
14
+ }
15
+
16
+ const moduleDirectory = fileURLToPath(new URL(".", import.meta.url));
17
+
18
+ export async function buildStandaloneReportHtml(options: BuildReportHtmlOptions): Promise<string> {
19
+ const templatePath = options.templatePath ? resolve(options.templatePath) : undefined;
20
+ const templateHtmlPath = await buildRendererTemplate(templatePath);
21
+ const templateHtml = await readFile(templateHtmlPath, "utf8");
22
+
23
+ return injectReportDataIntoHtml(templateHtml, options.report);
24
+ }
25
+
26
+ async function buildRendererTemplate(templatePath: string | undefined): Promise<string> {
27
+ const packageDirectory = resolve(moduleDirectory, "..");
28
+ const outDir = resolve(packageDirectory, "dist", "template");
29
+ const configFile = resolve(packageDirectory, "vite.config.ts");
30
+
31
+ await mkdir(dirname(outDir), { recursive: true });
32
+ await rm(outDir, { recursive: true, force: true });
33
+
34
+ const previousTemplateModule = process.env.GIT_SNITCH_TEMPLATE_MODULE;
35
+
36
+ try {
37
+ if (templatePath) {
38
+ process.env.GIT_SNITCH_TEMPLATE_MODULE = templatePath;
39
+ await ensureTemplateReadable(templatePath);
40
+ } else {
41
+ delete process.env.GIT_SNITCH_TEMPLATE_MODULE;
42
+ }
43
+
44
+ await viteBuild({ configFile, root: packageDirectory, logLevel: "silent" });
45
+ } catch (error) {
46
+ throw new Error(`Unable to compile report renderer${templatePath ? ` with template ${templatePath}` : ""}: ${errorMessage(error)}`);
47
+ } finally {
48
+ if (previousTemplateModule === undefined) {
49
+ delete process.env.GIT_SNITCH_TEMPLATE_MODULE;
50
+ } else {
51
+ process.env.GIT_SNITCH_TEMPLATE_MODULE = previousTemplateModule;
52
+ }
53
+ }
54
+
55
+ return resolve(outDir, "report-template.html");
56
+ }
57
+
58
+ async function ensureTemplateReadable(templatePath: string): Promise<void> {
59
+ try {
60
+ await readFile(templatePath, "utf8");
61
+ } catch (error) {
62
+ throw new Error(`Unable to read custom template ${templatePath}: ${errorMessage(error)}`);
63
+ }
64
+ }
65
+
66
+ function errorMessage(error: unknown): string {
67
+ return error instanceof Error ? error.message : "unknown error";
68
+ }