@urmzd/github-insights 2.0.0
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/.gitattributes +28 -0
- package/.github/dependabot.yml +6 -0
- package/.github/pull_request_template.md +14 -0
- package/.github/workflows/ci.yml +93 -0
- package/.github/workflows/release.yml +59 -0
- package/.nvmrc +1 -0
- package/.pre-commit-config.yaml +5 -0
- package/AGENTS.md +69 -0
- package/CHANGELOG.md +260 -0
- package/CONTRIBUTING.md +87 -0
- package/LICENSE +190 -0
- package/README.md +188 -0
- package/action.yml +45 -0
- package/biome.json +40 -0
- package/examples/classic/README.md +9 -0
- package/examples/classic/index.svg +14 -0
- package/examples/classic/metrics-calendar.svg +14 -0
- package/examples/classic/metrics-complexity.svg +14 -0
- package/examples/classic/metrics-contributions.svg +14 -0
- package/examples/classic/metrics-expertise.svg +14 -0
- package/examples/classic/metrics-languages.svg +14 -0
- package/examples/classic/metrics-pulse.svg +14 -0
- package/examples/ecosystem/README.md +59 -0
- package/examples/ecosystem/index.svg +14 -0
- package/examples/ecosystem/metrics-calendar.svg +14 -0
- package/examples/ecosystem/metrics-complexity.svg +14 -0
- package/examples/ecosystem/metrics-contributions.svg +14 -0
- package/examples/ecosystem/metrics-expertise.svg +14 -0
- package/examples/ecosystem/metrics-languages.svg +14 -0
- package/examples/ecosystem/metrics-pulse.svg +14 -0
- package/examples/minimal/README.md +9 -0
- package/examples/minimal/index.svg +14 -0
- package/examples/minimal/metrics-calendar.svg +14 -0
- package/examples/minimal/metrics-complexity.svg +14 -0
- package/examples/minimal/metrics-contributions.svg +14 -0
- package/examples/minimal/metrics-expertise.svg +14 -0
- package/examples/minimal/metrics-languages.svg +14 -0
- package/examples/minimal/metrics-pulse.svg +14 -0
- package/examples/modern/README.md +111 -0
- package/examples/modern/index.svg +14 -0
- package/examples/modern/metrics-calendar.svg +14 -0
- package/examples/modern/metrics-complexity.svg +14 -0
- package/examples/modern/metrics-contributions.svg +14 -0
- package/examples/modern/metrics-expertise.svg +14 -0
- package/examples/modern/metrics-languages.svg +14 -0
- package/examples/modern/metrics-pulse.svg +14 -0
- package/llms.txt +24 -0
- package/metrics/index.svg +14 -0
- package/metrics/metrics-calendar.svg +14 -0
- package/metrics/metrics-complexity.svg +14 -0
- package/metrics/metrics-contributions.svg +14 -0
- package/metrics/metrics-domains.svg +14 -0
- package/metrics/metrics-expertise.svg +14 -0
- package/metrics/metrics-languages.svg +14 -0
- package/metrics/metrics-pulse.svg +14 -0
- package/metrics/metrics-tech-stack.svg +14 -0
- package/package.json +35 -0
- package/skills/github-insights/SKILL.md +237 -0
- package/sr.yaml +16 -0
- package/src/__fixtures__/repos.ts +84 -0
- package/src/api.ts +729 -0
- package/src/components/bar-chart.test.tsx +38 -0
- package/src/components/bar-chart.tsx +54 -0
- package/src/components/contribution-calendar.test.tsx +44 -0
- package/src/components/contribution-calendar.tsx +94 -0
- package/src/components/contribution-cards.test.tsx +36 -0
- package/src/components/contribution-cards.tsx +58 -0
- package/src/components/donut-chart.test.tsx +36 -0
- package/src/components/donut-chart.tsx +102 -0
- package/src/components/full-svg.test.tsx +54 -0
- package/src/components/full-svg.tsx +59 -0
- package/src/components/project-cards.test.tsx +46 -0
- package/src/components/project-cards.tsx +66 -0
- package/src/components/section.test.tsx +69 -0
- package/src/components/section.tsx +79 -0
- package/src/components/stat-cards.test.tsx +32 -0
- package/src/components/stat-cards.tsx +57 -0
- package/src/components/style-defs.test.tsx +26 -0
- package/src/components/style-defs.tsx +27 -0
- package/src/components/tech-highlights.test.tsx +63 -0
- package/src/components/tech-highlights.tsx +109 -0
- package/src/config.test.ts +127 -0
- package/src/config.ts +103 -0
- package/src/index.ts +363 -0
- package/src/jsx-factory.test.tsx +86 -0
- package/src/jsx-factory.ts +46 -0
- package/src/jsx.d.ts +6 -0
- package/src/metrics.test.ts +669 -0
- package/src/metrics.ts +365 -0
- package/src/parsers.test.ts +247 -0
- package/src/parsers.ts +146 -0
- package/src/readme.test.ts +189 -0
- package/src/readme.ts +70 -0
- package/src/svg-utils.test.ts +66 -0
- package/src/svg-utils.ts +33 -0
- package/src/templates.test.ts +412 -0
- package/src/templates.ts +296 -0
- package/src/theme.ts +33 -0
- package/src/types.ts +235 -0
- package/teasr.toml +14 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { Fragment, h } from "../jsx-factory.js";
|
|
3
|
+
import type { BarItem } from "../types.js";
|
|
4
|
+
import { renderBarChart } from "./bar-chart.js";
|
|
5
|
+
|
|
6
|
+
void Fragment;
|
|
7
|
+
|
|
8
|
+
describe("renderBarChart", () => {
|
|
9
|
+
const items: BarItem[] = [
|
|
10
|
+
{ name: "TypeScript", value: 75, percent: "75.0", color: "#3178c6" },
|
|
11
|
+
{ name: "JavaScript", value: 25, percent: "25.0", color: "#f1e05a" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
it("returns { svg, height }", () => {
|
|
15
|
+
const result = renderBarChart(items, 0);
|
|
16
|
+
expect(result).toHaveProperty("svg");
|
|
17
|
+
expect(result).toHaveProperty("height");
|
|
18
|
+
expect(typeof result.svg).toBe("string");
|
|
19
|
+
expect(typeof result.height).toBe("number");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("height matches item count * row height", () => {
|
|
23
|
+
const result = renderBarChart(items, 0);
|
|
24
|
+
expect(result.height).toBe(items.length * 48);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("svg contains item names", () => {
|
|
28
|
+
const result = renderBarChart(items, 0);
|
|
29
|
+
expect(result.svg).toContain("TypeScript");
|
|
30
|
+
expect(result.svg).toContain("JavaScript");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns empty for empty input", () => {
|
|
34
|
+
const result = renderBarChart([], 0);
|
|
35
|
+
expect(result.svg).toBe("");
|
|
36
|
+
expect(result.height).toBe(0);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Fragment, h } from "../jsx-factory.js";
|
|
2
|
+
import { escapeXml, truncate } from "../svg-utils.js";
|
|
3
|
+
import { BAR_COLORS, LAYOUT } from "../theme.js";
|
|
4
|
+
import type { BarItem, RenderResult } from "../types.js";
|
|
5
|
+
|
|
6
|
+
export function renderBarChart(
|
|
7
|
+
items: BarItem[],
|
|
8
|
+
y: number,
|
|
9
|
+
options: Record<string, unknown> = {},
|
|
10
|
+
): RenderResult {
|
|
11
|
+
if (items.length === 0) return { svg: "", height: 0 };
|
|
12
|
+
|
|
13
|
+
const { barHeight, barRowHeight, barMaxWidth, padX } = LAYOUT;
|
|
14
|
+
const useItemColors = options.useItemColors === true;
|
|
15
|
+
const maxValue = Math.max(...items.map((d) => d.value));
|
|
16
|
+
|
|
17
|
+
const svg = (
|
|
18
|
+
<>
|
|
19
|
+
{items.map((item, i) => {
|
|
20
|
+
const ry = y + i * barRowHeight;
|
|
21
|
+
const barWidth = Math.max((item.value / maxValue) * barMaxWidth, 4);
|
|
22
|
+
const color = useItemColors
|
|
23
|
+
? item.color || BAR_COLORS[i % BAR_COLORS.length]
|
|
24
|
+
: BAR_COLORS[i % BAR_COLORS.length];
|
|
25
|
+
const label = escapeXml(truncate(item.name, 40));
|
|
26
|
+
const valueLabel = item.percent
|
|
27
|
+
? `${item.percent}%`
|
|
28
|
+
: String(item.value);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
<text x={padX} y={ry + 14} className="t t-label">
|
|
33
|
+
{label}
|
|
34
|
+
</text>
|
|
35
|
+
<rect
|
|
36
|
+
x={padX}
|
|
37
|
+
y={ry + 26}
|
|
38
|
+
width={barWidth}
|
|
39
|
+
height={barHeight}
|
|
40
|
+
rx="3"
|
|
41
|
+
fill={color}
|
|
42
|
+
opacity="0.85"
|
|
43
|
+
/>
|
|
44
|
+
<text x={padX + barWidth + 8} y={ry + 40} className="t t-value">
|
|
45
|
+
{valueLabel}
|
|
46
|
+
</text>
|
|
47
|
+
</>
|
|
48
|
+
);
|
|
49
|
+
})}
|
|
50
|
+
</>
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return { svg, height: items.length * barRowHeight };
|
|
54
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { makeContributionCalendar } from "../__fixtures__/repos.js";
|
|
3
|
+
import { Fragment, h } from "../jsx-factory.js";
|
|
4
|
+
import { renderContributionCalendar } from "./contribution-calendar.js";
|
|
5
|
+
|
|
6
|
+
void Fragment;
|
|
7
|
+
|
|
8
|
+
describe("renderContributionCalendar", () => {
|
|
9
|
+
const calendar = makeContributionCalendar();
|
|
10
|
+
|
|
11
|
+
it("returns { svg, height }", () => {
|
|
12
|
+
const result = renderContributionCalendar(calendar, 0);
|
|
13
|
+
expect(result).toHaveProperty("svg");
|
|
14
|
+
expect(result).toHaveProperty("height");
|
|
15
|
+
expect(typeof result.svg).toBe("string");
|
|
16
|
+
expect(result.height).toBeGreaterThan(0);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("renders rect cells for each contribution day", () => {
|
|
20
|
+
const { svg } = renderContributionCalendar(calendar, 0);
|
|
21
|
+
// 2 weeks x 7 days = 14 rects
|
|
22
|
+
const rectCount = (svg.match(/<rect /g) || []).length;
|
|
23
|
+
expect(rectCount).toBe(14);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("includes month labels", () => {
|
|
27
|
+
const { svg } = renderContributionCalendar(calendar, 0);
|
|
28
|
+
expect(svg).toContain("Jan");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("includes day labels", () => {
|
|
32
|
+
const { svg } = renderContributionCalendar(calendar, 0);
|
|
33
|
+
expect(svg).toContain("Mon");
|
|
34
|
+
expect(svg).toContain("Wed");
|
|
35
|
+
expect(svg).toContain("Fri");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("uses colors from the calendar data", () => {
|
|
39
|
+
const { svg } = renderContributionCalendar(calendar, 0);
|
|
40
|
+
expect(svg).toContain("#0e4429");
|
|
41
|
+
expect(svg).toContain("#006d32");
|
|
42
|
+
expect(svg).toContain("#39d353");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Fragment, h } from "../jsx-factory.js";
|
|
2
|
+
import { escapeXml } from "../svg-utils.js";
|
|
3
|
+
import { LAYOUT, THEME } from "../theme.js";
|
|
4
|
+
import type { ContributionCalendar, RenderResult } from "../types.js";
|
|
5
|
+
|
|
6
|
+
const CELL_SIZE = 11;
|
|
7
|
+
const CELL_GAP = 2;
|
|
8
|
+
const STEP = CELL_SIZE + CELL_GAP;
|
|
9
|
+
const DAY_LABEL_WIDTH = 30;
|
|
10
|
+
const MONTH_LABEL_HEIGHT = 16;
|
|
11
|
+
const DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""];
|
|
12
|
+
const MONTH_NAMES = [
|
|
13
|
+
"Jan",
|
|
14
|
+
"Feb",
|
|
15
|
+
"Mar",
|
|
16
|
+
"Apr",
|
|
17
|
+
"May",
|
|
18
|
+
"Jun",
|
|
19
|
+
"Jul",
|
|
20
|
+
"Aug",
|
|
21
|
+
"Sep",
|
|
22
|
+
"Oct",
|
|
23
|
+
"Nov",
|
|
24
|
+
"Dec",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export function renderContributionCalendar(
|
|
28
|
+
calendar: ContributionCalendar,
|
|
29
|
+
y: number,
|
|
30
|
+
): RenderResult {
|
|
31
|
+
const { padX } = LAYOUT;
|
|
32
|
+
const weeks = calendar.weeks;
|
|
33
|
+
const gridX = padX + DAY_LABEL_WIDTH;
|
|
34
|
+
const gridY = y + MONTH_LABEL_HEIGHT;
|
|
35
|
+
|
|
36
|
+
// Build month labels from the first day of each week
|
|
37
|
+
const monthLabels: { label: string; x: number }[] = [];
|
|
38
|
+
let lastMonth = -1;
|
|
39
|
+
for (let w = 0; w < weeks.length; w++) {
|
|
40
|
+
const days = weeks[w].contributionDays;
|
|
41
|
+
if (days.length === 0) continue;
|
|
42
|
+
const month = new Date(days[0].date).getMonth();
|
|
43
|
+
if (month !== lastMonth) {
|
|
44
|
+
monthLabels.push({
|
|
45
|
+
label: MONTH_NAMES[month],
|
|
46
|
+
x: gridX + w * STEP,
|
|
47
|
+
});
|
|
48
|
+
lastMonth = month;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const svg = (
|
|
53
|
+
<>
|
|
54
|
+
{/* Month labels */}
|
|
55
|
+
{monthLabels.map((m) => (
|
|
56
|
+
<text x={m.x} y={y + 11} className="t t-value">
|
|
57
|
+
{escapeXml(m.label)}
|
|
58
|
+
</text>
|
|
59
|
+
))}
|
|
60
|
+
{/* Day labels */}
|
|
61
|
+
{DAY_LABELS.map((label, d) =>
|
|
62
|
+
label ? (
|
|
63
|
+
<text
|
|
64
|
+
x={padX}
|
|
65
|
+
y={gridY + d * STEP + CELL_SIZE - 1}
|
|
66
|
+
className="t t-value"
|
|
67
|
+
>
|
|
68
|
+
{escapeXml(label)}
|
|
69
|
+
</text>
|
|
70
|
+
) : (
|
|
71
|
+
""
|
|
72
|
+
),
|
|
73
|
+
)}
|
|
74
|
+
{/* Cells */}
|
|
75
|
+
{weeks.map((week, w) =>
|
|
76
|
+
week.contributionDays.map((day, d) => (
|
|
77
|
+
<rect
|
|
78
|
+
x={gridX + w * STEP}
|
|
79
|
+
y={gridY + d * STEP}
|
|
80
|
+
width={CELL_SIZE}
|
|
81
|
+
height={CELL_SIZE}
|
|
82
|
+
rx="2"
|
|
83
|
+
fill={day.color || THEME.cardBg}
|
|
84
|
+
/>
|
|
85
|
+
)),
|
|
86
|
+
)}
|
|
87
|
+
</>
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const height = MONTH_LABEL_HEIGHT + 7 * STEP;
|
|
91
|
+
return { svg, height };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
void Fragment;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { Fragment, h } from "../jsx-factory.js";
|
|
3
|
+
import type { ContributionHighlight } from "../types.js";
|
|
4
|
+
import { renderContributionCards } from "./contribution-cards.js";
|
|
5
|
+
|
|
6
|
+
void Fragment;
|
|
7
|
+
|
|
8
|
+
describe("renderContributionCards", () => {
|
|
9
|
+
const highlights: ContributionHighlight[] = [
|
|
10
|
+
{ project: "org/repo-one", detail: "★ 500 · TypeScript" },
|
|
11
|
+
{ project: "org/repo-two", detail: "★ 200 · Go" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
it("returns { svg, height }", () => {
|
|
15
|
+
const result = renderContributionCards(highlights, 0);
|
|
16
|
+
expect(result).toHaveProperty("svg");
|
|
17
|
+
expect(result).toHaveProperty("height");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("height accounts for cards and gaps", () => {
|
|
21
|
+
const result = renderContributionCards(highlights, 0);
|
|
22
|
+
// 2 cards of 44px + 1 gap of 8px = 96
|
|
23
|
+
expect(result.height).toBe(2 * 44 + 1 * 8);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("svg contains project names", () => {
|
|
27
|
+
const result = renderContributionCards(highlights, 0);
|
|
28
|
+
expect(result.svg).toContain("org/repo-one");
|
|
29
|
+
expect(result.svg).toContain("org/repo-two");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("handles empty input", () => {
|
|
33
|
+
const result = renderContributionCards([], 0);
|
|
34
|
+
expect(result.height).toBe(0);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Fragment, h } from "../jsx-factory.js";
|
|
2
|
+
import { escapeXml, truncate } from "../svg-utils.js";
|
|
3
|
+
import { BAR_COLORS, LAYOUT, THEME } from "../theme.js";
|
|
4
|
+
import type { ContributionHighlight, RenderResult } from "../types.js";
|
|
5
|
+
|
|
6
|
+
export function renderContributionCards(
|
|
7
|
+
highlights: ContributionHighlight[],
|
|
8
|
+
y: number,
|
|
9
|
+
): RenderResult {
|
|
10
|
+
const { padX } = LAYOUT;
|
|
11
|
+
const cardW = 760;
|
|
12
|
+
const cardH = 44;
|
|
13
|
+
const gap = 8;
|
|
14
|
+
|
|
15
|
+
const svg = (
|
|
16
|
+
<>
|
|
17
|
+
{highlights.map((hl, i) => {
|
|
18
|
+
const cy = y + i * (cardH + gap);
|
|
19
|
+
const color = BAR_COLORS[i % BAR_COLORS.length];
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
<rect
|
|
24
|
+
x={padX}
|
|
25
|
+
y={cy}
|
|
26
|
+
width={cardW}
|
|
27
|
+
height={cardH}
|
|
28
|
+
rx="6"
|
|
29
|
+
fill={THEME.cardBg}
|
|
30
|
+
stroke={THEME.border}
|
|
31
|
+
stroke-width="1"
|
|
32
|
+
/>
|
|
33
|
+
<rect
|
|
34
|
+
x={padX}
|
|
35
|
+
y={cy}
|
|
36
|
+
width="4"
|
|
37
|
+
height={cardH}
|
|
38
|
+
rx="2"
|
|
39
|
+
fill={color}
|
|
40
|
+
/>
|
|
41
|
+
<text x={padX + 16} y={cy + 18} className="t t-card-title">
|
|
42
|
+
{escapeXml(truncate(hl.project, 40))}
|
|
43
|
+
</text>
|
|
44
|
+
<text x={padX + 16} y={cy + 34} className="t t-card-detail">
|
|
45
|
+
{escapeXml(truncate(hl.detail, 80))}
|
|
46
|
+
</text>
|
|
47
|
+
</>
|
|
48
|
+
);
|
|
49
|
+
})}
|
|
50
|
+
</>
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
svg,
|
|
55
|
+
height:
|
|
56
|
+
highlights.length * (cardH + gap) - (highlights.length > 0 ? gap : 0),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { Fragment, h } from "../jsx-factory.js";
|
|
3
|
+
import type { LanguageItem } from "../types.js";
|
|
4
|
+
import { renderDonutChart } from "./donut-chart.js";
|
|
5
|
+
|
|
6
|
+
void Fragment;
|
|
7
|
+
|
|
8
|
+
describe("renderDonutChart", () => {
|
|
9
|
+
const items: LanguageItem[] = [
|
|
10
|
+
{ name: "TypeScript", value: 60000, percent: "60.0", color: "#3178c6" },
|
|
11
|
+
{ name: "Python", value: 40000, percent: "40.0", color: "#3572A5" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
it("returns { svg, height }", () => {
|
|
15
|
+
const result = renderDonutChart(items, 0);
|
|
16
|
+
expect(result).toHaveProperty("svg");
|
|
17
|
+
expect(result).toHaveProperty("height");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("height is at least 180", () => {
|
|
21
|
+
const result = renderDonutChart(items, 0);
|
|
22
|
+
expect(result.height).toBeGreaterThanOrEqual(180);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("svg contains language names", () => {
|
|
26
|
+
const result = renderDonutChart(items, 0);
|
|
27
|
+
expect(result.svg).toContain("TypeScript");
|
|
28
|
+
expect(result.svg).toContain("Python");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("svg contains percentages", () => {
|
|
32
|
+
const result = renderDonutChart(items, 0);
|
|
33
|
+
expect(result.svg).toContain("60.0%");
|
|
34
|
+
expect(result.svg).toContain("40.0%");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Fragment, h } from "../jsx-factory.js";
|
|
2
|
+
import { escapeXml } from "../svg-utils.js";
|
|
3
|
+
import { BAR_COLORS, LAYOUT, THEME } from "../theme.js";
|
|
4
|
+
import type { LanguageItem, RenderResult } from "../types.js";
|
|
5
|
+
|
|
6
|
+
export function renderDonutChart(
|
|
7
|
+
items: LanguageItem[],
|
|
8
|
+
y: number,
|
|
9
|
+
): RenderResult {
|
|
10
|
+
const { padX } = LAYOUT;
|
|
11
|
+
const cx = padX + 90;
|
|
12
|
+
const cy = y + 90;
|
|
13
|
+
const r = 70;
|
|
14
|
+
const strokeW = 28;
|
|
15
|
+
const circumference = 2 * Math.PI * r;
|
|
16
|
+
|
|
17
|
+
let offset = 0;
|
|
18
|
+
const segments = items.map((item, i) => {
|
|
19
|
+
const pct = parseFloat(item.percent) / 100;
|
|
20
|
+
const dash = pct * circumference;
|
|
21
|
+
const color = item.color || BAR_COLORS[i % BAR_COLORS.length];
|
|
22
|
+
const seg = (
|
|
23
|
+
<circle
|
|
24
|
+
cx={cx}
|
|
25
|
+
cy={cy}
|
|
26
|
+
r={r}
|
|
27
|
+
fill="none"
|
|
28
|
+
stroke={color}
|
|
29
|
+
stroke-width={strokeW}
|
|
30
|
+
stroke-dasharray={`${dash} ${circumference - dash}`}
|
|
31
|
+
stroke-dashoffset={-offset}
|
|
32
|
+
transform={`rotate(-90 ${cx} ${cy})`}
|
|
33
|
+
opacity="0.85"
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
offset += dash;
|
|
37
|
+
return seg;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const legendX = padX + 220;
|
|
41
|
+
const legendItemH = 24;
|
|
42
|
+
const legend = items.map((item, i) => {
|
|
43
|
+
const ly = y + 10 + i * legendItemH;
|
|
44
|
+
const color = item.color || BAR_COLORS[i % BAR_COLORS.length];
|
|
45
|
+
return (
|
|
46
|
+
<>
|
|
47
|
+
<rect
|
|
48
|
+
x={legendX}
|
|
49
|
+
y={ly}
|
|
50
|
+
width="12"
|
|
51
|
+
height="12"
|
|
52
|
+
rx="2"
|
|
53
|
+
fill={color}
|
|
54
|
+
opacity="0.85"
|
|
55
|
+
/>
|
|
56
|
+
<text x={legendX + 20} y={ly + 10} className="t t-label">
|
|
57
|
+
{escapeXml(item.name)}
|
|
58
|
+
</text>
|
|
59
|
+
<text
|
|
60
|
+
x={legendX + 200}
|
|
61
|
+
y={ly + 10}
|
|
62
|
+
className="t t-value"
|
|
63
|
+
text-anchor="end"
|
|
64
|
+
>
|
|
65
|
+
{item.percent}%
|
|
66
|
+
</text>
|
|
67
|
+
</>
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const height = Math.max(180, items.length * legendItemH + 20);
|
|
72
|
+
|
|
73
|
+
const svg = (
|
|
74
|
+
<>
|
|
75
|
+
{segments.join("")}
|
|
76
|
+
<text
|
|
77
|
+
x={cx}
|
|
78
|
+
y={cy + 5}
|
|
79
|
+
className="t"
|
|
80
|
+
fill={THEME.text}
|
|
81
|
+
font-size="14"
|
|
82
|
+
font-weight="700"
|
|
83
|
+
text-anchor="middle"
|
|
84
|
+
>
|
|
85
|
+
{String(items.length)}
|
|
86
|
+
</text>
|
|
87
|
+
<text
|
|
88
|
+
x={cx}
|
|
89
|
+
y={cy + 20}
|
|
90
|
+
className="t"
|
|
91
|
+
fill={THEME.muted}
|
|
92
|
+
font-size="10"
|
|
93
|
+
text-anchor="middle"
|
|
94
|
+
>
|
|
95
|
+
languages
|
|
96
|
+
</text>
|
|
97
|
+
{legend.join("")}
|
|
98
|
+
</>
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
return { svg, height };
|
|
102
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { Fragment, h } from "../jsx-factory.js";
|
|
3
|
+
import type { SectionDef } from "../types.js";
|
|
4
|
+
import { generateFullSvg, wrapSectionSvg } from "./full-svg.js";
|
|
5
|
+
|
|
6
|
+
void Fragment;
|
|
7
|
+
|
|
8
|
+
describe("wrapSectionSvg", () => {
|
|
9
|
+
it("returns an svg string", () => {
|
|
10
|
+
const result = wrapSectionSvg("<text>hi</text>", 100);
|
|
11
|
+
expect(result).toContain("<svg");
|
|
12
|
+
expect(result).toContain("</svg>");
|
|
13
|
+
expect(result).toContain("<text>hi</text>");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("includes width and height", () => {
|
|
17
|
+
const result = wrapSectionSvg("", 200);
|
|
18
|
+
expect(result).toContain('height="200"');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("generateFullSvg", () => {
|
|
23
|
+
it("combines multiple sections", () => {
|
|
24
|
+
const sections: SectionDef[] = [
|
|
25
|
+
{
|
|
26
|
+
filename: "a.svg",
|
|
27
|
+
title: "Section A",
|
|
28
|
+
subtitle: "Subtitle A",
|
|
29
|
+
renderBody: (y) => ({
|
|
30
|
+
svg: `<text y="${y}">A body</text>`,
|
|
31
|
+
height: 50,
|
|
32
|
+
}),
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
filename: "b.svg",
|
|
36
|
+
title: "Section B",
|
|
37
|
+
subtitle: "Subtitle B",
|
|
38
|
+
items: [{ name: "Go", value: 10 }],
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
const result = generateFullSvg(sections);
|
|
42
|
+
expect(result).toContain("<svg");
|
|
43
|
+
expect(result).toContain("SECTION A");
|
|
44
|
+
expect(result).toContain("SECTION B");
|
|
45
|
+
expect(result).toContain("A body");
|
|
46
|
+
expect(result).toContain("Go");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns valid svg for empty sections", () => {
|
|
50
|
+
const result = generateFullSvg([]);
|
|
51
|
+
expect(result).toContain("<svg");
|
|
52
|
+
expect(result).toContain("</svg>");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Fragment, h } from "../jsx-factory.js";
|
|
2
|
+
import { LAYOUT, THEME } from "../theme.js";
|
|
3
|
+
import type { SectionDef } from "../types.js";
|
|
4
|
+
import { renderBarChart } from "./bar-chart.js";
|
|
5
|
+
import { renderSectionHeader } from "./section.js";
|
|
6
|
+
import { StyleDefs } from "./style-defs.js";
|
|
7
|
+
|
|
8
|
+
export function wrapSectionSvg(bodySvg: string, height: number): string {
|
|
9
|
+
const { width } = LAYOUT;
|
|
10
|
+
return (
|
|
11
|
+
<svg
|
|
12
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
13
|
+
width={width}
|
|
14
|
+
height={height}
|
|
15
|
+
viewBox={`0 0 ${width} ${height}`}
|
|
16
|
+
>
|
|
17
|
+
<StyleDefs />
|
|
18
|
+
<rect width={width} height={height} rx="12" fill={THEME.bg} />
|
|
19
|
+
{bodySvg}
|
|
20
|
+
</svg>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function generateFullSvg(sections: SectionDef[]): string {
|
|
25
|
+
const { width, padY, sectionGap } = LAYOUT;
|
|
26
|
+
let y = padY;
|
|
27
|
+
let bodySvg = "";
|
|
28
|
+
|
|
29
|
+
for (const section of sections) {
|
|
30
|
+
const header = renderSectionHeader(section.title, section.subtitle, y);
|
|
31
|
+
bodySvg += header.svg;
|
|
32
|
+
y += header.height;
|
|
33
|
+
|
|
34
|
+
if (section.renderBody) {
|
|
35
|
+
const body = section.renderBody(y);
|
|
36
|
+
bodySvg += body.svg;
|
|
37
|
+
y += body.height + sectionGap;
|
|
38
|
+
} else if (section.items) {
|
|
39
|
+
const bars = renderBarChart(section.items, y, section.options || {});
|
|
40
|
+
bodySvg += bars.svg;
|
|
41
|
+
y += bars.height + sectionGap;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const totalHeight = y + padY;
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<svg
|
|
49
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
50
|
+
width={width}
|
|
51
|
+
height={totalHeight}
|
|
52
|
+
viewBox={`0 0 ${width} ${totalHeight}`}
|
|
53
|
+
>
|
|
54
|
+
<StyleDefs />
|
|
55
|
+
<rect width={width} height={totalHeight} rx="12" fill={THEME.bg} />
|
|
56
|
+
{bodySvg}
|
|
57
|
+
</svg>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { Fragment, h } from "../jsx-factory.js";
|
|
3
|
+
import type { ProjectItem } from "../types.js";
|
|
4
|
+
import { renderProjectCards } from "./project-cards.js";
|
|
5
|
+
|
|
6
|
+
void Fragment;
|
|
7
|
+
|
|
8
|
+
describe("renderProjectCards", () => {
|
|
9
|
+
const projects: ProjectItem[] = [
|
|
10
|
+
{
|
|
11
|
+
name: "big-app",
|
|
12
|
+
url: "https://github.com/u/big-app",
|
|
13
|
+
description: "A large app",
|
|
14
|
+
stars: 120,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: "small-lib",
|
|
18
|
+
url: "https://github.com/u/small-lib",
|
|
19
|
+
description: "",
|
|
20
|
+
stars: 45,
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
it("returns { svg, height }", () => {
|
|
25
|
+
const result = renderProjectCards(projects, 0);
|
|
26
|
+
expect(result).toHaveProperty("svg");
|
|
27
|
+
expect(result).toHaveProperty("height");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("height is positive for non-empty input", () => {
|
|
31
|
+
const result = renderProjectCards(projects, 0);
|
|
32
|
+
expect(result.height).toBeGreaterThan(0);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("svg contains project names and star counts", () => {
|
|
36
|
+
const result = renderProjectCards(projects, 0);
|
|
37
|
+
expect(result.svg).toContain("big-app");
|
|
38
|
+
expect(result.svg).toContain("\u2605 120");
|
|
39
|
+
expect(result.svg).toContain("small-lib");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("handles empty input", () => {
|
|
43
|
+
const result = renderProjectCards([], 0);
|
|
44
|
+
expect(result.height).toBe(0);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Fragment, h } from "../jsx-factory.js";
|
|
2
|
+
import { escapeXml, truncate } from "../svg-utils.js";
|
|
3
|
+
import { BAR_COLORS, LAYOUT, THEME } from "../theme.js";
|
|
4
|
+
import type { ProjectItem, RenderResult } from "../types.js";
|
|
5
|
+
|
|
6
|
+
export function renderProjectCards(
|
|
7
|
+
projects: ProjectItem[],
|
|
8
|
+
y: number,
|
|
9
|
+
): RenderResult {
|
|
10
|
+
const { padX } = LAYOUT;
|
|
11
|
+
const cardW = 760;
|
|
12
|
+
const gap = 10;
|
|
13
|
+
let svg = "";
|
|
14
|
+
let totalHeight = 0;
|
|
15
|
+
|
|
16
|
+
for (let i = 0; i < projects.length; i++) {
|
|
17
|
+
const cy = y + totalHeight;
|
|
18
|
+
const color = BAR_COLORS[i % BAR_COLORS.length];
|
|
19
|
+
const p = projects[i];
|
|
20
|
+
const desc = truncate(p.description, 90);
|
|
21
|
+
|
|
22
|
+
let innerH = 20;
|
|
23
|
+
if (desc) innerH += 16;
|
|
24
|
+
const cardH = Math.max(innerH + 16, 44);
|
|
25
|
+
|
|
26
|
+
// Card background + accent bar
|
|
27
|
+
svg += (
|
|
28
|
+
<>
|
|
29
|
+
<rect
|
|
30
|
+
x={padX}
|
|
31
|
+
y={cy}
|
|
32
|
+
width={cardW}
|
|
33
|
+
height={cardH}
|
|
34
|
+
rx="6"
|
|
35
|
+
fill={THEME.cardBg}
|
|
36
|
+
stroke={THEME.border}
|
|
37
|
+
stroke-width="1"
|
|
38
|
+
/>
|
|
39
|
+
<rect x={padX} y={cy} width="4" height={cardH} rx="2" fill={color} />
|
|
40
|
+
<text x={padX + 16} y={cy + 18} className="t t-card-title">
|
|
41
|
+
{escapeXml(truncate(p.name, 40))}
|
|
42
|
+
</text>
|
|
43
|
+
<text
|
|
44
|
+
x={padX + cardW - 16}
|
|
45
|
+
y={cy + 18}
|
|
46
|
+
className="t t-value"
|
|
47
|
+
text-anchor="end"
|
|
48
|
+
>
|
|
49
|
+
{`\u2605 ${p.stars.toLocaleString()}`}
|
|
50
|
+
</text>
|
|
51
|
+
</>
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
if (desc) {
|
|
55
|
+
svg += (
|
|
56
|
+
<text x={padX + 16} y={cy + 34} className="t t-card-detail">
|
|
57
|
+
{escapeXml(desc)}
|
|
58
|
+
</text>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
totalHeight += cardH + gap;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { svg, height: totalHeight > 0 ? totalHeight - gap : 0 };
|
|
66
|
+
}
|