@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,69 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { Fragment, h } from "../jsx-factory.js";
|
|
3
|
+
import {
|
|
4
|
+
renderDivider,
|
|
5
|
+
renderSection,
|
|
6
|
+
renderSectionHeader,
|
|
7
|
+
renderSubHeader,
|
|
8
|
+
} from "./section.js";
|
|
9
|
+
|
|
10
|
+
void Fragment;
|
|
11
|
+
|
|
12
|
+
describe("renderSectionHeader", () => {
|
|
13
|
+
it("returns { svg, height }", () => {
|
|
14
|
+
const result = renderSectionHeader("Title", "Subtitle", 0);
|
|
15
|
+
expect(typeof result.svg).toBe("string");
|
|
16
|
+
expect(typeof result.height).toBe("number");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("height is 42 with subtitle", () => {
|
|
20
|
+
expect(renderSectionHeader("T", "S", 0).height).toBe(42);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("height is 24 without subtitle", () => {
|
|
24
|
+
expect(renderSectionHeader("T", undefined, 0).height).toBe(24);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("svg contains uppercased title", () => {
|
|
28
|
+
const result = renderSectionHeader("Languages", "By bytes", 0);
|
|
29
|
+
expect(result.svg).toContain("LANGUAGES");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("renderSubHeader", () => {
|
|
34
|
+
it("returns height 14", () => {
|
|
35
|
+
expect(renderSubHeader("WEB FRAMEWORKS", 0).height).toBe(14);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("svg contains text", () => {
|
|
39
|
+
expect(renderSubHeader("DATABASES", 0).svg).toContain("DATABASES");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("renderDivider", () => {
|
|
44
|
+
it("returns height 1", () => {
|
|
45
|
+
expect(renderDivider(100).height).toBe(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns a line element", () => {
|
|
49
|
+
expect(renderDivider(100).svg).toContain("<line");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("renderSection", () => {
|
|
54
|
+
it("returns { svg, height } with render function", () => {
|
|
55
|
+
const result = renderSection("Title", "Sub", (y) => ({
|
|
56
|
+
svg: `<text y="${y}">body</text>`,
|
|
57
|
+
height: 50,
|
|
58
|
+
}));
|
|
59
|
+
expect(result.svg).toContain("TITLE");
|
|
60
|
+
expect(result.svg).toContain("body");
|
|
61
|
+
expect(result.height).toBeGreaterThan(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns { svg, height } with items array", () => {
|
|
65
|
+
const items = [{ name: "Go", value: 10 }];
|
|
66
|
+
const result = renderSection("Tech", "Detected", items);
|
|
67
|
+
expect(result.svg).toContain("Go");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
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 { BarItem, RenderResult } from "../types.js";
|
|
5
|
+
import { renderBarChart } from "./bar-chart.js";
|
|
6
|
+
|
|
7
|
+
export function renderSectionHeader(
|
|
8
|
+
title: string,
|
|
9
|
+
subtitle: string | undefined,
|
|
10
|
+
y: number,
|
|
11
|
+
): RenderResult {
|
|
12
|
+
const svg = (
|
|
13
|
+
<>
|
|
14
|
+
<text x={LAYOUT.padX} y={y + 16} className="t t-h">
|
|
15
|
+
{escapeXml(title.toUpperCase())}
|
|
16
|
+
</text>
|
|
17
|
+
{subtitle ? (
|
|
18
|
+
<text x={LAYOUT.padX} y={y + 32} className="t t-sub">
|
|
19
|
+
{escapeXml(subtitle)}
|
|
20
|
+
</text>
|
|
21
|
+
) : (
|
|
22
|
+
""
|
|
23
|
+
)}
|
|
24
|
+
</>
|
|
25
|
+
);
|
|
26
|
+
return { svg, height: subtitle ? 42 : 24 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function renderSubHeader(text: string, y: number): RenderResult {
|
|
30
|
+
const svg = (
|
|
31
|
+
<text x={LAYOUT.padX} y={y + 11} className="t t-subhdr">
|
|
32
|
+
{escapeXml(text.toUpperCase())}
|
|
33
|
+
</text>
|
|
34
|
+
);
|
|
35
|
+
return { svg, height: 14 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function renderDivider(y: number): RenderResult {
|
|
39
|
+
const svg = (
|
|
40
|
+
<line
|
|
41
|
+
x1={LAYOUT.padX}
|
|
42
|
+
y1={y}
|
|
43
|
+
x2={LAYOUT.padX + 760}
|
|
44
|
+
y2={y}
|
|
45
|
+
stroke={THEME.border}
|
|
46
|
+
stroke-opacity="0.6"
|
|
47
|
+
stroke-width="1"
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
return { svg, height: 1 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function renderSection(
|
|
54
|
+
title: string,
|
|
55
|
+
subtitle: string,
|
|
56
|
+
itemsOrRenderBody: ((y: number) => RenderResult) | BarItem[],
|
|
57
|
+
options: Record<string, unknown> = {},
|
|
58
|
+
): RenderResult {
|
|
59
|
+
let y = LAYOUT.padY;
|
|
60
|
+
let svg = "";
|
|
61
|
+
|
|
62
|
+
const header = renderSectionHeader(title, subtitle, y);
|
|
63
|
+
svg += header.svg;
|
|
64
|
+
y += header.height;
|
|
65
|
+
|
|
66
|
+
if (typeof itemsOrRenderBody === "function") {
|
|
67
|
+
const body = itemsOrRenderBody(y);
|
|
68
|
+
svg += body.svg;
|
|
69
|
+
y += body.height + LAYOUT.padY;
|
|
70
|
+
} else {
|
|
71
|
+
const bars = renderBarChart(itemsOrRenderBody, y, options);
|
|
72
|
+
svg += bars.svg;
|
|
73
|
+
y += bars.height + LAYOUT.padY;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { svg, height: y };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
void Fragment;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { Fragment, h } from "../jsx-factory.js";
|
|
3
|
+
import type { StatItem } from "../types.js";
|
|
4
|
+
import { renderStatCards } from "./stat-cards.js";
|
|
5
|
+
|
|
6
|
+
void Fragment;
|
|
7
|
+
|
|
8
|
+
describe("renderStatCards", () => {
|
|
9
|
+
const stats: StatItem[] = [
|
|
10
|
+
{ label: "COMMITS", value: "500" },
|
|
11
|
+
{ label: "PRS", value: "42" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
it("returns { svg, height }", () => {
|
|
15
|
+
const result = renderStatCards(stats, 0);
|
|
16
|
+
expect(result).toHaveProperty("svg");
|
|
17
|
+
expect(result).toHaveProperty("height");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("height is card height (72)", () => {
|
|
21
|
+
const result = renderStatCards(stats, 0);
|
|
22
|
+
expect(result.height).toBe(72);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("svg contains labels and values", () => {
|
|
26
|
+
const result = renderStatCards(stats, 0);
|
|
27
|
+
expect(result.svg).toContain("COMMITS");
|
|
28
|
+
expect(result.svg).toContain("500");
|
|
29
|
+
expect(result.svg).toContain("PRS");
|
|
30
|
+
expect(result.svg).toContain("42");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
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 { RenderResult, StatItem } from "../types.js";
|
|
5
|
+
|
|
6
|
+
export function renderStatCards(stats: StatItem[], y: number): RenderResult {
|
|
7
|
+
const { padX } = LAYOUT;
|
|
8
|
+
const cardW = 140;
|
|
9
|
+
const cardH = 72;
|
|
10
|
+
const gap = 15;
|
|
11
|
+
const colors = [
|
|
12
|
+
BAR_COLORS[0],
|
|
13
|
+
BAR_COLORS[1],
|
|
14
|
+
BAR_COLORS[2],
|
|
15
|
+
BAR_COLORS[4],
|
|
16
|
+
BAR_COLORS[5],
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const svg = (
|
|
20
|
+
<>
|
|
21
|
+
{stats.map((stat, i) => {
|
|
22
|
+
const cx = padX + i * (cardW + gap);
|
|
23
|
+
const color = colors[i % colors.length];
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<>
|
|
27
|
+
<rect
|
|
28
|
+
x={cx}
|
|
29
|
+
y={y}
|
|
30
|
+
width={cardW}
|
|
31
|
+
height={cardH}
|
|
32
|
+
rx="8"
|
|
33
|
+
fill={THEME.cardBg}
|
|
34
|
+
stroke={THEME.border}
|
|
35
|
+
stroke-width="1"
|
|
36
|
+
/>
|
|
37
|
+
<circle cx={cx + 14} cy={y + 16} r="4" fill={color} />
|
|
38
|
+
<text x={cx + 24} y={y + 20} className="t t-stat-label">
|
|
39
|
+
{escapeXml(stat.label)}
|
|
40
|
+
</text>
|
|
41
|
+
<text
|
|
42
|
+
x={cx + cardW / 2}
|
|
43
|
+
y={y + 52}
|
|
44
|
+
fill={color}
|
|
45
|
+
className="t t-stat-value"
|
|
46
|
+
text-anchor="middle"
|
|
47
|
+
>
|
|
48
|
+
{escapeXml(String(stat.value))}
|
|
49
|
+
</text>
|
|
50
|
+
</>
|
|
51
|
+
);
|
|
52
|
+
})}
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return { svg, height: cardH };
|
|
57
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { Fragment, h } from "../jsx-factory.js";
|
|
3
|
+
import { StyleDefs } from "./style-defs.js";
|
|
4
|
+
|
|
5
|
+
void Fragment;
|
|
6
|
+
|
|
7
|
+
describe("StyleDefs", () => {
|
|
8
|
+
it("returns a string containing <defs> and <style>", () => {
|
|
9
|
+
const result = StyleDefs();
|
|
10
|
+
expect(result).toContain("<defs>");
|
|
11
|
+
expect(result).toContain("<style>");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("contains expected CSS classes", () => {
|
|
15
|
+
const result = StyleDefs();
|
|
16
|
+
expect(result).toContain(".t-h");
|
|
17
|
+
expect(result).toContain(".t-label");
|
|
18
|
+
expect(result).toContain(".t-value");
|
|
19
|
+
expect(result).toContain(".t-pill");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("contains font-family declaration", () => {
|
|
23
|
+
const result = StyleDefs();
|
|
24
|
+
expect(result).toContain("font-family:");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Fragment, h } from "../jsx-factory.js";
|
|
2
|
+
import { FONT, THEME } from "../theme.js";
|
|
3
|
+
|
|
4
|
+
export function StyleDefs(): string {
|
|
5
|
+
return (
|
|
6
|
+
<defs>
|
|
7
|
+
<style>
|
|
8
|
+
{`
|
|
9
|
+
.t { font-family: ${FONT}; }
|
|
10
|
+
.t-h { font-size: 13px; fill: ${THEME.text}; letter-spacing: 1.5px; font-weight: 600; }
|
|
11
|
+
.t-sub { font-size: 11px; fill: ${THEME.muted}; }
|
|
12
|
+
.t-label { font-size: 12px; fill: ${THEME.secondary}; }
|
|
13
|
+
.t-value { font-size: 11px; fill: ${THEME.muted}; }
|
|
14
|
+
.t-subhdr { font-size: 11px; fill: ${THEME.secondary}; letter-spacing: 1px; font-weight: 600; }
|
|
15
|
+
.t-stat-label { font-size: 10px; fill: ${THEME.secondary}; font-weight: 600; }
|
|
16
|
+
.t-stat-value { font-size: 22px; font-weight: 700; }
|
|
17
|
+
.t-card-title { font-size: 12px; fill: ${THEME.link}; font-weight: 700; }
|
|
18
|
+
.t-card-detail { font-size: 11px; fill: ${THEME.secondary}; }
|
|
19
|
+
.t-pill { font-size: 11px; font-weight: 600; }
|
|
20
|
+
.t-bullet { font-size: 12px; fill: ${THEME.text}; }
|
|
21
|
+
`}
|
|
22
|
+
</style>
|
|
23
|
+
</defs>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
void Fragment;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TechHighlight } from "../types.js";
|
|
3
|
+
import { renderTechHighlights } from "./tech-highlights.js";
|
|
4
|
+
|
|
5
|
+
describe("renderTechHighlights", () => {
|
|
6
|
+
it("renders category names in uppercase", () => {
|
|
7
|
+
const highlights: TechHighlight[] = [
|
|
8
|
+
{ category: "Frontend", items: ["React", "Vue"], score: 85 },
|
|
9
|
+
{ category: "Backend", items: ["Express", "Django"], score: 70 },
|
|
10
|
+
];
|
|
11
|
+
const { svg } = renderTechHighlights(highlights, 0);
|
|
12
|
+
expect(svg).toContain("FRONTEND");
|
|
13
|
+
expect(svg).toContain("BACKEND");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("renders skill items joined with middle dot", () => {
|
|
17
|
+
const highlights: TechHighlight[] = [
|
|
18
|
+
{
|
|
19
|
+
category: "Languages",
|
|
20
|
+
items: ["TypeScript", "Python", "Go"],
|
|
21
|
+
score: 90,
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
const { svg } = renderTechHighlights(highlights, 0);
|
|
25
|
+
expect(svg).toContain("TypeScript");
|
|
26
|
+
expect(svg).toContain("Python");
|
|
27
|
+
expect(svg).toContain("Go");
|
|
28
|
+
expect(svg).toContain("\u00B7");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("renders score percentages", () => {
|
|
32
|
+
const highlights: TechHighlight[] = [
|
|
33
|
+
{ category: "Web Dev", items: ["React", "Astro"], score: 85 },
|
|
34
|
+
{ category: "DevOps", items: ["Docker"], score: 60 },
|
|
35
|
+
];
|
|
36
|
+
const { svg } = renderTechHighlights(highlights, 0);
|
|
37
|
+
expect(svg).toContain("85%");
|
|
38
|
+
expect(svg).toContain("60%");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns height > 0 for non-empty input", () => {
|
|
42
|
+
const highlights: TechHighlight[] = [
|
|
43
|
+
{ category: "Tools", items: ["Docker", "Kubernetes"], score: 75 },
|
|
44
|
+
];
|
|
45
|
+
const { height } = renderTechHighlights(highlights, 0);
|
|
46
|
+
expect(height).toBeGreaterThan(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns empty result for empty input", () => {
|
|
50
|
+
const result = renderTechHighlights([], 0);
|
|
51
|
+
expect(result).toEqual({ svg: "", height: 0 });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("clamps out-of-range scores", () => {
|
|
55
|
+
const highlights: TechHighlight[] = [
|
|
56
|
+
{ category: "Over", items: ["A"], score: 150 },
|
|
57
|
+
{ category: "Under", items: ["B"], score: -10 },
|
|
58
|
+
];
|
|
59
|
+
const { svg } = renderTechHighlights(highlights, 0);
|
|
60
|
+
expect(svg).toContain("100%");
|
|
61
|
+
expect(svg).toContain("0%");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Fragment, h } from "../jsx-factory.js";
|
|
2
|
+
import { escapeXml, truncate, wrapText } from "../svg-utils.js";
|
|
3
|
+
import { BAR_COLORS, LAYOUT } from "../theme.js";
|
|
4
|
+
import type { RenderResult, TechHighlight } from "../types.js";
|
|
5
|
+
|
|
6
|
+
export function renderTechHighlights(
|
|
7
|
+
highlights: TechHighlight[],
|
|
8
|
+
y: number,
|
|
9
|
+
): RenderResult {
|
|
10
|
+
if (highlights.length === 0) return { svg: "", height: 0 };
|
|
11
|
+
|
|
12
|
+
const { padX, barHeight, barMaxWidth } = LAYOUT;
|
|
13
|
+
const labelMaxChars = 60;
|
|
14
|
+
const skillMaxChars = 120;
|
|
15
|
+
const skillLineHeight = 16;
|
|
16
|
+
const labelLineHeight = 26;
|
|
17
|
+
const scoreX = padX + barMaxWidth + 10;
|
|
18
|
+
const skillY = 16;
|
|
19
|
+
const rowGap = 14;
|
|
20
|
+
|
|
21
|
+
let svg = "";
|
|
22
|
+
let height = 0;
|
|
23
|
+
|
|
24
|
+
for (let hi = 0; hi < highlights.length; hi++) {
|
|
25
|
+
const group = highlights[hi];
|
|
26
|
+
const color = BAR_COLORS[hi % BAR_COLORS.length];
|
|
27
|
+
const score = Math.max(0, Math.min(100, group.score));
|
|
28
|
+
const fillWidth = (score / 100) * barMaxWidth;
|
|
29
|
+
|
|
30
|
+
const baseY = y + height;
|
|
31
|
+
|
|
32
|
+
// Category label (uppercase, left-aligned, on its own line)
|
|
33
|
+
svg += (
|
|
34
|
+
<text x={padX} y={baseY + 14} className="t t-subhdr">
|
|
35
|
+
{escapeXml(truncate(group.category.toUpperCase(), labelMaxChars))}
|
|
36
|
+
</text>
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// Bar track (full width, low opacity)
|
|
40
|
+
svg += (
|
|
41
|
+
<rect
|
|
42
|
+
x={padX}
|
|
43
|
+
y={baseY + labelLineHeight}
|
|
44
|
+
width={barMaxWidth}
|
|
45
|
+
height={barHeight}
|
|
46
|
+
rx={4}
|
|
47
|
+
fill={color}
|
|
48
|
+
fill-opacity="0.15"
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// Bar fill (proportional to score)
|
|
53
|
+
if (fillWidth > 0) {
|
|
54
|
+
svg += (
|
|
55
|
+
<rect
|
|
56
|
+
x={padX}
|
|
57
|
+
y={baseY + labelLineHeight}
|
|
58
|
+
width={fillWidth}
|
|
59
|
+
height={barHeight}
|
|
60
|
+
rx={4}
|
|
61
|
+
fill={color}
|
|
62
|
+
fill-opacity="0.85"
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Score label (right of bar)
|
|
68
|
+
svg += (
|
|
69
|
+
<text
|
|
70
|
+
x={scoreX}
|
|
71
|
+
y={baseY + labelLineHeight + barHeight / 2 + 4}
|
|
72
|
+
className="t t-value"
|
|
73
|
+
>
|
|
74
|
+
{`${score}%`}
|
|
75
|
+
</text>
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Skill items text (below bar, muted, wrapped to avoid overflow)
|
|
79
|
+
const skillText = group.items
|
|
80
|
+
.map((item) => truncate(item, 30))
|
|
81
|
+
.join(" \u00B7 ");
|
|
82
|
+
|
|
83
|
+
const skillLines = wrapText(skillText, skillMaxChars);
|
|
84
|
+
for (let li = 0; li < skillLines.length; li++) {
|
|
85
|
+
svg += (
|
|
86
|
+
<text
|
|
87
|
+
x={padX}
|
|
88
|
+
y={
|
|
89
|
+
baseY + labelLineHeight + barHeight + skillY + li * skillLineHeight
|
|
90
|
+
}
|
|
91
|
+
className="t t-card-detail"
|
|
92
|
+
>
|
|
93
|
+
{escapeXml(skillLines[li])}
|
|
94
|
+
</text>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
height +=
|
|
99
|
+
labelLineHeight +
|
|
100
|
+
barHeight +
|
|
101
|
+
skillY +
|
|
102
|
+
(skillLines.length - 1) * skillLineHeight +
|
|
103
|
+
rowGap;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { svg, height };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
void Fragment;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseUserConfig } from "./config.js";
|
|
3
|
+
|
|
4
|
+
describe("parseUserConfig", () => {
|
|
5
|
+
it("parses both fields", () => {
|
|
6
|
+
const raw = `title: "Senior Backend Engineer"\ndesired_title: "Staff Engineer"`;
|
|
7
|
+
expect(parseUserConfig(raw)).toEqual({
|
|
8
|
+
title: "Senior Backend Engineer",
|
|
9
|
+
desired_title: "Staff Engineer",
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("parses title only", () => {
|
|
14
|
+
const raw = `title: "Software Engineer"`;
|
|
15
|
+
expect(parseUserConfig(raw)).toEqual({ title: "Software Engineer" });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns empty config for empty string", () => {
|
|
19
|
+
expect(parseUserConfig("")).toEqual({});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("trims whitespace-only values", () => {
|
|
23
|
+
const raw = `title: " "\ndesired_title: "Staff Engineer"`;
|
|
24
|
+
expect(parseUserConfig(raw)).toEqual({ desired_title: "Staff Engineer" });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("trims surrounding whitespace from values", () => {
|
|
28
|
+
const raw = `title: " Senior Engineer "`;
|
|
29
|
+
expect(parseUserConfig(raw)).toEqual({ title: "Senior Engineer" });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("ignores unknown fields", () => {
|
|
33
|
+
const raw = `title: "SWE"\nfoo: "bar"\nbaz: 42`;
|
|
34
|
+
expect(parseUserConfig(raw)).toEqual({ title: "SWE" });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("throws on invalid YAML", () => {
|
|
38
|
+
expect(() => parseUserConfig("title: [invalid")).toThrow();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("parses name", () => {
|
|
42
|
+
const raw = `name: "Urmzd Maharramoff"`;
|
|
43
|
+
expect(parseUserConfig(raw)).toEqual({ name: "Urmzd Maharramoff" });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("parses pronunciation", () => {
|
|
47
|
+
const raw = `pronunciation: "/ˈʊrm.zəd/"`;
|
|
48
|
+
expect(parseUserConfig(raw)).toEqual({ pronunciation: "/ˈʊrm.zəd/" });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("parses bio", () => {
|
|
52
|
+
const raw = `bio: "Building tools for developers"`;
|
|
53
|
+
expect(parseUserConfig(raw)).toEqual({
|
|
54
|
+
bio: "Building tools for developers",
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("parses preamble", () => {
|
|
59
|
+
const raw = `preamble: "PREAMBLE.md"`;
|
|
60
|
+
expect(parseUserConfig(raw)).toEqual({ preamble: "PREAMBLE.md" });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("skips whitespace-only name", () => {
|
|
64
|
+
const raw = `name: " "`;
|
|
65
|
+
expect(parseUserConfig(raw)).toEqual({});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("parses template field", () => {
|
|
69
|
+
const raw = `template: "modern"`;
|
|
70
|
+
expect(parseUserConfig(raw)).toEqual({ template: "modern" });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("normalizes template to lowercase", () => {
|
|
74
|
+
const raw = `template: "Modern"`;
|
|
75
|
+
expect(parseUserConfig(raw)).toEqual({ template: "modern" });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("ignores unknown template values", () => {
|
|
79
|
+
const raw = `template: "fancy"`;
|
|
80
|
+
expect(parseUserConfig(raw)).toEqual({});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("parses sections array", () => {
|
|
84
|
+
const raw = `sections:\n - pulse\n - languages`;
|
|
85
|
+
expect(parseUserConfig(raw)).toEqual({
|
|
86
|
+
sections: ["pulse", "languages"],
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("filters out non-string sections entries", () => {
|
|
91
|
+
const raw = `sections:\n - pulse\n - 42\n - languages`;
|
|
92
|
+
expect(parseUserConfig(raw)).toEqual({
|
|
93
|
+
sections: ["pulse", "languages"],
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("ignores empty sections array", () => {
|
|
98
|
+
const raw = `sections: []`;
|
|
99
|
+
expect(parseUserConfig(raw)).toEqual({});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("parses all fields together", () => {
|
|
103
|
+
const raw = [
|
|
104
|
+
`name: "Urmzd Maharramoff"`,
|
|
105
|
+
`pronunciation: "/ˈʊrm.zəd/"`,
|
|
106
|
+
`title: "Senior Backend Engineer"`,
|
|
107
|
+
`desired_title: "Staff Engineer"`,
|
|
108
|
+
`bio: "Building tools for developers"`,
|
|
109
|
+
`preamble: "PREAMBLE.md"`,
|
|
110
|
+
].join("\n");
|
|
111
|
+
expect(parseUserConfig(raw)).toEqual({
|
|
112
|
+
name: "Urmzd Maharramoff",
|
|
113
|
+
pronunciation: "/ˈʊrm.zəd/",
|
|
114
|
+
title: "Senior Backend Engineer",
|
|
115
|
+
desired_title: "Staff Engineer",
|
|
116
|
+
bio: "Building tools for developers",
|
|
117
|
+
preamble: "PREAMBLE.md",
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("parses TOML format when specified", () => {
|
|
122
|
+
const raw = `title = "Senior Backend Engineer"`;
|
|
123
|
+
expect(parseUserConfig(raw, "toml")).toEqual({
|
|
124
|
+
title: "Senior Backend Engineer",
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import * as toml from "smol-toml";
|
|
3
|
+
import * as yaml from "yaml";
|
|
4
|
+
import type { TemplateName, UserConfig } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const VALID_TEMPLATES = new Set<string>(["classic", "modern", "minimal"]);
|
|
7
|
+
|
|
8
|
+
function extractConfig(parsed: Record<string, unknown>): UserConfig {
|
|
9
|
+
const config: UserConfig = {};
|
|
10
|
+
|
|
11
|
+
if (typeof parsed.title === "string" && parsed.title.trim()) {
|
|
12
|
+
config.title = parsed.title.trim();
|
|
13
|
+
}
|
|
14
|
+
if (typeof parsed.desired_title === "string" && parsed.desired_title.trim()) {
|
|
15
|
+
config.desired_title = parsed.desired_title.trim();
|
|
16
|
+
}
|
|
17
|
+
if (typeof parsed.name === "string" && parsed.name.trim()) {
|
|
18
|
+
config.name = parsed.name.trim();
|
|
19
|
+
}
|
|
20
|
+
if (
|
|
21
|
+
typeof parsed.pronunciation === "string" &&
|
|
22
|
+
parsed.pronunciation.trim()
|
|
23
|
+
) {
|
|
24
|
+
config.pronunciation = parsed.pronunciation.trim();
|
|
25
|
+
}
|
|
26
|
+
if (typeof parsed.bio === "string" && parsed.bio.trim()) {
|
|
27
|
+
config.bio = parsed.bio.trim();
|
|
28
|
+
}
|
|
29
|
+
if (typeof parsed.preamble === "string" && parsed.preamble.trim()) {
|
|
30
|
+
config.preamble = parsed.preamble.trim();
|
|
31
|
+
}
|
|
32
|
+
if (typeof parsed.template === "string" && parsed.template.trim()) {
|
|
33
|
+
const t = parsed.template.trim().toLowerCase();
|
|
34
|
+
if (VALID_TEMPLATES.has(t)) {
|
|
35
|
+
config.template = t as TemplateName;
|
|
36
|
+
} else {
|
|
37
|
+
console.warn(
|
|
38
|
+
`Unknown template "${t}", falling back to "classic". Valid: ${[...VALID_TEMPLATES].join(", ")}`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (Array.isArray(parsed.sections)) {
|
|
43
|
+
const sections = parsed.sections
|
|
44
|
+
.filter((s): s is string => typeof s === "string" && s.trim().length > 0)
|
|
45
|
+
.map((s) => s.trim().toLowerCase());
|
|
46
|
+
if (sections.length > 0) {
|
|
47
|
+
config.sections = sections;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return config;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseUserConfig(raw: string, format: "yaml" | "toml" = "yaml"): UserConfig {
|
|
55
|
+
const parsed =
|
|
56
|
+
format === "toml"
|
|
57
|
+
? toml.parse(raw)
|
|
58
|
+
: (yaml.parse(raw) as Record<string, unknown>) ?? {};
|
|
59
|
+
return extractConfig(parsed);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function loadUserConfig(configPath?: string): UserConfig {
|
|
63
|
+
const resolved = configPath
|
|
64
|
+
? { path: configPath, format: detectFormat(configPath) }
|
|
65
|
+
: resolveConfigPath();
|
|
66
|
+
try {
|
|
67
|
+
const raw = readFileSync(resolved.path, "utf-8");
|
|
68
|
+
return parseUserConfig(raw, resolved.format);
|
|
69
|
+
} catch (err: unknown) {
|
|
70
|
+
if (
|
|
71
|
+
err instanceof Error &&
|
|
72
|
+
"code" in err &&
|
|
73
|
+
(err as NodeJS.ErrnoException).code === "ENOENT"
|
|
74
|
+
) {
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
78
|
+
console.warn(
|
|
79
|
+
`Warning: failed to parse config file "${resolved.path}": ${msg}`,
|
|
80
|
+
);
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function detectFormat(path: string): "yaml" | "toml" {
|
|
86
|
+
return path.endsWith(".toml") ? "toml" : "yaml";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveConfigPath(): { path: string; format: "yaml" | "toml" } {
|
|
90
|
+
if (existsSync("github-insights.yml")) {
|
|
91
|
+
return { path: "github-insights.yml", format: "yaml" };
|
|
92
|
+
}
|
|
93
|
+
if (existsSync("github-insights.yaml")) {
|
|
94
|
+
return { path: "github-insights.yaml", format: "yaml" };
|
|
95
|
+
}
|
|
96
|
+
if (existsSync(".github-metrics.toml")) {
|
|
97
|
+
console.warn(
|
|
98
|
+
'Warning: ".github-metrics.toml" is deprecated. Please rename it to "github-insights.yml".',
|
|
99
|
+
);
|
|
100
|
+
return { path: ".github-metrics.toml", format: "toml" };
|
|
101
|
+
}
|
|
102
|
+
return { path: "github-insights.yml", format: "yaml" };
|
|
103
|
+
}
|