From 061c37a69bd6dc0068f03de126c5b00c3db502c4 Mon Sep 17 00:00:00 2001
From: "Calvin A. Allen"
Date: Sat, 31 Jan 2026 11:35:23 -0500
Subject: [PATCH] feat(projects): add pre-build stats fetching
- Add scripts/fetch-project-stats.js to fetch GitHub and marketplace stats before build
- Stats are fetched based on project category:
- nuget-package: GitHub + NuGet
- vs-extension/vscode-extension: GitHub + VS Marketplace
- cli-tool/desktop-app/documentation/github-action: GitHub only
- Update workflow to use build:with-stats with GITHUB_TOKEN
- Update projects.ts to read from cached stats JSON
- Remove marketplace-stats.ts (replaced by pre-build script)
- Fix NuGet package URLs to use correct CodingWithCalvin. prefix
- Add npm scripts: fetch:stats, build:with-stats
---
.github/workflows/scheduled-deploy.yml | 4 +-
package-lock.json | 19 +-
package.json | 7 +-
scripts/fetch-project-stats.js | 314 ++++++++++++++++++++++++
src/content/projects/otel4vsix/index.md | 2 +-
src/content/projects/vsixsdk/index.md | 2 +-
src/data/project-stats.json | 151 ++++++++++++
src/lib/marketplace-stats.ts | 212 ----------------
src/lib/projects.ts | 76 ++++--
src/pages/projects/[...page].astro | 38 +--
10 files changed, 568 insertions(+), 257 deletions(-)
create mode 100644 scripts/fetch-project-stats.js
create mode 100644 src/data/project-stats.json
delete mode 100644 src/lib/marketplace-stats.ts
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 {
- try {
- const parsed = parseGitHubUrl(repoUrl);
- if (!parsed) return undefined;
-
- const { owner, repo } = parsed;
-
- // Use GITHUB_TOKEN if available to avoid rate limits
- const token = import.meta.env.GITHUB_TOKEN || process.env.GITHUB_TOKEN;
- const headers: Record = {
- '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 undefined;
- }
-
- const repoData = await repoResponse.json();
-
- let latestRelease: GitHubRelease | undefined;
- 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);
- return undefined;
- }
-}
-
-/**
- * Fetch VS Marketplace extension stats
- */
-export async function fetchVSMarketplaceStats(extensionId: string): Promise {
- try {
- // extensionId format: "CodingWithCalvin.VS-MCPServer"
- const [publisher, extension] = extensionId.split('.');
-
- 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 {};
- }
-
- const data = await response.json();
- const ext = data?.results?.[0]?.extensions?.[0];
-
- if (!ext) {
- console.warn(`Extension not found: ${extensionId}`);
- return {};
- }
-
- const stats = ext.statistics || [];
- const getStatValue = (name: string) => {
- const stat = stats.find((s: any) => 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);
- return {};
- }
-}
-
-/**
- * Fetch NuGet package stats using the search API
- */
-export async function fetchNuGetStats(packageId: string): Promise {
- try {
- // Use the search API which is more reliable
- 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 {};
- }
-
- const data = await response.json();
- const pkg = data?.data?.[0];
-
- if (!pkg) {
- console.warn(`NuGet package not found: ${packageId}`);
- return {};
- }
-
- return {
- downloads: pkg.totalDownloads,
- lastUpdated: pkg.versions?.[pkg.versions.length - 1]?.version,
- };
- } catch (error) {
- console.warn(`Failed to fetch NuGet stats for ${packageId}:`, error);
- return {};
- }
-}
-
-/**
- * Extract extension/package ID from marketplace URL
- */
-export function extractMarketplaceId(url: string, type: string): string | null {
- if (type === 'vs-marketplace') {
- // https://marketplace.visualstudio.com/items?itemName=CodingWithCalvin.VS-MCPServer
- const match = url.match(/itemName=([^&]+)/);
- return match?.[1] || null;
- } else if (type === 'nuget') {
- // https://www.nuget.org/packages/VsixSdk
- const match = url.match(/packages\/([^\/]+)/);
- return match?.[1] || null;
- }
- return null;
-}
-
-/**
- * Fetch stats based on marketplace type
- */
-export async function fetchMarketplaceStats(
- url: string,
- type: 'vs-marketplace' | 'nuget' | 'npm' | 'other'
-): Promise {
- const id = extractMarketplaceId(url, type);
- if (!id) return {};
-
- switch (type) {
- case 'vs-marketplace':
- return fetchVSMarketplaceStats(id);
- case 'nuget':
- return fetchNuGetStats(id);
- default:
- return {};
- }
-}
diff --git a/src/lib/projects.ts b/src/lib/projects.ts
index 7f280a0..77c347f 100644
--- a/src/lib/projects.ts
+++ b/src/lib/projects.ts
@@ -1,7 +1,9 @@
import type { ImageMetadata } from 'astro';
import { getCollection } from 'astro:content';
import defaultCover from '../assets/default-cover.png';
-import { fetchMarketplaceStats, fetchGitHubStats, type MarketplaceStats, type GitHubStats } from './marketplace-stats';
+
+// Import pre-fetched stats (generated by scripts/fetch-project-stats.js)
+import projectStatsData from '../data/project-stats.json';
// Import all cover images from project directories
const coverImages = import.meta.glob<{ default: ImageMetadata }>(
@@ -9,6 +11,28 @@ const coverImages = import.meta.glob<{ default: ImageMetadata }>(
{ eager: true }
);
+export interface MarketplaceStats {
+ downloads?: number;
+ installs?: number;
+ rating?: number;
+ ratingCount?: number;
+ lastUpdated?: string;
+ latestVersion?: string;
+}
+
+export interface GitHubRelease {
+ version: string;
+ publishedAt: string;
+ url: string;
+}
+
+export interface GitHubStats {
+ stars: number;
+ forks: number;
+ openIssues: number;
+ latestRelease?: GitHubRelease;
+}
+
export type ProjectWithImage = Awaited>>[number] & {
resolvedImage: ImageMetadata;
marketplaceStats?: MarketplaceStats;
@@ -30,6 +54,23 @@ export function getProjectImage(project: Awaited {
/**
* Get all projects with resolved cover images AND marketplace/GitHub stats
- * Use this for pages that need live data
+ * Stats are loaded from pre-fetched cache (generated at build time)
*/
export async function getProjectsWithStats(): Promise {
const projects = await getCollection('projects');
- // Fetch all stats in parallel
- const projectsWithStats = await Promise.all(
- projects.map(async (project) => {
- // Fetch marketplace and GitHub stats in parallel
- const [marketplaceStats, githubStats] = await Promise.all([
- project.data.marketplace
- ? fetchMarketplaceStats(project.data.marketplace.url, project.data.marketplace.type)
- : Promise.resolve(undefined),
- fetchGitHubStats(project.data.repoUrl),
- ]);
-
- return {
- ...project,
- resolvedImage: getProjectImage(project),
- marketplaceStats,
- githubStats,
- };
- })
- );
-
- return projectsWithStats;
+ return projects.map((project) => {
+ const { github, marketplace } = getCachedStats(project.id);
+
+ return {
+ ...project,
+ resolvedImage: getProjectImage(project),
+ marketplaceStats: marketplace || undefined,
+ githubStats: github || undefined,
+ };
+ });
}
/**
diff --git a/src/pages/projects/[...page].astro b/src/pages/projects/[...page].astro
index b01bed6..acf0b23 100644
--- a/src/pages/projects/[...page].astro
+++ b/src/pages/projects/[...page].astro
@@ -3,13 +3,15 @@ import type { GetStaticPaths } from 'astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import ProjectCard from '../../components/ProjectCard.astro';
import Pagination from '../../components/Pagination.astro';
-import { getProjectsWithImages, getProjectSlug } from '../../lib/projects';
+import { getProjectsWithStats, getProjectSlug } from '../../lib/projects';
export const getStaticPaths = (async ({ paginate }) => {
- const allProjects = await getProjectsWithImages();
- // Sort by GitHub stars (highest first)
+ const allProjects = await getProjectsWithStats();
+ // Sort by GitHub stars (highest first), using live stats when available
const sortedProjects = allProjects.sort((a, b) => {
- return (b.data.stars ?? 0) - (a.data.stars ?? 0);
+ const aStars = a.githubStats?.stars ?? a.data.stars ?? 0;
+ const bStars = b.githubStats?.stars ?? b.data.stars ?? 0;
+ return bStars - aStars;
});
return paginate(sortedProjects, { pageSize: 9 });
@@ -27,18 +29,22 @@ const { page } = Astro.props;
- {page.data.map(project => (
-
- ))}
+ {page.data.map(project => {
+ const stars = project.githubStats?.stars ?? project.data.stars;
+ const downloads = project.marketplaceStats?.downloads ?? project.marketplaceStats?.installs ?? project.data.downloads;
+ return (
+
+ );
+ })}
{page.lastPage > 1 && (