@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
package/src/parsers.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import * as toml from "smol-toml";
|
|
2
|
+
import type { PackageParser } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export const NodePackageParser: PackageParser = {
|
|
5
|
+
filenames: ["package.json"],
|
|
6
|
+
parseDependencies(text) {
|
|
7
|
+
try {
|
|
8
|
+
const pkg = JSON.parse(text);
|
|
9
|
+
return [
|
|
10
|
+
...Object.keys(pkg.dependencies || {}),
|
|
11
|
+
...Object.keys(pkg.devDependencies || {}),
|
|
12
|
+
];
|
|
13
|
+
} catch {
|
|
14
|
+
console.warn("Warning: failed to parse package.json");
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const CargoParser: PackageParser = {
|
|
21
|
+
filenames: ["Cargo.toml"],
|
|
22
|
+
parseDependencies(text) {
|
|
23
|
+
try {
|
|
24
|
+
const parsed = toml.parse(text);
|
|
25
|
+
const tables: string[] = [
|
|
26
|
+
"dependencies",
|
|
27
|
+
"dev-dependencies",
|
|
28
|
+
"build-dependencies",
|
|
29
|
+
];
|
|
30
|
+
const deps: string[] = [];
|
|
31
|
+
for (const table of tables) {
|
|
32
|
+
const section = parsed[table];
|
|
33
|
+
if (section && typeof section === "object") {
|
|
34
|
+
deps.push(...Object.keys(section as Record<string, unknown>));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return deps;
|
|
38
|
+
} catch {
|
|
39
|
+
console.warn("Warning: failed to parse Cargo.toml");
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const GoModParser: PackageParser = {
|
|
46
|
+
filenames: ["go.mod"],
|
|
47
|
+
parseDependencies(text) {
|
|
48
|
+
try {
|
|
49
|
+
const deps: string[] = [];
|
|
50
|
+
let inRequire = false;
|
|
51
|
+
for (const line of text.split("\n")) {
|
|
52
|
+
const trimmed = line.trim();
|
|
53
|
+
if (trimmed.startsWith("require (")) {
|
|
54
|
+
inRequire = true;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (trimmed === ")") {
|
|
58
|
+
inRequire = false;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (inRequire && trimmed && !trimmed.startsWith("//")) {
|
|
62
|
+
const modulePath = trimmed.split(/\s/)[0];
|
|
63
|
+
const segments = modulePath.split("/");
|
|
64
|
+
deps.push(segments[segments.length - 1]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return deps;
|
|
68
|
+
} catch {
|
|
69
|
+
console.warn("Warning: failed to parse go.mod");
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const PyprojectParser: PackageParser = {
|
|
76
|
+
filenames: ["pyproject.toml"],
|
|
77
|
+
parseDependencies(text) {
|
|
78
|
+
try {
|
|
79
|
+
const parsed = toml.parse(text);
|
|
80
|
+
const deps: string[] = [];
|
|
81
|
+
|
|
82
|
+
// PEP 621: project.dependencies array
|
|
83
|
+
const project = parsed.project as { dependencies?: string[] } | undefined;
|
|
84
|
+
if (project?.dependencies) {
|
|
85
|
+
for (const raw of project.dependencies) {
|
|
86
|
+
const name = raw.split(/[>=<!~;\s[]/)[0].trim();
|
|
87
|
+
if (name) deps.push(name);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Poetry: tool.poetry.dependencies table
|
|
92
|
+
const tool = parsed.tool as
|
|
93
|
+
| { poetry?: { dependencies?: Record<string, unknown> } }
|
|
94
|
+
| undefined;
|
|
95
|
+
const poetryDeps = tool?.poetry?.dependencies;
|
|
96
|
+
if (poetryDeps) {
|
|
97
|
+
for (const name of Object.keys(poetryDeps)) {
|
|
98
|
+
if (name !== "python") deps.push(name);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return deps;
|
|
103
|
+
} catch {
|
|
104
|
+
console.warn("Warning: failed to parse pyproject.toml");
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const RequirementsTxtParser: PackageParser = {
|
|
111
|
+
filenames: ["requirements.txt"],
|
|
112
|
+
parseDependencies(text) {
|
|
113
|
+
try {
|
|
114
|
+
return text
|
|
115
|
+
.split("\n")
|
|
116
|
+
.map((line) => line.trim())
|
|
117
|
+
.filter(
|
|
118
|
+
(line) => line && !line.startsWith("#") && !line.startsWith("-"),
|
|
119
|
+
)
|
|
120
|
+
.map((line) => line.split(/[>=<!~;\s[]/)[0].trim())
|
|
121
|
+
.filter(Boolean);
|
|
122
|
+
} catch {
|
|
123
|
+
console.warn("Warning: failed to parse requirements.txt");
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export const PARSERS: PackageParser[] = [
|
|
130
|
+
NodePackageParser,
|
|
131
|
+
CargoParser,
|
|
132
|
+
GoModParser,
|
|
133
|
+
PyprojectParser,
|
|
134
|
+
RequirementsTxtParser,
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
// Build lookup from filenames → parser (derived from PARSERS, not manually maintained)
|
|
138
|
+
const PARSER_MAP = new Map<string, PackageParser>();
|
|
139
|
+
for (const parser of PARSERS) {
|
|
140
|
+
for (const filename of parser.filenames) {
|
|
141
|
+
PARSER_MAP.set(filename, parser);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const parseManifest = (filename: string, text: string): string[] =>
|
|
146
|
+
PARSER_MAP.get(filename)?.parseDependencies(text) ?? [];
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { generateReadme } from "./readme.js";
|
|
3
|
+
|
|
4
|
+
describe("generateReadme", () => {
|
|
5
|
+
it("renders minimal readme (name + single SVG)", () => {
|
|
6
|
+
const result = generateReadme({
|
|
7
|
+
name: "octocat",
|
|
8
|
+
svgs: [{ label: "GitHub Metrics", path: "metrics/index.svg" }],
|
|
9
|
+
});
|
|
10
|
+
expect(result).toContain(
|
|
11
|
+
"# octocat\n\n",
|
|
12
|
+
);
|
|
13
|
+
expect(result).toMatch(
|
|
14
|
+
/Last generated on \d{4}-\d{2}-\d{2} using \[@urmzd\/github-insights\]/,
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("includes pronunciation when set", () => {
|
|
19
|
+
const result = generateReadme({
|
|
20
|
+
name: "Urmzd",
|
|
21
|
+
pronunciation: "/ˈʊrm.zəd/",
|
|
22
|
+
svgs: [{ label: "GitHub Metrics", path: "metrics/index.svg" }],
|
|
23
|
+
});
|
|
24
|
+
expect(result).toContain("# Urmzd <sub><i>(/ˈʊrm.zəd/)</i></sub>");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("omits pronunciation when not set", () => {
|
|
28
|
+
const result = generateReadme({
|
|
29
|
+
name: "Urmzd",
|
|
30
|
+
svgs: [{ label: "GitHub Metrics", path: "metrics/index.svg" }],
|
|
31
|
+
});
|
|
32
|
+
expect(result).not.toContain("<sub><i>");
|
|
33
|
+
expect(result).toContain("# Urmzd\n");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("includes title as blockquote when set", () => {
|
|
37
|
+
const result = generateReadme({
|
|
38
|
+
name: "Urmzd",
|
|
39
|
+
title: "Senior Backend Engineer",
|
|
40
|
+
svgs: [{ label: "GitHub Metrics", path: "metrics/index.svg" }],
|
|
41
|
+
});
|
|
42
|
+
expect(result).toContain("> Senior Backend Engineer");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("omits title when not set", () => {
|
|
46
|
+
const result = generateReadme({
|
|
47
|
+
name: "Urmzd",
|
|
48
|
+
svgs: [{ label: "GitHub Metrics", path: "metrics/index.svg" }],
|
|
49
|
+
});
|
|
50
|
+
expect(result).not.toContain("> ");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("includes preamble content when set", () => {
|
|
54
|
+
const result = generateReadme({
|
|
55
|
+
name: "Urmzd",
|
|
56
|
+
preamble: "Hello, I build things.",
|
|
57
|
+
svgs: [{ label: "GitHub Metrics", path: "metrics/index.svg" }],
|
|
58
|
+
});
|
|
59
|
+
expect(result).toContain("Hello, I build things.");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("omits preamble when not set", () => {
|
|
63
|
+
const result = generateReadme({
|
|
64
|
+
name: "Urmzd",
|
|
65
|
+
svgs: [{ label: "GitHub Metrics", path: "metrics/index.svg" }],
|
|
66
|
+
});
|
|
67
|
+
// Only heading + SVG + attribution + trailing newline
|
|
68
|
+
expect(result).not.toContain("Hello");
|
|
69
|
+
expect(result).toContain("# Urmzd");
|
|
70
|
+
expect(result).toContain("");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("preamble is not wrapped in code fences", () => {
|
|
74
|
+
const result = generateReadme({
|
|
75
|
+
name: "Urmzd",
|
|
76
|
+
preamble: "Hello, I build things.",
|
|
77
|
+
svgs: [{ label: "GitHub Metrics", path: "metrics/index.svg" }],
|
|
78
|
+
});
|
|
79
|
+
expect(result).toContain("Hello, I build things.");
|
|
80
|
+
expect(result).not.toContain("```");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("multi-paragraph preamble preserves structure", () => {
|
|
84
|
+
const preamble =
|
|
85
|
+
"First paragraph.\n\nSecond paragraph.\n\nThird paragraph.";
|
|
86
|
+
const result = generateReadme({
|
|
87
|
+
name: "Urmzd",
|
|
88
|
+
preamble: preamble,
|
|
89
|
+
svgs: [{ label: "GitHub Metrics", path: "metrics/index.svg" }],
|
|
90
|
+
});
|
|
91
|
+
expect(result).toContain(preamble);
|
|
92
|
+
expect(result).toContain(`# Urmzd\n\n${preamble}\n\n![GitHub Metrics]`);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("preamble with markdown features inserted raw", () => {
|
|
96
|
+
const preamble =
|
|
97
|
+
"I am **bold** and have [](https://example.com) and a [link](https://example.com).";
|
|
98
|
+
const result = generateReadme({
|
|
99
|
+
name: "Urmzd",
|
|
100
|
+
preamble: preamble,
|
|
101
|
+
svgs: [{ label: "GitHub Metrics", path: "metrics/index.svg" }],
|
|
102
|
+
});
|
|
103
|
+
expect(result).toContain("**bold**");
|
|
104
|
+
expect(result).toContain(
|
|
105
|
+
"[](https://example.com)",
|
|
106
|
+
);
|
|
107
|
+
expect(result).toContain("[link](https://example.com)");
|
|
108
|
+
expect(result).not.toContain("```");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("preamble separated from adjacent sections by blank lines", () => {
|
|
112
|
+
const result = generateReadme({
|
|
113
|
+
name: "Urmzd",
|
|
114
|
+
pronunciation: "/ˈʊrm.zəd/",
|
|
115
|
+
title: "Senior Backend Engineer",
|
|
116
|
+
preamble: "Welcome to my profile!",
|
|
117
|
+
svgs: [{ label: "GitHub Metrics", path: "metrics/index.svg" }],
|
|
118
|
+
bio: "Building tools for developers",
|
|
119
|
+
});
|
|
120
|
+
expect(result).toContain(
|
|
121
|
+
"> Senior Backend Engineer\n\nWelcome to my profile!",
|
|
122
|
+
);
|
|
123
|
+
expect(result).toContain(
|
|
124
|
+
"Welcome to my profile!\n\n",
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("includes bio footer when set", () => {
|
|
129
|
+
const result = generateReadme({
|
|
130
|
+
name: "Urmzd",
|
|
131
|
+
bio: "Building tools for developers",
|
|
132
|
+
svgs: [{ label: "GitHub Metrics", path: "metrics/index.svg" }],
|
|
133
|
+
});
|
|
134
|
+
expect(result).toContain("---\n\n<sub>Building tools for developers</sub>");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("omits bio when not set", () => {
|
|
138
|
+
const result = generateReadme({
|
|
139
|
+
name: "Urmzd",
|
|
140
|
+
svgs: [{ label: "GitHub Metrics", path: "metrics/index.svg" }],
|
|
141
|
+
});
|
|
142
|
+
expect(result).not.toContain("---");
|
|
143
|
+
// Should only have the attribution <sub>, not a bio <sub>
|
|
144
|
+
const subMatches = result.match(/<sub>/g) || [];
|
|
145
|
+
expect(subMatches).toHaveLength(1);
|
|
146
|
+
expect(result).toContain("Last generated on");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("renders multiple SVGs individually", () => {
|
|
150
|
+
const result = generateReadme({
|
|
151
|
+
name: "Urmzd",
|
|
152
|
+
svgs: [
|
|
153
|
+
{ label: "Languages", path: "metrics/metrics-languages.svg" },
|
|
154
|
+
{ label: "Projects", path: "metrics/metrics-projects.svg" },
|
|
155
|
+
{ label: "Expertise", path: "metrics/metrics-expertise.svg" },
|
|
156
|
+
],
|
|
157
|
+
});
|
|
158
|
+
expect(result).toContain("");
|
|
159
|
+
expect(result).toContain("");
|
|
160
|
+
expect(result).toContain("");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("renders all sections combined", () => {
|
|
164
|
+
const result = generateReadme({
|
|
165
|
+
name: "Urmzd Maharramoff",
|
|
166
|
+
pronunciation: "/ˈʊrm.zəd/",
|
|
167
|
+
title: "Senior Backend Engineer",
|
|
168
|
+
preamble: "Welcome to my profile!",
|
|
169
|
+
svgs: [{ label: "GitHub Metrics", path: "metrics/index.svg" }],
|
|
170
|
+
bio: "Building tools for developers",
|
|
171
|
+
});
|
|
172
|
+
expect(result).toContain(
|
|
173
|
+
"# Urmzd Maharramoff <sub><i>(/ˈʊrm.zəd/)</i></sub>",
|
|
174
|
+
);
|
|
175
|
+
expect(result).toContain("> Senior Backend Engineer");
|
|
176
|
+
expect(result).toContain("Welcome to my profile!");
|
|
177
|
+
expect(result).toContain("");
|
|
178
|
+
expect(result).toContain("---\n\n<sub>Building tools for developers</sub>");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("ends with a trailing newline", () => {
|
|
182
|
+
const result = generateReadme({
|
|
183
|
+
name: "octocat",
|
|
184
|
+
svgs: [{ label: "GitHub Metrics", path: "metrics/index.svg" }],
|
|
185
|
+
});
|
|
186
|
+
expect(result.endsWith("\n")).toBe(true);
|
|
187
|
+
expect(result.endsWith("\n\n")).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
});
|
package/src/readme.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import type { SvgEmbed } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export type { SvgEmbed };
|
|
5
|
+
|
|
6
|
+
export interface ReadmeOptions {
|
|
7
|
+
name: string;
|
|
8
|
+
pronunciation?: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
preamble?: string;
|
|
11
|
+
svgs: SvgEmbed[];
|
|
12
|
+
bio?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function generateReadme(options: ReadmeOptions): string {
|
|
16
|
+
const parts: string[] = [];
|
|
17
|
+
|
|
18
|
+
// Heading
|
|
19
|
+
if (options.pronunciation) {
|
|
20
|
+
parts.push(
|
|
21
|
+
`# ${options.name} <sub><i>(${options.pronunciation})</i></sub>`,
|
|
22
|
+
);
|
|
23
|
+
} else {
|
|
24
|
+
parts.push(`# ${options.name}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Title blockquote
|
|
28
|
+
if (options.title) {
|
|
29
|
+
parts.push(`> ${options.title}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Preamble
|
|
33
|
+
if (options.preamble) {
|
|
34
|
+
parts.push(options.preamble);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// SVG embeds
|
|
38
|
+
for (const svg of options.svgs) {
|
|
39
|
+
parts.push(``);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Bio footer
|
|
43
|
+
if (options.bio) {
|
|
44
|
+
parts.push(`---\n\n<sub>${options.bio}</sub>`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Attribution
|
|
48
|
+
const now = new Date().toISOString().split("T")[0];
|
|
49
|
+
parts.push(
|
|
50
|
+
`<sub>Last generated on ${now} using [@urmzd/github-insights](https://github.com/urmzd/github-insights)</sub>`,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return `${parts.join("\n\n")}\n`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function loadPreamble(path?: string): string | undefined {
|
|
57
|
+
const filePath = path || "PREAMBLE.md";
|
|
58
|
+
try {
|
|
59
|
+
return readFileSync(filePath, "utf-8");
|
|
60
|
+
} catch (err: unknown) {
|
|
61
|
+
if (
|
|
62
|
+
err instanceof Error &&
|
|
63
|
+
"code" in err &&
|
|
64
|
+
(err as NodeJS.ErrnoException).code === "ENOENT"
|
|
65
|
+
) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { escapeXml, truncate, wrapText } from "./svg-utils.js";
|
|
3
|
+
|
|
4
|
+
describe("escapeXml", () => {
|
|
5
|
+
it("escapes & < > \" '", () => {
|
|
6
|
+
expect(escapeXml('Tom & Jerry <"hello"> it\'s')).toBe(
|
|
7
|
+
"Tom & Jerry <"hello"> it's",
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('returns "" for null', () => {
|
|
12
|
+
expect(escapeXml(null)).toBe("");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns "" for undefined', () => {
|
|
16
|
+
expect(escapeXml(undefined)).toBe("");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('returns "" for empty string', () => {
|
|
20
|
+
expect(escapeXml("")).toBe("");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns plain string unchanged", () => {
|
|
24
|
+
expect(escapeXml("hello world")).toBe("hello world");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("truncate", () => {
|
|
29
|
+
it("returns short string untouched", () => {
|
|
30
|
+
expect(truncate("hello", 10)).toBe("hello");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("truncates long string with ellipsis", () => {
|
|
34
|
+
expect(truncate("abcdefghij", 5)).toBe("abcd\u2026");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns "" for null', () => {
|
|
38
|
+
expect(truncate(null, 10)).toBe("");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns "" for undefined', () => {
|
|
42
|
+
expect(truncate(undefined, 10)).toBe("");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns exact-length string untouched", () => {
|
|
46
|
+
expect(truncate("abcde", 5)).toBe("abcde");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("wrapText", () => {
|
|
51
|
+
it("returns single line when text fits", () => {
|
|
52
|
+
expect(wrapText("short text", 30)).toEqual(["short text"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("wraps at word boundary", () => {
|
|
56
|
+
const lines = wrapText("hello world foo bar", 12);
|
|
57
|
+
expect(lines.length).toBeGreaterThan(1);
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
expect(line.length).toBeLessThanOrEqual(12);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns [] for empty string", () => {
|
|
64
|
+
expect(wrapText("", 20)).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
});
|
package/src/svg-utils.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export const escapeXml = (str: string | null | undefined): string => {
|
|
2
|
+
if (!str) return "";
|
|
3
|
+
return str
|
|
4
|
+
.replace(/&/g, "&")
|
|
5
|
+
.replace(/</g, "<")
|
|
6
|
+
.replace(/>/g, ">")
|
|
7
|
+
.replace(/"/g, """)
|
|
8
|
+
.replace(/'/g, "'");
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const truncate = (
|
|
12
|
+
str: string | null | undefined,
|
|
13
|
+
max: number,
|
|
14
|
+
): string => {
|
|
15
|
+
if (!str) return "";
|
|
16
|
+
return str.length > max ? `${str.slice(0, max - 1)}\u2026` : str;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const wrapText = (text: string, maxChars: number): string[] => {
|
|
20
|
+
const words = text.split(/\s+/);
|
|
21
|
+
const lines: string[] = [];
|
|
22
|
+
let current = "";
|
|
23
|
+
for (const word of words) {
|
|
24
|
+
if (current && `${current} ${word}`.length > maxChars) {
|
|
25
|
+
lines.push(current);
|
|
26
|
+
current = word;
|
|
27
|
+
} else {
|
|
28
|
+
current = current ? `${current} ${word}` : word;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (current) lines.push(current);
|
|
32
|
+
return lines;
|
|
33
|
+
};
|