Skip to content

Commit 4b2bc12

Browse files
authored
feat(projects): add pre-build stats fetching (#68)
1 parent ca5642c commit 4b2bc12

File tree

10 files changed

+568
-257
lines changed

10 files changed

+568
-257
lines changed

.github/workflows/scheduled-deploy.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ jobs:
3333
run: npm ci
3434

3535
- name: Build site
36-
run: npm run build
36+
run: npm run build:with-stats
37+
env:
38+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3739

3840
- name: Deploy to Cloudflare
3941
uses: cloudflare/wrangler-action@v3

package-lock.json

Lines changed: 18 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
"scripts": {
66
"dev": "node scripts/copy-originals.js && astro dev",
77
"build": "node scripts/copy-originals.js && astro build",
8+
"build:with-stats": "node scripts/fetch-project-stats.js && node scripts/copy-originals.js && astro build",
89
"preview": "astro preview",
910
"astro": "astro",
1011
"new": "node scripts/new-post.js",
1112
"new:project": "node scripts/new-project.js",
1213
"cover": "node scripts/generate-cover.js",
1314
"cover:project": "node scripts/generate-project-cover.js",
14-
"copy-originals": "node scripts/copy-originals.js"
15+
"copy-originals": "node scripts/copy-originals.js",
16+
"fetch:stats": "node scripts/fetch-project-stats.js"
1517
},
1618
"dependencies": {
1719
"@astrojs/rss": "^4.0.14",
@@ -26,7 +28,8 @@
2628
"sanitize-html": "^2.17.0",
2729
"satori": "^0.19.1",
2830
"sharp": "^0.34.5",
29-
"tailwindcss": "^4.1.18"
31+
"tailwindcss": "^4.1.18",
32+
"yaml": "^2.8.2"
3033
},
3134
"devDependencies": {
3235
"@types/markdown-it": "^14.1.2",

scripts/fetch-project-stats.js

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
/**
2+
* Pre-build script to fetch all project stats from GitHub, VS Marketplace, and NuGet
3+
* Run this before the Astro build to bake stats into the static site
4+
*
5+
* Stats fetched by category:
6+
* - nuget-package: GitHub + NuGet
7+
* - vs-extension: GitHub + VS Marketplace
8+
* - vscode-extension: GitHub + VS Marketplace
9+
* - cli-tool: GitHub only
10+
* - desktop-app: GitHub only
11+
* - documentation: GitHub only
12+
* - github-action: GitHub only (marketplace stats could be added later)
13+
*/
14+
15+
import { readdir, readFile, writeFile, mkdir } from "fs/promises";
16+
import { existsSync } from "fs";
17+
import { join } from "path";
18+
import { parse } from "yaml";
19+
20+
const PROJECTS_DIR = "src/content/projects";
21+
const STATS_OUTPUT = "src/data/project-stats.json";
22+
23+
/**
24+
* Parse frontmatter from markdown file
25+
*/
26+
function parseFrontmatter(content) {
27+
const match = content.match(/^---\n([\s\S]*?)\n---/);
28+
if (!match) return null;
29+
return parse(match[1]);
30+
}
31+
32+
/**
33+
* Extract owner/repo from GitHub URL
34+
*/
35+
function parseGitHubUrl(repoUrl) {
36+
const match = repoUrl.match(/github\.com\/([^\/]+)\/([^\/]+)/);
37+
if (!match) return null;
38+
return { owner: match[1], repo: match[2].replace(/\.git$/, "") };
39+
}
40+
41+
/**
42+
* Fetch GitHub repository stats
43+
*/
44+
async function fetchGitHubStats(repoUrl) {
45+
try {
46+
const parsed = parseGitHubUrl(repoUrl);
47+
if (!parsed) return null;
48+
49+
const { owner, repo } = parsed;
50+
const token = process.env.GITHUB_TOKEN;
51+
52+
const headers = {
53+
Accept: "application/vnd.github.v3+json",
54+
"User-Agent": "codingwithcalvin.net",
55+
};
56+
if (token) {
57+
headers["Authorization"] = `Bearer ${token}`;
58+
}
59+
60+
// Fetch repo info and latest release in parallel
61+
const [repoResponse, releaseResponse] = await Promise.all([
62+
fetch(`https://api.github.com/repos/${owner}/${repo}`, { headers }),
63+
fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`, {
64+
headers,
65+
}),
66+
]);
67+
68+
if (!repoResponse.ok) {
69+
console.warn(` GitHub API error for ${repoUrl}: ${repoResponse.status}`);
70+
return null;
71+
}
72+
73+
const repoData = await repoResponse.json();
74+
75+
let latestRelease = null;
76+
if (releaseResponse.ok) {
77+
const releaseData = await releaseResponse.json();
78+
latestRelease = {
79+
version: releaseData.tag_name,
80+
publishedAt: releaseData.published_at,
81+
url: releaseData.html_url,
82+
};
83+
}
84+
85+
return {
86+
stars: repoData.stargazers_count,
87+
forks: repoData.forks_count,
88+
openIssues: repoData.open_issues_count,
89+
latestRelease,
90+
};
91+
} catch (error) {
92+
console.warn(` Failed to fetch GitHub stats for ${repoUrl}:`, error.message);
93+
return null;
94+
}
95+
}
96+
97+
/**
98+
* Fetch VS Marketplace extension stats
99+
*/
100+
async function fetchVSMarketplaceStats(extensionId) {
101+
try {
102+
const response = await fetch(
103+
"https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery",
104+
{
105+
method: "POST",
106+
headers: {
107+
"Content-Type": "application/json",
108+
Accept: "application/json;api-version=7.1-preview.1",
109+
},
110+
body: JSON.stringify({
111+
filters: [
112+
{
113+
criteria: [{ filterType: 7, value: extensionId }],
114+
},
115+
],
116+
flags: 914, // Include statistics
117+
}),
118+
}
119+
);
120+
121+
if (!response.ok) {
122+
console.warn(` VS Marketplace API error for ${extensionId}: ${response.status}`);
123+
return null;
124+
}
125+
126+
const data = await response.json();
127+
const ext = data?.results?.[0]?.extensions?.[0];
128+
129+
if (!ext) {
130+
console.warn(` Extension not found: ${extensionId}`);
131+
return null;
132+
}
133+
134+
const stats = ext.statistics || [];
135+
const getStatValue = (name) => {
136+
const stat = stats.find((s) => s.statisticName === name);
137+
return stat?.value;
138+
};
139+
140+
return {
141+
installs: getStatValue("install"),
142+
downloads: getStatValue("downloadCount"),
143+
rating: getStatValue("averagerating"),
144+
ratingCount: getStatValue("ratingcount"),
145+
lastUpdated: ext.lastUpdated,
146+
};
147+
} catch (error) {
148+
console.warn(` Failed to fetch VS Marketplace stats for ${extensionId}:`, error.message);
149+
return null;
150+
}
151+
}
152+
153+
/**
154+
* Fetch NuGet package stats
155+
*/
156+
async function fetchNuGetStats(packageId) {
157+
try {
158+
const searchUrl = `https://azuresearch-usnc.nuget.org/query?q=packageid:${packageId}&prerelease=true&take=1`;
159+
const response = await fetch(searchUrl);
160+
161+
if (!response.ok) {
162+
console.warn(` NuGet API error for ${packageId}: ${response.status}`);
163+
return null;
164+
}
165+
166+
const data = await response.json();
167+
const pkg = data?.data?.[0];
168+
169+
if (!pkg) {
170+
console.warn(` NuGet package not found: ${packageId}`);
171+
return null;
172+
}
173+
174+
return {
175+
downloads: pkg.totalDownloads,
176+
latestVersion: pkg.versions?.[pkg.versions.length - 1]?.version,
177+
};
178+
} catch (error) {
179+
console.warn(` Failed to fetch NuGet stats for ${packageId}:`, error.message);
180+
return null;
181+
}
182+
}
183+
184+
/**
185+
* Extract extension/package ID from marketplace URL
186+
*/
187+
function extractMarketplaceId(url, type) {
188+
if (type === "vs-marketplace") {
189+
const match = url.match(/itemName=([^&]+)/);
190+
return match?.[1] || null;
191+
} else if (type === "nuget") {
192+
const match = url.match(/packages\/([^\/]+)/);
193+
return match?.[1] || null;
194+
}
195+
return null;
196+
}
197+
198+
/**
199+
* Determine what stats to fetch based on project category
200+
*/
201+
function getStatsToFetch(category) {
202+
switch (category) {
203+
case "nuget-package":
204+
return { github: true, marketplace: "nuget" };
205+
case "vs-extension":
206+
case "vscode-extension":
207+
return { github: true, marketplace: "vs-marketplace" };
208+
case "cli-tool":
209+
case "desktop-app":
210+
case "documentation":
211+
case "github-action":
212+
default:
213+
return { github: true, marketplace: null };
214+
}
215+
}
216+
217+
async function main() {
218+
console.log("\nFetching project stats...\n");
219+
220+
const token = process.env.GITHUB_TOKEN;
221+
if (token) {
222+
console.log("Using GITHUB_TOKEN for API requests\n");
223+
} else {
224+
console.warn("Warning: GITHUB_TOKEN not set, may hit rate limits\n");
225+
}
226+
227+
// Read all project directories
228+
const projectDirs = await readdir(PROJECTS_DIR);
229+
const stats = {};
230+
231+
for (const dir of projectDirs) {
232+
const indexPath = join(PROJECTS_DIR, dir, "index.md");
233+
if (!existsSync(indexPath)) continue;
234+
235+
console.log(`Processing: ${dir}`);
236+
237+
const content = await readFile(indexPath, "utf-8");
238+
const frontmatter = parseFrontmatter(content);
239+
if (!frontmatter) {
240+
console.warn(` Could not parse frontmatter`);
241+
continue;
242+
}
243+
244+
const projectStats = {
245+
slug: dir,
246+
github: null,
247+
marketplace: null,
248+
};
249+
250+
const category = frontmatter.category;
251+
const statsConfig = getStatsToFetch(category);
252+
253+
console.log(` Category: ${category} → GitHub: ${statsConfig.github}, Marketplace: ${statsConfig.marketplace || "none"}`);
254+
255+
// Fetch GitHub stats (all projects)
256+
if (statsConfig.github && frontmatter.repoUrl) {
257+
console.log(` Fetching GitHub stats...`);
258+
projectStats.github = await fetchGitHubStats(frontmatter.repoUrl);
259+
if (projectStats.github) {
260+
console.log(` Stars: ${projectStats.github.stars}, Forks: ${projectStats.github.forks}`);
261+
}
262+
}
263+
264+
// Fetch marketplace stats based on category
265+
if (statsConfig.marketplace && frontmatter.marketplace?.url) {
266+
const { url, type } = frontmatter.marketplace;
267+
const id = extractMarketplaceId(url, type);
268+
269+
if (id) {
270+
if (statsConfig.marketplace === "nuget" && type === "nuget") {
271+
console.log(` Fetching NuGet stats for ${id}...`);
272+
projectStats.marketplace = await fetchNuGetStats(id);
273+
} else if (statsConfig.marketplace === "vs-marketplace" && type === "vs-marketplace") {
274+
console.log(` Fetching VS Marketplace stats for ${id}...`);
275+
projectStats.marketplace = await fetchVSMarketplaceStats(id);
276+
}
277+
278+
if (projectStats.marketplace) {
279+
const downloads = projectStats.marketplace.downloads ?? projectStats.marketplace.installs;
280+
if (downloads) {
281+
console.log(` Downloads/Installs: ${downloads}`);
282+
}
283+
if (projectStats.marketplace.rating) {
284+
console.log(` Rating: ${projectStats.marketplace.rating.toFixed(1)}`);
285+
}
286+
}
287+
}
288+
}
289+
290+
stats[dir] = projectStats;
291+
}
292+
293+
// Ensure output directory exists
294+
const outputDir = "src/data";
295+
if (!existsSync(outputDir)) {
296+
await mkdir(outputDir, { recursive: true });
297+
}
298+
299+
// Write stats to JSON file
300+
const output = {
301+
generatedAt: new Date().toISOString(),
302+
projects: stats,
303+
};
304+
305+
await writeFile(STATS_OUTPUT, JSON.stringify(output, null, 2));
306+
307+
console.log(`\nStats written to ${STATS_OUTPUT}`);
308+
console.log(`Total projects processed: ${Object.keys(stats).length}`);
309+
}
310+
311+
main().catch((err) => {
312+
console.error("Error:", err);
313+
process.exit(1);
314+
});

src/content/projects/otel4vsix/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ startDate: "2025-12-23"
1111
stars: 0
1212
marketplace:
1313
type: "nuget"
14-
url: "https://www.nuget.org/packages/Otel4Vsix"
14+
url: "https://www.nuget.org/packages/CodingWithCalvin.Otel4Vsix"
1515
---
1616

1717

0 commit comments

Comments
 (0)