@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,13 @@
1
+ import type { Plugin } from "vite";
2
+ export interface InlineHtmlAsset {
3
+ readonly fileName: string;
4
+ readonly source: string;
5
+ readonly kind: "script" | "style";
6
+ }
7
+ export interface InlineHtmlResult {
8
+ readonly html: string;
9
+ readonly inlinedFileNames: readonly string[];
10
+ }
11
+ export declare function inlineHtmlAssets(html: string, assets: readonly InlineHtmlAsset[]): InlineHtmlResult;
12
+ export declare function createInlineHtmlPlugin(): Plugin;
13
+ //# sourceMappingURL=inline-plugin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inline-plugin.d.ts","sourceRoot":"","sources":["../src/inline-plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAEnC,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC;CACnC;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,gBAAgB,EAAE,SAAS,MAAM,EAAE,CAAC;CAC9C;AAYD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,eAAe,EAAE,GAAG,gBAAgB,CA+CnG;AAMD,wBAAgB,sBAAsB,IAAI,MAAM,CA8C/C"}
@@ -0,0 +1,81 @@
1
+ function normalizeAssetReference(reference) {
2
+ return reference.replace(/^\.\//, "").replace(/^\//, "");
3
+ }
4
+ function findInlineAsset(assets, reference, kind) {
5
+ const normalizedReference = normalizeAssetReference(reference);
6
+ return assets.find((asset) => asset.kind === kind && asset.fileName === normalizedReference);
7
+ }
8
+ export function inlineHtmlAssets(html, assets) {
9
+ const inlined = new Set();
10
+ let output = html.replace(/<link\s+[^>]*rel=["']modulepreload["'][^>]*>/g, "");
11
+ output = output.replace(/<script\s+([^>]*?)src=["']([^"']+)["']([^>]*)><\/script>/g, (tag, beforeAttributes, source, afterAttributes) => {
12
+ const asset = findInlineAsset(assets, source, "script");
13
+ if (!asset) {
14
+ return tag;
15
+ }
16
+ inlined.add(asset.fileName);
17
+ const attributes = `${beforeAttributes} ${afterAttributes}`.replace(/\s*crossorigin(?:=["'][^"']*["'])?/, "").trim();
18
+ const attributeText = attributes.length > 0 ? ` ${attributes}` : "";
19
+ return `<script${attributeText}>\n${asset.source}\n</script>`;
20
+ });
21
+ output = output.replace(/<link\s+([^>]*?)rel=["']stylesheet["']([^>]*?)href=["']([^"']+)["']([^>]*)>/g, (tag, beforeAttributes, middleAttributes, href) => {
22
+ const asset = findInlineAsset(assets, href, "style");
23
+ if (!asset) {
24
+ return tag;
25
+ }
26
+ inlined.add(asset.fileName);
27
+ const mediaMatch = `${beforeAttributes} ${middleAttributes}`.match(/\smedia=["']([^"']+)["']/);
28
+ const mediaAttribute = mediaMatch ? ` media="${mediaMatch[1]}"` : "";
29
+ return `<style${mediaAttribute}>\n${asset.source}\n</style>`;
30
+ });
31
+ output = output.replace(/<link\s+([^>]*?)href=["']([^"']+)["']([^>]*?)rel=["']stylesheet["']([^>]*)>/g, (tag, beforeAttributes, href, middleAttributes) => {
32
+ const asset = findInlineAsset(assets, href, "style");
33
+ if (!asset) {
34
+ return tag;
35
+ }
36
+ inlined.add(asset.fileName);
37
+ const mediaMatch = `${beforeAttributes} ${middleAttributes}`.match(/\smedia=["']([^"']+)["']/);
38
+ const mediaAttribute = mediaMatch ? ` media="${mediaMatch[1]}"` : "";
39
+ return `<style${mediaAttribute}>\n${asset.source}\n</style>`;
40
+ });
41
+ return { html: output, inlinedFileNames: [...inlined] };
42
+ }
43
+ function sourceToString(source) {
44
+ return typeof source === "string" ? source : new TextDecoder().decode(source);
45
+ }
46
+ export function createInlineHtmlPlugin() {
47
+ return {
48
+ name: "git-snitch-inline-html",
49
+ enforce: "post",
50
+ generateBundle(_options, bundle) {
51
+ const htmlEntries = [];
52
+ const assets = [];
53
+ for (const [fileName, entry] of Object.entries(bundle)) {
54
+ if (entry.type === "chunk") {
55
+ assets.push({ fileName: entry.fileName, source: entry.code, kind: "script" });
56
+ }
57
+ if (entry.type === "asset" && entry.fileName.endsWith(".css")) {
58
+ assets.push({ fileName: entry.fileName, source: sourceToString(entry.source), kind: "style" });
59
+ }
60
+ if (entry.type === "asset" && entry.fileName.endsWith(".html")) {
61
+ htmlEntries.push({ fileName, asset: entry });
62
+ }
63
+ }
64
+ if (htmlEntries.length !== 1) {
65
+ throw new Error(`Expected exactly one HTML asset to inline, found ${htmlEntries.length}.`);
66
+ }
67
+ const htmlEntry = htmlEntries[0];
68
+ if (!htmlEntry) {
69
+ throw new Error("Expected one HTML asset to inline, but none was available after validation.");
70
+ }
71
+ const result = inlineHtmlAssets(sourceToString(htmlEntry.asset.source), assets);
72
+ htmlEntry.asset.source = result.html;
73
+ for (const fileName of result.inlinedFileNames) {
74
+ delete bundle[fileName];
75
+ }
76
+ if (htmlEntry.fileName !== "report-template.html") {
77
+ throw new Error(`Expected Vite to emit report-template.html, received ${htmlEntry.fileName}.`);
78
+ }
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,43 @@
1
+ import type { ReactNode } from "react";
2
+ export type NavigationItem = {
3
+ readonly label: string;
4
+ readonly href: string;
5
+ readonly current?: boolean;
6
+ readonly disabled?: boolean;
7
+ };
8
+ export type StatItem = {
9
+ readonly label: string;
10
+ readonly value: string | number;
11
+ readonly description?: string;
12
+ };
13
+ type HeaderProps = {
14
+ readonly title: string;
15
+ readonly titleHref?: string;
16
+ readonly eyebrow?: string;
17
+ readonly description?: string;
18
+ readonly actions?: ReactNode;
19
+ };
20
+ type NavigationProps = {
21
+ readonly items: readonly NavigationItem[];
22
+ readonly label?: string;
23
+ };
24
+ type StatsGridProps = {
25
+ readonly stats: readonly StatItem[];
26
+ readonly emptyTitle?: string;
27
+ readonly emptyDescription?: string;
28
+ };
29
+ type AppShellProps = {
30
+ readonly title: string;
31
+ readonly titleHref?: string;
32
+ readonly eyebrow?: string;
33
+ readonly description?: string;
34
+ readonly navigationItems?: readonly NavigationItem[];
35
+ readonly headerActions?: ReactNode;
36
+ readonly children: ReactNode;
37
+ };
38
+ export declare function Header({ title, titleHref, eyebrow, description, actions }: HeaderProps): import("react/jsx-runtime").JSX.Element;
39
+ export declare function Navigation({ items, label }: NavigationProps): import("react/jsx-runtime").JSX.Element | null;
40
+ export declare function StatsGrid({ stats, emptyTitle, emptyDescription, }: StatsGridProps): import("react/jsx-runtime").JSX.Element;
41
+ export declare function AppShell({ title, titleHref, eyebrow, description, navigationItems, headerActions, children, }: AppShellProps): import("react/jsx-runtime").JSX.Element;
42
+ export {};
43
+ //# sourceMappingURL=layout.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"layout.d.ts","sourceRoot":"","sources":["../src/layout.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAKvC,MAAM,MAAM,cAAc,GAAG;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;CAC7B,CAAC;AAEF,MAAM,MAAM,QAAQ,GAAG;IACrB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;IAChC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B,CAAC;AAEF,KAAK,WAAW,GAAG;IACjB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,CAAC;CAC9B,CAAC;AAEF,KAAK,eAAe,GAAG;IACrB,QAAQ,CAAC,KAAK,EAAE,SAAS,cAAc,EAAE,CAAC;IAC1C,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF,KAAK,cAAc,GAAG;IACpB,QAAQ,CAAC,KAAK,EAAE,SAAS,QAAQ,EAAE,CAAC;IACpC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CACpC,CAAC;AAEF,KAAK,aAAa,GAAG;IACnB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,eAAe,CAAC,EAAE,SAAS,cAAc,EAAE,CAAC;IACrD,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS,CAAC;IACnC,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAC;CAC9B,CAAC;AAEF,wBAAgB,MAAM,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,EAAE,WAAW,2CAwBtF;AAED,wBAAgB,UAAU,CAAC,EAAE,KAAK,EAAE,KAAyB,EAAE,EAAE,eAAe,kDA+B/E;AAED,wBAAgB,SAAS,CAAC,EACxB,KAAK,EACL,UAAuC,EACvC,gBAAqF,GACtF,EAAE,cAAc,2CAoBhB;AAED,wBAAgB,QAAQ,CAAC,EACvB,KAAK,EACL,SAAS,EACT,OAAO,EACP,WAAW,EACX,eAAoB,EACpB,aAAa,EACb,QAAQ,GACT,EAAE,aAAa,2CAQf"}
package/dist/layout.js ADDED
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { buttonVariants } from "@git-snitch/ui/components/button";
3
+ import { Card, CardContent, CardHeader, CardTitle } from "@git-snitch/ui/components/card";
4
+ import { cn } from "@git-snitch/ui/lib/utils";
5
+ import { EmptyState } from "./empty-state.js";
6
+ import { ThemeToggle } from "./theme-toggle.js";
7
+ export function Header({ title, titleHref, eyebrow, description, actions }) {
8
+ const titleContent = _jsx("h1", { className: "mt-3 text-3xl font-semibold tracking-tight text-foreground sm:text-4xl", children: title });
9
+ return (_jsx("header", { className: "border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/75", children: _jsxs("div", { className: "mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 sm:px-8 lg:flex-row lg:items-end lg:justify-between", children: [_jsxs("div", { className: "max-w-4xl", children: [eyebrow ? _jsx("p", { className: "text-xs font-medium uppercase tracking-[0.24em] text-muted-foreground", children: eyebrow }) : null, titleHref ? (_jsx("a", { href: titleHref, target: "_blank", rel: "noopener noreferrer", className: "hover:underline", children: titleContent })) : (titleContent), description ? _jsx("p", { className: "mt-3 max-w-2xl text-sm leading-6 text-muted-foreground", children: description }) : null] }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [actions, _jsx(ThemeToggle, {})] })] }) }));
10
+ }
11
+ export function Navigation({ items, label = "Report sections" }) {
12
+ if (items.length === 0) {
13
+ return null;
14
+ }
15
+ return (_jsx("nav", { "aria-label": label, className: "border-b bg-background/80", children: _jsx("div", { className: "mx-auto flex w-full max-w-7xl gap-2 overflow-x-auto px-5 py-3 sm:px-8", children: items.map((item) => item.disabled ? (_jsx("span", { "aria-disabled": "true", className: "inline-flex h-8 shrink-0 items-center border border-transparent px-3 text-xs font-medium text-muted-foreground/60", children: item.label }, item.href)) : (_jsx("a", { href: item.href, "aria-current": item.current ? "page" : undefined, className: buttonVariants({ variant: item.current ? "secondary" : "ghost", size: "sm", className: "shrink-0" }), children: item.label }, item.href))) }) }));
16
+ }
17
+ export function StatsGrid({ stats, emptyTitle = "No report statistics yet", emptyDescription = "This report does not include enough data for summary statistics.", }) {
18
+ if (stats.length === 0) {
19
+ return _jsx(EmptyState, { title: emptyTitle, description: emptyDescription });
20
+ }
21
+ return (_jsx("section", { "aria-label": "Report summary statistics", className: "grid grid-flow-dense gap-3 sm:grid-cols-2 lg:grid-cols-4", children: stats.map((stat) => (_jsxs(Card, { className: "min-h-32 shadow-none", children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground", children: stat.label }) }), _jsxs(CardContent, { children: [_jsx("p", { className: "text-3xl font-semibold tracking-tight text-foreground", children: stat.value }), stat.description ? _jsx("p", { className: "mt-2 text-xs leading-5 text-muted-foreground", children: stat.description }) : null] })] }, stat.label))) }));
22
+ }
23
+ export function AppShell({ title, titleHref, eyebrow, description, navigationItems = [], headerActions, children, }) {
24
+ return (_jsxs("main", { className: "min-h-screen w-full max-w-full overflow-x-hidden bg-background text-foreground transition-colors", children: [_jsx(Header, { title: title, titleHref: titleHref, eyebrow: eyebrow, description: description, actions: headerActions }), _jsx(Navigation, { items: navigationItems }), _jsx("div", { className: cn("mx-auto flex w-full max-w-7xl flex-col gap-8 px-5 py-8 sm:px-8 sm:py-10"), children: children })] }));
25
+ }
@@ -0,0 +1,6 @@
1
+ export type GitProvider = "github" | "gitlab" | "bitbucket" | "unknown";
2
+ export declare function normalizeRemoteToWebUrl(remoteUrl: string): string;
3
+ export declare function detectProvider(remoteUrl: string | undefined): GitProvider;
4
+ export declare function buildCommitUrl(remoteUrl: string | undefined, hash: string): string | undefined;
5
+ export declare function buildFileUrl(remoteUrl: string | undefined, branch: string | undefined, filePath: string): string | undefined;
6
+ //# sourceMappingURL=remote-urls.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"remote-urls.d.ts","sourceRoot":"","sources":["../src/remote-urls.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,QAAQ,GAAG,WAAW,GAAG,SAAS,CAAC;AAIxE,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAmBjE;AAED,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,WAAW,CAqBzE;AAED,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAkB9F;AAED,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAkB5H"}
@@ -0,0 +1,82 @@
1
+ const SCP_REMOTE = /^git@([^:]+):(.+?)$/;
2
+ export function normalizeRemoteToWebUrl(remoteUrl) {
3
+ const trimmed = remoteUrl.replace(/\/+$/, "");
4
+ const scpMatch = SCP_REMOTE.exec(trimmed);
5
+ if (scpMatch && scpMatch[1] !== undefined && scpMatch[2] !== undefined) {
6
+ return `https://${scpMatch[1]}/${scpMatch[2].replace(/\.git$/, "")}`;
7
+ }
8
+ if (trimmed.startsWith("ssh://")) {
9
+ try {
10
+ const parsed = new URL(trimmed);
11
+ const path = parsed.pathname.replace(/^\/+/, "").replace(/\.git$/, "");
12
+ return `https://${parsed.hostname}/${path}`;
13
+ }
14
+ catch {
15
+ return trimmed.replace(/\.git$/, "");
16
+ }
17
+ }
18
+ return trimmed.replace(/\.git$/, "");
19
+ }
20
+ export function detectProvider(remoteUrl) {
21
+ if (remoteUrl === undefined) {
22
+ return "unknown";
23
+ }
24
+ const host = extractHost(remoteUrl);
25
+ if (host === undefined) {
26
+ return "unknown";
27
+ }
28
+ if (host === "github.com" || host.endsWith(".github.com")) {
29
+ return "github";
30
+ }
31
+ if (host === "gitlab.com" || host.endsWith(".gitlab.com")) {
32
+ return "gitlab";
33
+ }
34
+ if (host === "bitbucket.org" || host.endsWith(".bitbucket.org")) {
35
+ return "bitbucket";
36
+ }
37
+ return "unknown";
38
+ }
39
+ export function buildCommitUrl(remoteUrl, hash) {
40
+ if (remoteUrl === undefined) {
41
+ return undefined;
42
+ }
43
+ const provider = detectProvider(remoteUrl);
44
+ const base = normalizeRemoteToWebUrl(remoteUrl);
45
+ switch (provider) {
46
+ case "github":
47
+ return `${base}/commit/${hash}`;
48
+ case "gitlab":
49
+ return `${base}/-/commit/${hash}`;
50
+ case "bitbucket":
51
+ return `${base}/commits/${hash}`;
52
+ default:
53
+ return `${base}/commit/${hash}`;
54
+ }
55
+ }
56
+ export function buildFileUrl(remoteUrl, branch, filePath) {
57
+ if (remoteUrl === undefined || branch === undefined) {
58
+ return undefined;
59
+ }
60
+ const provider = detectProvider(remoteUrl);
61
+ const base = normalizeRemoteToWebUrl(remoteUrl);
62
+ switch (provider) {
63
+ case "github":
64
+ return `${base}/blob/${branch}/${filePath}`;
65
+ case "gitlab":
66
+ return `${base}/-/blob/${branch}/${filePath}`;
67
+ case "bitbucket":
68
+ return `${base}/src/${branch}/${filePath}`;
69
+ default:
70
+ return `${base}/blob/${branch}/${filePath}`;
71
+ }
72
+ }
73
+ function extractHost(remoteUrl) {
74
+ try {
75
+ const parsed = new URL(remoteUrl);
76
+ return parsed.hostname;
77
+ }
78
+ catch {
79
+ const scpMatch = SCP_REMOTE.exec(remoteUrl);
80
+ return scpMatch?.[1];
81
+ }
82
+ }
@@ -0,0 +1,5 @@
1
+ import type { ReportData } from "@git-snitch/core";
2
+ export declare const REPORT_DATA_PLACEHOLDER = "__GIT_SNITCH_REPORT_DATA__";
3
+ export declare function serializeReportDataForHtml(report: ReportData): string;
4
+ export declare function injectReportDataIntoHtml(templateHtml: string, report: ReportData): string;
5
+ //# sourceMappingURL=serialization.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serialization.d.ts","sourceRoot":"","sources":["../src/serialization.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,eAAO,MAAM,uBAAuB,+BAA+B,CAAC;AAyBpE,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,UAAU,GAAG,MAAM,CAerE;AAED,wBAAgB,wBAAwB,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,GAAG,MAAM,CAazF"}
@@ -0,0 +1,46 @@
1
+ export const REPORT_DATA_PLACEHOLDER = "__GIT_SNITCH_REPORT_DATA__";
2
+ const escapedCharacters = /[<>&\u2028\u2029]/g;
3
+ function escapeCharacter(character) {
4
+ switch (character) {
5
+ case "<":
6
+ return "\\u003c";
7
+ case ">":
8
+ return "\\u003e";
9
+ case "&":
10
+ return "\\u0026";
11
+ case "\u2028":
12
+ return "\\u2028";
13
+ case "\u2029":
14
+ return "\\u2029";
15
+ default:
16
+ return character;
17
+ }
18
+ }
19
+ function escapeJsonForScript(json) {
20
+ return json.replace(escapedCharacters, escapeCharacter);
21
+ }
22
+ export function serializeReportDataForHtml(report) {
23
+ let json;
24
+ try {
25
+ json = JSON.stringify(report);
26
+ }
27
+ catch (error) {
28
+ const message = error instanceof Error ? error.message : "unknown serialization error";
29
+ throw new Error(`Report data could not be serialized for HTML injection: ${message}`);
30
+ }
31
+ if (json === undefined) {
32
+ throw new Error("Report data could not be serialized for HTML injection: JSON.stringify returned undefined.");
33
+ }
34
+ return escapeJsonForScript(json);
35
+ }
36
+ export function injectReportDataIntoHtml(templateHtml, report) {
37
+ const placeholderLiteral = JSON.stringify(REPORT_DATA_PLACEHOLDER);
38
+ const placeholderIndex = templateHtml.indexOf(placeholderLiteral);
39
+ if (placeholderIndex === -1) {
40
+ throw new Error(`Report template is missing the ${REPORT_DATA_PLACEHOLDER} data placeholder.`);
41
+ }
42
+ if (templateHtml.indexOf(placeholderLiteral, placeholderIndex + placeholderLiteral.length) !== -1) {
43
+ throw new Error(`Report template must contain exactly one ${REPORT_DATA_PLACEHOLDER} data placeholder.`);
44
+ }
45
+ return templateHtml.replace(placeholderLiteral, serializeReportDataForHtml(report));
46
+ }
@@ -0,0 +1,50 @@
1
+ import type { ColumnDef } from "@tanstack/react-table";
2
+ import type { FileHotspot, CommitRecord, ContributorSummary } from "@git-snitch/core";
3
+ import type { CsvRow, DownloadResult } from "./export.js";
4
+ type CsvDownloader = (filename: string, rows: readonly CsvRow[], columns?: readonly string[]) => DownloadResult;
5
+ export type DataTableExport<TData> = {
6
+ readonly filename: string;
7
+ readonly mapRow: (row: TData) => CsvRow;
8
+ readonly columns?: readonly string[];
9
+ readonly downloader?: CsvDownloader;
10
+ };
11
+ export type DataTableEmptyState = {
12
+ readonly title: string;
13
+ readonly description: string;
14
+ };
15
+ export type DataTableProps<TData> = {
16
+ readonly data: readonly TData[];
17
+ readonly columns: ColumnDef<TData>[];
18
+ readonly emptyState: DataTableEmptyState;
19
+ readonly search?: {
20
+ readonly placeholder: string;
21
+ readonly toText: (row: TData) => string;
22
+ };
23
+ readonly exportConfig?: DataTableExport<TData>;
24
+ readonly initialPageSize?: number;
25
+ readonly ariaLabel: string;
26
+ };
27
+ export declare function DataTable<TData>({ data, columns, emptyState, search, exportConfig, initialPageSize, ariaLabel, }: DataTableProps<TData>): import("react/jsx-runtime").JSX.Element;
28
+ export type CommitsTableProps = {
29
+ readonly commits: readonly CommitRecord[];
30
+ readonly exportFilename?: string;
31
+ readonly downloader?: CsvDownloader;
32
+ readonly remoteUrl?: string;
33
+ };
34
+ export declare function CommitsTable({ commits, exportFilename, downloader, remoteUrl }: CommitsTableProps): import("react/jsx-runtime").JSX.Element;
35
+ export type ContributorsTableProps = {
36
+ readonly contributors: readonly ContributorSummary[];
37
+ readonly exportFilename?: string;
38
+ readonly downloader?: CsvDownloader;
39
+ };
40
+ export declare function ContributorsTable({ contributors, exportFilename, downloader }: ContributorsTableProps): import("react/jsx-runtime").JSX.Element;
41
+ export type HotspotsTableProps = {
42
+ readonly hotspots: readonly FileHotspot[];
43
+ readonly exportFilename?: string;
44
+ readonly downloader?: CsvDownloader;
45
+ readonly remoteUrl?: string;
46
+ readonly currentBranch?: string;
47
+ };
48
+ export declare function HotspotsTable({ hotspots, exportFilename, downloader, remoteUrl, currentBranch }: HotspotsTableProps): import("react/jsx-runtime").JSX.Element;
49
+ export {};
50
+ //# sourceMappingURL=tables.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tables.d.ts","sourceRoot":"","sources":["../src/tables.tsx"],"names":[],"mappings":"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"}
package/dist/tables.js ADDED
@@ -0,0 +1,228 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { flexRender, getCoreRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, } from "@tanstack/react-table";
3
+ import { Button } from "@git-snitch/ui/components/button";
4
+ import { Input } from "@git-snitch/ui/components/input";
5
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@git-snitch/ui/components/table";
6
+ import { cn } from "@git-snitch/ui/lib/utils";
7
+ import { useMemo, useState } from "react";
8
+ import { buildCommitUrl, buildFileUrl } from "./remote-urls.js";
9
+ import { EmptyState } from "./empty-state.js";
10
+ import { downloadCsv } from "./export.js";
11
+ const defaultPageSize = 10;
12
+ const pageSizes = [5, 10, 25, 50];
13
+ function getAriaSort(sortDirection) {
14
+ if (sortDirection === "asc") {
15
+ return "ascending";
16
+ }
17
+ if (sortDirection === "desc") {
18
+ return "descending";
19
+ }
20
+ return "none";
21
+ }
22
+ export function DataTable({ data, columns, emptyState, search, exportConfig, initialPageSize = defaultPageSize, ariaLabel, }) {
23
+ const [query, setQuery] = useState("");
24
+ const [sorting, setSorting] = useState([]);
25
+ const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: initialPageSize });
26
+ const [exportStatus, setExportStatus] = useState();
27
+ const filteredData = useMemo(() => {
28
+ const normalizedQuery = query.trim().toLocaleLowerCase();
29
+ if (search === undefined || normalizedQuery.length === 0) {
30
+ return data;
31
+ }
32
+ return data.filter((row) => search.toText(row).toLocaleLowerCase().includes(normalizedQuery));
33
+ }, [data, query, search]);
34
+ const tableData = useMemo(() => [...filteredData], [filteredData]);
35
+ const table = useReactTable({
36
+ data: tableData,
37
+ columns,
38
+ getCoreRowModel: getCoreRowModel(),
39
+ getPaginationRowModel: getPaginationRowModel(),
40
+ getSortedRowModel: getSortedRowModel(),
41
+ onPaginationChange: setPagination,
42
+ onSortingChange: setSorting,
43
+ state: { pagination, sorting },
44
+ });
45
+ const totalRows = filteredData.length;
46
+ const firstVisibleRow = totalRows === 0 ? 0 : pagination.pageIndex * pagination.pageSize + 1;
47
+ const lastVisibleRow = Math.min(totalRows, (pagination.pageIndex + 1) * pagination.pageSize);
48
+ const canExport = exportConfig !== undefined && totalRows > 0;
49
+ function handleSearchChange(value) {
50
+ setQuery(value);
51
+ setPagination((current) => ({ ...current, pageIndex: 0 }));
52
+ }
53
+ function handleExport() {
54
+ if (exportConfig === undefined) {
55
+ return;
56
+ }
57
+ const rows = table.getSortedRowModel().rows.map((row) => exportConfig.mapRow(row.original));
58
+ const result = (exportConfig.downloader ?? downloadCsv)(exportConfig.filename, rows, exportConfig.columns);
59
+ setExportStatus(result.status === "downloaded" ? "CSV export started." : result.reason);
60
+ }
61
+ if (data.length === 0) {
62
+ return _jsx(EmptyState, { title: emptyState.title, description: emptyState.description });
63
+ }
64
+ return (_jsxs("section", { className: "rounded-xl border border-border/70 bg-card/80 shadow-sm", "aria-label": ariaLabel, children: [_jsxs("div", { className: "flex flex-col gap-3 border-b border-border/70 p-3 sm:flex-row sm:items-center sm:justify-between", children: [search ? (_jsxs("label", { className: "min-w-0 flex-1 sm:max-w-sm", children: [_jsx("span", { className: "sr-only", children: "Search table" }), _jsx(Input, { value: query, onChange: (event) => handleSearchChange(event.currentTarget.value), placeholder: search.placeholder, className: "h-9 rounded-lg border-border/70 bg-background/70 text-sm" })] })) : (_jsx("div", {})), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("p", { className: "text-xs text-muted-foreground", "aria-live": "polite", children: totalRows === 0 ? "No matching rows" : `${firstVisibleRow}-${lastVisibleRow} of ${totalRows}` }), exportConfig ? (_jsx(Button, { variant: "outline", size: "sm", onClick: handleExport, disabled: !canExport, children: "Export CSV" })) : null] })] }), totalRows === 0 ? (_jsx("div", { className: "p-4", children: _jsx(EmptyState, { title: "No matching rows", description: "Adjust the search term to bring matching report rows back into view." }) })) : (_jsxs(Table, { "aria-label": ariaLabel, children: [_jsx(TableHeader, { children: table.getHeaderGroups().map((headerGroup) => (_jsx(TableRow, { className: "hover:bg-transparent", children: headerGroup.headers.map((header) => {
65
+ const canSort = header.column.getCanSort();
66
+ const sortDirection = header.column.getIsSorted();
67
+ return (_jsx(TableHead, { className: "bg-muted/30 px-3 py-2 text-[0.7rem] uppercase tracking-[0.18em] text-muted-foreground", "aria-sort": canSort ? getAriaSort(sortDirection) : undefined, children: header.isPlaceholder ? null : canSort ? (_jsxs("button", { type: "button", className: "inline-flex items-center gap-1 rounded-md text-left font-semibold outline-none transition-colors hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring", onClick: header.column.getToggleSortingHandler(), "aria-label": `Sort by ${String(header.column.columnDef.header)}`, children: [flexRender(header.column.columnDef.header, header.getContext()), _jsx("span", { "aria-hidden": "true", children: sortDirection === "asc" ? "↑" : sortDirection === "desc" ? "↓" : "↕" })] })) : (flexRender(header.column.columnDef.header, header.getContext())) }, header.id));
68
+ }) }, headerGroup.id))) }), _jsx(TableBody, { children: table.getRowModel().rows.map((row) => (_jsx(TableRow, { children: row.getVisibleCells().map((cell) => (_jsx(TableCell, { className: "px-3 py-3 text-sm", children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id))) }, row.id))) })] })), _jsxs("div", { className: "flex flex-col gap-3 border-t border-border/70 p-3 sm:flex-row sm:items-center sm:justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-xs text-muted-foreground", children: "Rows per page" }), _jsx("div", { className: "flex gap-1", role: "group", "aria-label": "Rows per page", children: pageSizes.map((size) => (_jsx(Button, { type: "button", variant: pagination.pageSize === size ? "secondary" : "ghost", size: "xs", onClick: () => table.setPageSize(size), "aria-pressed": pagination.pageSize === size, "aria-label": `${size} rows per page`, children: size }, size))) })] }), _jsxs("div", { className: "flex items-center justify-between gap-2 sm:justify-end", children: [_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => table.previousPage(), disabled: !table.getCanPreviousPage(), children: "Previous" }), _jsxs("span", { className: "min-w-20 text-center text-xs text-muted-foreground", children: ["Page ", table.getState().pagination.pageIndex + 1, " of ", Math.max(table.getPageCount(), 1)] }), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => table.nextPage(), disabled: !table.getCanNextPage(), children: "Next" })] })] }), exportStatus ? _jsx("p", { className: "border-t border-border/70 px-3 py-2 text-xs text-muted-foreground", "aria-live": "polite", children: exportStatus }) : null] }));
69
+ }
70
+ export function CommitsTable({ commits, exportFilename = "commits.csv", downloader, remoteUrl }) {
71
+ return (_jsx(DataTable, { ariaLabel: "Commits table", data: commits, columns: buildCommitColumns(remoteUrl), search: { placeholder: "Search commits, authors, files", toText: commitSearchText }, emptyState: { title: "No commits to show", description: "This report did not include commits for the selected repository and branch scope." }, exportConfig: { filename: exportFilename, mapRow: commitToCsvRow, columns: commitCsvColumns, downloader } }));
72
+ }
73
+ export function ContributorsTable({ contributors, exportFilename = "contributors.csv", downloader }) {
74
+ return (_jsx(DataTable, { ariaLabel: "Contributors table", data: contributors, columns: contributorColumns, search: { placeholder: "Search contributors", toText: contributorSearchText }, emptyState: { title: "No contributors to show", description: "This report has no contributor activity yet." }, exportConfig: { filename: exportFilename, mapRow: contributorToCsvRow, columns: contributorCsvColumns, downloader } }));
75
+ }
76
+ export function HotspotsTable({ hotspots, exportFilename = "hotspots.csv", downloader, remoteUrl, currentBranch }) {
77
+ return (_jsx(DataTable, { ariaLabel: "Hotspots table", data: hotspots, columns: buildHotspotColumns(remoteUrl, currentBranch), search: { placeholder: "Search files or contributors", toText: hotspotSearchText }, emptyState: { title: "No hotspots to show", description: "This repository has no file churn data to rank yet." }, exportConfig: { filename: exportFilename, mapRow: hotspotToCsvRow, columns: hotspotCsvColumns, downloader } }));
78
+ }
79
+ function commitAdditions(commit) {
80
+ return commit.files.reduce((sum, file) => sum + file.additions, 0);
81
+ }
82
+ function commitDeletions(commit) {
83
+ return commit.files.reduce((sum, file) => sum + file.deletions, 0);
84
+ }
85
+ function formatDate(isoDate) {
86
+ if (isoDate === undefined) {
87
+ return "—";
88
+ }
89
+ return new Intl.DateTimeFormat("en", { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" }).format(new Date(isoDate));
90
+ }
91
+ function numberCell(value) {
92
+ return value.toLocaleString("en");
93
+ }
94
+ function RiskBadge({ level }) {
95
+ return (_jsx("span", { className: cn("inline-flex rounded-full border px-2 py-0.5 text-xs font-medium capitalize", level === "high" ? "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300" : undefined, level === "medium" ? "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300" : undefined, level === "low" ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" : undefined), children: level }));
96
+ }
97
+ function buildCommitColumns(remoteUrl) {
98
+ return [
99
+ {
100
+ accessorKey: "shortHash",
101
+ header: "Commit",
102
+ cell: ({ row }) => {
103
+ const url = buildCommitUrl(remoteUrl, row.original.hash);
104
+ const inner = _jsx("span", { className: "font-mono text-xs text-muted-foreground", children: row.original.shortHash });
105
+ return url ? _jsx("a", { href: url, target: "_blank", rel: "noopener noreferrer", children: inner }) : inner;
106
+ },
107
+ },
108
+ {
109
+ accessorKey: "message",
110
+ header: "Message",
111
+ cell: ({ row }) => _jsx("span", { className: "block max-w-xl truncate font-medium text-foreground", children: row.original.message }),
112
+ },
113
+ {
114
+ accessorFn: (commit) => commit.author.name,
115
+ id: "author",
116
+ header: "Author",
117
+ },
118
+ {
119
+ accessorKey: "classification",
120
+ header: "Type",
121
+ cell: ({ row }) => _jsx("span", { className: "capitalize", children: row.original.classification }),
122
+ },
123
+ {
124
+ accessorFn: (commit) => commitAdditions(commit),
125
+ id: "additions",
126
+ header: "Additions",
127
+ cell: ({ row }) => numberCell(commitAdditions(row.original)),
128
+ },
129
+ {
130
+ accessorFn: (commit) => commitDeletions(commit),
131
+ id: "deletions",
132
+ header: "Deletions",
133
+ cell: ({ row }) => numberCell(commitDeletions(row.original)),
134
+ },
135
+ {
136
+ accessorKey: "authoredAt",
137
+ header: "Authored",
138
+ cell: ({ row }) => formatDate(row.original.authoredAt),
139
+ },
140
+ ];
141
+ }
142
+ const contributorColumns = [
143
+ { accessorKey: "name", header: "Contributor", cell: ({ row }) => _jsx("span", { className: "font-medium text-foreground", children: row.original.name }) },
144
+ { accessorKey: "email", header: "Email" },
145
+ { accessorKey: "commitCount", header: "Commits", cell: ({ row }) => numberCell(row.original.commitCount) },
146
+ { accessorKey: "additions", header: "Additions", cell: ({ row }) => numberCell(row.original.additions) },
147
+ { accessorKey: "deletions", header: "Deletions", cell: ({ row }) => numberCell(row.original.deletions) },
148
+ { accessorKey: "filesChanged", header: "Files", cell: ({ row }) => numberCell(row.original.filesChanged) },
149
+ { accessorKey: "lastCommitAt", header: "Last seen", cell: ({ row }) => formatDate(row.original.lastCommitAt) },
150
+ ];
151
+ function buildHotspotColumns(remoteUrl, currentBranch) {
152
+ return [
153
+ {
154
+ accessorKey: "path",
155
+ header: "File",
156
+ cell: ({ row }) => {
157
+ const url = buildFileUrl(remoteUrl, currentBranch, row.original.path);
158
+ const inner = _jsx("span", { className: "font-mono text-xs text-foreground", children: row.original.path });
159
+ return url ? _jsx("a", { href: url, target: "_blank", rel: "noopener noreferrer", children: inner }) : inner;
160
+ },
161
+ },
162
+ { accessorKey: "riskLevel.level", header: "Risk", cell: ({ row }) => _jsx(RiskBadge, { level: row.original.riskLevel.level }) },
163
+ { accessorKey: "hotspotScore", header: "Score", cell: ({ row }) => numberCell(row.original.hotspotScore) },
164
+ { accessorKey: "changeCount", header: "Changes", cell: ({ row }) => numberCell(row.original.changeCount) },
165
+ { accessorKey: "churn", header: "Churn", cell: ({ row }) => numberCell(row.original.churn) },
166
+ { accessorKey: "contributorCount", header: "Contributors", cell: ({ row }) => numberCell(row.original.contributorCount) },
167
+ { accessorKey: "lastChangedAt", header: "Last changed", cell: ({ row }) => formatDate(row.original.lastChangedAt) },
168
+ ];
169
+ }
170
+ const commitCsvColumns = ["hash", "message", "author", "email", "classification", "additions", "deletions", "authoredAt", "files"];
171
+ const contributorCsvColumns = ["name", "email", "commitCount", "additions", "deletions", "filesChanged", "firstCommitAt", "lastCommitAt"];
172
+ const hotspotCsvColumns = ["path", "risk", "hotspotScore", "changeCount", "additions", "deletions", "churn", "contributors", "lastChangedAt"];
173
+ function commitSearchText(commit) {
174
+ return [
175
+ commit.hash,
176
+ commit.shortHash,
177
+ commit.message,
178
+ commit.author.name,
179
+ commit.author.email,
180
+ commit.classification,
181
+ ...commit.refs,
182
+ ...commit.files.map((file) => file.path),
183
+ ].join(" ");
184
+ }
185
+ function contributorSearchText(contributor) {
186
+ return `${contributor.name} ${contributor.email}`;
187
+ }
188
+ function hotspotSearchText(hotspot) {
189
+ return `${hotspot.path} ${hotspot.riskLevel.level} ${hotspot.contributors.join(" ")}`;
190
+ }
191
+ function commitToCsvRow(commit) {
192
+ return {
193
+ hash: commit.hash,
194
+ message: commit.message,
195
+ author: commit.author.name,
196
+ email: commit.author.email,
197
+ classification: commit.classification,
198
+ additions: commitAdditions(commit),
199
+ deletions: commitDeletions(commit),
200
+ authoredAt: commit.authoredAt,
201
+ files: commit.files.map((file) => file.path).join("; "),
202
+ };
203
+ }
204
+ function contributorToCsvRow(contributor) {
205
+ return {
206
+ name: contributor.name,
207
+ email: contributor.email,
208
+ commitCount: contributor.commitCount,
209
+ additions: contributor.additions,
210
+ deletions: contributor.deletions,
211
+ filesChanged: contributor.filesChanged,
212
+ firstCommitAt: contributor.firstCommitAt,
213
+ lastCommitAt: contributor.lastCommitAt,
214
+ };
215
+ }
216
+ function hotspotToCsvRow(hotspot) {
217
+ return {
218
+ path: hotspot.path,
219
+ risk: hotspot.riskLevel.level,
220
+ hotspotScore: hotspot.hotspotScore,
221
+ changeCount: hotspot.changeCount,
222
+ additions: hotspot.additions,
223
+ deletions: hotspot.deletions,
224
+ churn: hotspot.churn,
225
+ contributors: hotspot.contributors.join("; "),
226
+ lastChangedAt: hotspot.lastChangedAt,
227
+ };
228
+ }