|
| 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 | +}); |
0 commit comments