diff --git a/.github/workflows/scheduled-deploy.yml b/.github/workflows/scheduled-deploy.yml
index 5ad8535..1b9de6d 100644
--- a/.github/workflows/scheduled-deploy.yml
+++ b/.github/workflows/scheduled-deploy.yml
@@ -33,7 +33,9 @@ jobs:
run: npm ci
- name: Build site
- run: npm run build
+ run: npm run build:with-stats
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to Cloudflare
uses: cloudflare/wrangler-action@v3
diff --git a/package-lock.json b/package-lock.json
index 2fd0a36..3675f29 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,7 +20,8 @@
"sanitize-html": "^2.17.0",
"satori": "^0.19.1",
"sharp": "^0.34.5",
- "tailwindcss": "^4.1.18"
+ "tailwindcss": "^4.1.18",
+ "yaml": "^2.8.2"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
@@ -7510,6 +7511,22 @@
"integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==",
"license": "MIT"
},
+ "node_modules/yaml": {
+ "version": "2.8.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
+ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
+ "license": "ISC",
+ "peer": true,
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
diff --git a/package.json b/package.json
index c96610f..c6f1011 100644
--- a/package.json
+++ b/package.json
@@ -5,13 +5,15 @@
"scripts": {
"dev": "node scripts/copy-originals.js && astro dev",
"build": "node scripts/copy-originals.js && astro build",
+ "build:with-stats": "node scripts/fetch-project-stats.js && node scripts/copy-originals.js && astro build",
"preview": "astro preview",
"astro": "astro",
"new": "node scripts/new-post.js",
"new:project": "node scripts/new-project.js",
"cover": "node scripts/generate-cover.js",
"cover:project": "node scripts/generate-project-cover.js",
- "copy-originals": "node scripts/copy-originals.js"
+ "copy-originals": "node scripts/copy-originals.js",
+ "fetch:stats": "node scripts/fetch-project-stats.js"
},
"dependencies": {
"@astrojs/rss": "^4.0.14",
@@ -26,7 +28,8 @@
"sanitize-html": "^2.17.0",
"satori": "^0.19.1",
"sharp": "^0.34.5",
- "tailwindcss": "^4.1.18"
+ "tailwindcss": "^4.1.18",
+ "yaml": "^2.8.2"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
diff --git a/scripts/fetch-project-stats.js b/scripts/fetch-project-stats.js
new file mode 100644
index 0000000..01a0405
--- /dev/null
+++ b/scripts/fetch-project-stats.js
@@ -0,0 +1,314 @@
+/**
+ * Pre-build script to fetch all project stats from GitHub, VS Marketplace, and NuGet
+ * Run this before the Astro build to bake stats into the static site
+ *
+ * Stats fetched by category:
+ * - nuget-package: GitHub + NuGet
+ * - vs-extension: GitHub + VS Marketplace
+ * - vscode-extension: GitHub + VS Marketplace
+ * - cli-tool: GitHub only
+ * - desktop-app: GitHub only
+ * - documentation: GitHub only
+ * - github-action: GitHub only (marketplace stats could be added later)
+ */
+
+import { readdir, readFile, writeFile, mkdir } from "fs/promises";
+import { existsSync } from "fs";
+import { join } from "path";
+import { parse } from "yaml";
+
+const PROJECTS_DIR = "src/content/projects";
+const STATS_OUTPUT = "src/data/project-stats.json";
+
+/**
+ * Parse frontmatter from markdown file
+ */
+function parseFrontmatter(content) {
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
+ if (!match) return null;
+ return parse(match[1]);
+}
+
+/**
+ * Extract owner/repo from GitHub URL
+ */
+function parseGitHubUrl(repoUrl) {
+ const match = repoUrl.match(/github\.com\/([^\/]+)\/([^\/]+)/);
+ if (!match) return null;
+ return { owner: match[1], repo: match[2].replace(/\.git$/, "") };
+}
+
+/**
+ * Fetch GitHub repository stats
+ */
+async function fetchGitHubStats(repoUrl) {
+ try {
+ const parsed = parseGitHubUrl(repoUrl);
+ if (!parsed) return null;
+
+ const { owner, repo } = parsed;
+ const token = process.env.GITHUB_TOKEN;
+
+ const headers = {
+ Accept: "application/vnd.github.v3+json",
+ "User-Agent": "codingwithcalvin.net",
+ };
+ if (token) {
+ headers["Authorization"] = `Bearer ${token}`;
+ }
+
+ // Fetch repo info and latest release in parallel
+ const [repoResponse, releaseResponse] = await Promise.all([
+ fetch(`https://api.github.com/repos/${owner}/${repo}`, { headers }),
+ fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`, {
+ headers,
+ }),
+ ]);
+
+ if (!repoResponse.ok) {
+ console.warn(` GitHub API error for ${repoUrl}: ${repoResponse.status}`);
+ return null;
+ }
+
+ const repoData = await repoResponse.json();
+
+ let latestRelease = null;
+ if (releaseResponse.ok) {
+ const releaseData = await releaseResponse.json();
+ latestRelease = {
+ version: releaseData.tag_name,
+ publishedAt: releaseData.published_at,
+ url: releaseData.html_url,
+ };
+ }
+
+ return {
+ stars: repoData.stargazers_count,
+ forks: repoData.forks_count,
+ openIssues: repoData.open_issues_count,
+ latestRelease,
+ };
+ } catch (error) {
+ console.warn(` Failed to fetch GitHub stats for ${repoUrl}:`, error.message);
+ return null;
+ }
+}
+
+/**
+ * Fetch VS Marketplace extension stats
+ */
+async function fetchVSMarketplaceStats(extensionId) {
+ try {
+ const response = await fetch(
+ "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json;api-version=7.1-preview.1",
+ },
+ body: JSON.stringify({
+ filters: [
+ {
+ criteria: [{ filterType: 7, value: extensionId }],
+ },
+ ],
+ flags: 914, // Include statistics
+ }),
+ }
+ );
+
+ if (!response.ok) {
+ console.warn(` VS Marketplace API error for ${extensionId}: ${response.status}`);
+ return null;
+ }
+
+ const data = await response.json();
+ const ext = data?.results?.[0]?.extensions?.[0];
+
+ if (!ext) {
+ console.warn(` Extension not found: ${extensionId}`);
+ return null;
+ }
+
+ const stats = ext.statistics || [];
+ const getStatValue = (name) => {
+ const stat = stats.find((s) => s.statisticName === name);
+ return stat?.value;
+ };
+
+ return {
+ installs: getStatValue("install"),
+ downloads: getStatValue("downloadCount"),
+ rating: getStatValue("averagerating"),
+ ratingCount: getStatValue("ratingcount"),
+ lastUpdated: ext.lastUpdated,
+ };
+ } catch (error) {
+ console.warn(` Failed to fetch VS Marketplace stats for ${extensionId}:`, error.message);
+ return null;
+ }
+}
+
+/**
+ * Fetch NuGet package stats
+ */
+async function fetchNuGetStats(packageId) {
+ try {
+ const searchUrl = `https://azuresearch-usnc.nuget.org/query?q=packageid:${packageId}&prerelease=true&take=1`;
+ const response = await fetch(searchUrl);
+
+ if (!response.ok) {
+ console.warn(` NuGet API error for ${packageId}: ${response.status}`);
+ return null;
+ }
+
+ const data = await response.json();
+ const pkg = data?.data?.[0];
+
+ if (!pkg) {
+ console.warn(` NuGet package not found: ${packageId}`);
+ return null;
+ }
+
+ return {
+ downloads: pkg.totalDownloads,
+ latestVersion: pkg.versions?.[pkg.versions.length - 1]?.version,
+ };
+ } catch (error) {
+ console.warn(` Failed to fetch NuGet stats for ${packageId}:`, error.message);
+ return null;
+ }
+}
+
+/**
+ * Extract extension/package ID from marketplace URL
+ */
+function extractMarketplaceId(url, type) {
+ if (type === "vs-marketplace") {
+ const match = url.match(/itemName=([^&]+)/);
+ return match?.[1] || null;
+ } else if (type === "nuget") {
+ const match = url.match(/packages\/([^\/]+)/);
+ return match?.[1] || null;
+ }
+ return null;
+}
+
+/**
+ * Determine what stats to fetch based on project category
+ */
+function getStatsToFetch(category) {
+ switch (category) {
+ case "nuget-package":
+ return { github: true, marketplace: "nuget" };
+ case "vs-extension":
+ case "vscode-extension":
+ return { github: true, marketplace: "vs-marketplace" };
+ case "cli-tool":
+ case "desktop-app":
+ case "documentation":
+ case "github-action":
+ default:
+ return { github: true, marketplace: null };
+ }
+}
+
+async function main() {
+ console.log("\nFetching project stats...\n");
+
+ const token = process.env.GITHUB_TOKEN;
+ if (token) {
+ console.log("Using GITHUB_TOKEN for API requests\n");
+ } else {
+ console.warn("Warning: GITHUB_TOKEN not set, may hit rate limits\n");
+ }
+
+ // Read all project directories
+ const projectDirs = await readdir(PROJECTS_DIR);
+ const stats = {};
+
+ for (const dir of projectDirs) {
+ const indexPath = join(PROJECTS_DIR, dir, "index.md");
+ if (!existsSync(indexPath)) continue;
+
+ console.log(`Processing: ${dir}`);
+
+ const content = await readFile(indexPath, "utf-8");
+ const frontmatter = parseFrontmatter(content);
+ if (!frontmatter) {
+ console.warn(` Could not parse frontmatter`);
+ continue;
+ }
+
+ const projectStats = {
+ slug: dir,
+ github: null,
+ marketplace: null,
+ };
+
+ const category = frontmatter.category;
+ const statsConfig = getStatsToFetch(category);
+
+ console.log(` Category: ${category} → GitHub: ${statsConfig.github}, Marketplace: ${statsConfig.marketplace || "none"}`);
+
+ // Fetch GitHub stats (all projects)
+ if (statsConfig.github && frontmatter.repoUrl) {
+ console.log(` Fetching GitHub stats...`);
+ projectStats.github = await fetchGitHubStats(frontmatter.repoUrl);
+ if (projectStats.github) {
+ console.log(` Stars: ${projectStats.github.stars}, Forks: ${projectStats.github.forks}`);
+ }
+ }
+
+ // Fetch marketplace stats based on category
+ if (statsConfig.marketplace && frontmatter.marketplace?.url) {
+ const { url, type } = frontmatter.marketplace;
+ const id = extractMarketplaceId(url, type);
+
+ if (id) {
+ if (statsConfig.marketplace === "nuget" && type === "nuget") {
+ console.log(` Fetching NuGet stats for ${id}...`);
+ projectStats.marketplace = await fetchNuGetStats(id);
+ } else if (statsConfig.marketplace === "vs-marketplace" && type === "vs-marketplace") {
+ console.log(` Fetching VS Marketplace stats for ${id}...`);
+ projectStats.marketplace = await fetchVSMarketplaceStats(id);
+ }
+
+ if (projectStats.marketplace) {
+ const downloads = projectStats.marketplace.downloads ?? projectStats.marketplace.installs;
+ if (downloads) {
+ console.log(` Downloads/Installs: ${downloads}`);
+ }
+ if (projectStats.marketplace.rating) {
+ console.log(` Rating: ${projectStats.marketplace.rating.toFixed(1)}`);
+ }
+ }
+ }
+ }
+
+ stats[dir] = projectStats;
+ }
+
+ // Ensure output directory exists
+ const outputDir = "src/data";
+ if (!existsSync(outputDir)) {
+ await mkdir(outputDir, { recursive: true });
+ }
+
+ // Write stats to JSON file
+ const output = {
+ generatedAt: new Date().toISOString(),
+ projects: stats,
+ };
+
+ await writeFile(STATS_OUTPUT, JSON.stringify(output, null, 2));
+
+ console.log(`\nStats written to ${STATS_OUTPUT}`);
+ console.log(`Total projects processed: ${Object.keys(stats).length}`);
+}
+
+main().catch((err) => {
+ console.error("Error:", err);
+ process.exit(1);
+});
diff --git a/src/content/projects/otel4vsix/index.md b/src/content/projects/otel4vsix/index.md
index a44c139..eb8acef 100644
--- a/src/content/projects/otel4vsix/index.md
+++ b/src/content/projects/otel4vsix/index.md
@@ -11,7 +11,7 @@ startDate: "2025-12-23"
stars: 0
marketplace:
type: "nuget"
- url: "https://www.nuget.org/packages/Otel4Vsix"
+ url: "https://www.nuget.org/packages/CodingWithCalvin.Otel4Vsix"
---
diff --git a/src/content/projects/vsixsdk/index.md b/src/content/projects/vsixsdk/index.md
index 06c9f4d..27d3e47 100644
--- a/src/content/projects/vsixsdk/index.md
+++ b/src/content/projects/vsixsdk/index.md
@@ -11,7 +11,7 @@ startDate: "2025-12-24"
stars: 21
marketplace:
type: "nuget"
- url: "https://www.nuget.org/packages/VsixSdk"
+ url: "https://www.nuget.org/packages/CodingWithCalvin.VsixSdk"
---
diff --git a/src/data/project-stats.json b/src/data/project-stats.json
new file mode 100644
index 0000000..252d9df
--- /dev/null
+++ b/src/data/project-stats.json
@@ -0,0 +1,151 @@
+{
+ "generatedAt": "2026-01-31T16:25:57.846Z",
+ "projects": {
+ "dtvem-cli": {
+ "slug": "dtvem-cli",
+ "github": null,
+ "marketplace": null
+ },
+ "gha-jbmarketplacepublisher": {
+ "slug": "gha-jbmarketplacepublisher",
+ "github": null,
+ "marketplace": null
+ },
+ "gha-vsmarketplacepublisher": {
+ "slug": "gha-vsmarketplacepublisher",
+ "github": null,
+ "marketplace": null
+ },
+ "otel4vsix": {
+ "slug": "otel4vsix",
+ "github": null,
+ "marketplace": {
+ "downloads": 734,
+ "latestVersion": "0.2.2"
+ }
+ },
+ "rnr-cli": {
+ "slug": "rnr-cli",
+ "github": null,
+ "marketplace": null
+ },
+ "vs-breakpointnotifier": {
+ "slug": "vs-breakpointnotifier",
+ "github": null,
+ "marketplace": {
+ "installs": 127,
+ "lastUpdated": "2026-01-30T17:03:04.64Z"
+ }
+ },
+ "vs-couchbaseexplorer": {
+ "slug": "vs-couchbaseexplorer",
+ "github": null,
+ "marketplace": {
+ "installs": 16,
+ "lastUpdated": "2026-01-30T17:13:31.22Z"
+ }
+ },
+ "vs-debugalizers": {
+ "slug": "vs-debugalizers",
+ "github": null,
+ "marketplace": {
+ "installs": 48,
+ "lastUpdated": "2026-01-30T17:03:13.72Z"
+ }
+ },
+ "vs-gitranger": {
+ "slug": "vs-gitranger",
+ "github": null,
+ "marketplace": {
+ "installs": 147,
+ "lastUpdated": "2026-01-30T17:03:37.8Z"
+ }
+ },
+ "vs-mcpserver": {
+ "slug": "vs-mcpserver",
+ "github": null,
+ "marketplace": {
+ "installs": 207,
+ "rating": 5,
+ "ratingCount": 1,
+ "lastUpdated": "2026-01-30T17:04:41.153Z"
+ }
+ },
+ "vs-openbinfolder": {
+ "slug": "vs-openbinfolder",
+ "github": null,
+ "marketplace": {
+ "installs": 905,
+ "rating": 5,
+ "ratingCount": 1,
+ "lastUpdated": "2026-01-30T17:05:33.84Z"
+ }
+ },
+ "vs-openinnotepadplusplus": {
+ "slug": "vs-openinnotepadplusplus",
+ "github": null,
+ "marketplace": {
+ "installs": 2780,
+ "rating": 4.5,
+ "ratingCount": 2,
+ "lastUpdated": "2026-01-31T15:07:23.477Z"
+ }
+ },
+ "vs-projectrenamifier": {
+ "slug": "vs-projectrenamifier",
+ "github": null,
+ "marketplace": {
+ "installs": 70,
+ "lastUpdated": "2026-01-30T17:03:37.367Z"
+ }
+ },
+ "vs-superclean": {
+ "slug": "vs-superclean",
+ "github": null,
+ "marketplace": {
+ "installs": 697,
+ "lastUpdated": "2026-01-30T17:05:47.96Z"
+ }
+ },
+ "vs-vsixmanifestdesigner": {
+ "slug": "vs-vsixmanifestdesigner",
+ "github": null,
+ "marketplace": {
+ "installs": 4,
+ "lastUpdated": "2026-01-30T17:03:15.773Z"
+ }
+ },
+ "vsc-mcpserver": {
+ "slug": "vsc-mcpserver",
+ "github": null,
+ "marketplace": {
+ "installs": 15,
+ "downloads": 34,
+ "lastUpdated": "2026-01-14T16:32:45.703Z"
+ }
+ },
+ "vscwhere": {
+ "slug": "vscwhere",
+ "github": null,
+ "marketplace": null
+ },
+ "vsix-guide": {
+ "slug": "vsix-guide",
+ "github": null,
+ "marketplace": null
+ },
+ "vsixsdk": {
+ "slug": "vsixsdk",
+ "github": null,
+ "marketplace": {
+ "downloads": 556,
+ "latestVersion": "0.4.0"
+ }
+ },
+ "vstoolbox": {
+ "slug": "vstoolbox",
+ "github": null,
+ "marketplace": null
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/lib/marketplace-stats.ts b/src/lib/marketplace-stats.ts
deleted file mode 100644
index 313e675..0000000
--- a/src/lib/marketplace-stats.ts
+++ /dev/null
@@ -1,212 +0,0 @@
-/**
- * Fetch marketplace statistics at build time
- */
-
-export interface MarketplaceStats {
- downloads?: number;
- installs?: number;
- rating?: number;
- ratingCount?: number;
- lastUpdated?: string;
-}
-
-export interface GitHubRelease {
- version: string;
- publishedAt: string;
- url: string;
-}
-
-export interface GitHubStats {
- stars: number;
- forks: number;
- openIssues: number;
- latestRelease?: GitHubRelease;
-}
-
-/**
- * Extract owner/repo from GitHub URL
- */
-function parseGitHubUrl(repoUrl: string): { owner: string; repo: string } | null {
- const match = repoUrl.match(/github\.com\/([^\/]+)\/([^\/]+)/);
- if (!match) return null;
- return { owner: match[1], repo: match[2] };
-}
-
-/**
- * Fetch GitHub repository stats including stars, forks, and latest release
- */
-export async function fetchGitHubStats(repoUrl: string): Promise