diff --git a/package.json b/package.json index 76b7e5e..c96610f 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "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" }, "dependencies": { diff --git a/scripts/assets/book-icon.png b/scripts/assets/book-icon.png new file mode 100644 index 0000000..47d3e61 Binary files /dev/null and b/scripts/assets/book-icon.png differ diff --git a/scripts/assets/desktop-icon.png b/scripts/assets/desktop-icon.png new file mode 100644 index 0000000..e5b1562 Binary files /dev/null and b/scripts/assets/desktop-icon.png differ diff --git a/scripts/assets/github-logo.png b/scripts/assets/github-logo.png new file mode 100644 index 0000000..4bf59dd Binary files /dev/null and b/scripts/assets/github-logo.png differ diff --git a/scripts/assets/nuget-logo.png b/scripts/assets/nuget-logo.png new file mode 100644 index 0000000..1de2c6a Binary files /dev/null and b/scripts/assets/nuget-logo.png differ diff --git a/scripts/assets/terminal-icon.png b/scripts/assets/terminal-icon.png new file mode 100644 index 0000000..72f5e54 Binary files /dev/null and b/scripts/assets/terminal-icon.png differ diff --git a/scripts/assets/vs-logo.png b/scripts/assets/vs-logo.png new file mode 100644 index 0000000..cdeea92 Binary files /dev/null and b/scripts/assets/vs-logo.png differ diff --git a/scripts/assets/vscode-logo.png b/scripts/assets/vscode-logo.png new file mode 100644 index 0000000..37a0847 Binary files /dev/null and b/scripts/assets/vscode-logo.png differ diff --git a/scripts/generate-project-cover.js b/scripts/generate-project-cover.js new file mode 100644 index 0000000..1e095c0 --- /dev/null +++ b/scripts/generate-project-cover.js @@ -0,0 +1,691 @@ +import satori from "satori"; +import { Resvg } from "@resvg/resvg-js"; +import { readFileSync, writeFileSync, existsSync, readdirSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +import { createInterface } from "readline"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Dimensions +const WIDTH = 1920; +const HEIGHT = 1080; + +// Category configuration with colors and icons +const CATEGORY_CONFIG = { + "vs-extension": { + name: "Visual Studio Extension", + colors: { primary: "#68217A", secondary: "#9B4DCA", accent: "#FFFFFF" }, + icon: "vs-logo.png", + gradient: { start: "#68217A", mid: "#8B3FA8", end: "#A855C7" }, + }, + "vscode-extension": { + name: "VS Code Extension", + colors: { primary: "#007ACC", secondary: "#1E90FF", accent: "#FFFFFF" }, + icon: "vscode-logo.png", + gradient: { start: "#007ACC", mid: "#1A8FE3", end: "#3AA5F7" }, + }, + "github-action": { + name: "GitHub Action", + colors: { primary: "#238636", secondary: "#2EA043", accent: "#FFFFFF" }, + icon: "github-logo.png", + gradient: { start: "#238636", mid: "#2E9848", end: "#3DAA5B" }, + }, + "cli-tool": { + name: "CLI Tool", + colors: { primary: "#0D7377", secondary: "#14A3A8", accent: "#FFFFFF" }, + icon: "terminal-icon.png", + gradient: { start: "#0D7377", mid: "#14A3A8", end: "#32B8BD" }, + }, + "nuget-package": { + name: "NuGet Package", + colors: { primary: "#3D4752", secondary: "#4A5568", accent: "#FFFFFF" }, + icon: "nuget-logo.png", + gradient: { start: "#3D4752", mid: "#4A5568", end: "#5A6778" }, + }, + "desktop-app": { + name: "Desktop App", + colors: { primary: "#FF6B35", secondary: "#FF8C5A", accent: "#FFFFFF" }, + icon: "desktop-icon.png", + gradient: { start: "#FF6B35", mid: "#FF7D4D", end: "#FF9566" }, + }, + documentation: { + name: "Documentation", + colors: { primary: "#5C6BC0", secondary: "#7986CB", accent: "#FFFFFF" }, + icon: "book-icon.png", + gradient: { start: "#5C6BC0", mid: "#6E7BD0", end: "#8090DD" }, + }, +}; + +// Load image as base64 +function loadImageBase64(filename) { + const imagePath = join(__dirname, "assets", filename); + if (!existsSync(imagePath)) { + console.warn(`Warning: Asset not found: ${filename}`); + return null; + } + const buffer = readFileSync(imagePath); + return `data:image/png;base64,${buffer.toString("base64")}`; +} + +// Load fonts from local files +function loadFont() { + const fontPath = join(__dirname, "assets", "inter-regular.woff"); + return readFileSync(fontPath); +} + +function loadFontBold() { + const fontPath = join(__dirname, "assets", "inter-bold.woff"); + return readFileSync(fontPath); +} + +// Parse frontmatter from markdown file +function parseFrontmatter(content) { + const normalizedContent = content.replace(/\r\n/g, "\n"); + const match = normalizedContent.match(/^---\n([\s\S]*?)\n---/); + if (!match) return {}; + + const frontmatter = {}; + const lines = match[1].split("\n"); + let currentKey = null; + let inArray = false; + + for (const line of lines) { + // Check for array start + if (line.match(/^\s*-\s+/)) { + if (currentKey && inArray) { + let value = line.replace(/^\s*-\s+/, "").trim(); + if (value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1); + } + frontmatter[currentKey].push(value); + } + continue; + } + + const colonIndex = line.indexOf(":"); + if (colonIndex > 0) { + const key = line.slice(0, colonIndex).trim(); + let value = line.slice(colonIndex + 1).trim(); + + // Handle inline arrays: [item1, item2] + if (value.startsWith("[") && value.endsWith("]")) { + value = value + .slice(1, -1) + .split(",") + .map((v) => { + v = v.trim(); + if (v.startsWith('"') && v.endsWith('"')) { + v = v.slice(1, -1); + } + return v; + }); + frontmatter[key] = value; + inArray = false; + currentKey = null; + } else if (value === "") { + // Possible start of multi-line array + frontmatter[key] = []; + currentKey = key; + inArray = true; + } else { + // Remove quotes + if (value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1); + } + frontmatter[key] = value; + inArray = false; + currentKey = null; + } + } + } + + return frontmatter; +} + +// Create decorative background with layered solid colors (satori compatible) +function createBackgroundPattern(config) { + const { gradient } = config; + + return { + type: "div", + props: { + style: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + display: "flex", + overflow: "hidden", + }, + children: [ + // Base layer (darkest color) + { + type: "div", + props: { + style: { + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", + backgroundColor: gradient.start, + }, + }, + }, + // Middle gradient layer (angled) + { + type: "div", + props: { + style: { + position: "absolute", + top: 0, + left: "30%", + width: "100%", + height: "100%", + backgroundColor: gradient.mid, + transform: "skewX(-15deg)", + }, + }, + }, + // Light gradient layer (angled) + { + type: "div", + props: { + style: { + position: "absolute", + top: 0, + left: "55%", + width: "100%", + height: "100%", + backgroundColor: gradient.end, + transform: "skewX(-15deg)", + }, + }, + }, + // Decorative circle top-right + { + type: "div", + props: { + style: { + position: "absolute", + top: -200, + right: -200, + width: 600, + height: 600, + borderRadius: 300, + backgroundColor: "rgba(255,255,255,0.05)", + }, + }, + }, + // Decorative circle bottom-left + { + type: "div", + props: { + style: { + position: "absolute", + bottom: -150, + left: -150, + width: 500, + height: 500, + borderRadius: 250, + backgroundColor: "rgba(255,255,255,0.05)", + }, + }, + }, + ], + }, + }; +} + +// Create tech stack badge +function createBadge(text, accentColor) { + return { + type: "div", + props: { + style: { + display: "flex", + backgroundColor: "rgba(255,255,255,0.15)", + borderRadius: 35, + padding: "20px 40px", + marginRight: 24, + marginBottom: 24, + border: "2px solid rgba(255,255,255,0.3)", + }, + children: [ + { + type: "span", + props: { + style: { + color: accentColor, + fontSize: 42, + fontWeight: 700, + letterSpacing: 1, + }, + children: text, + }, + }, + ], + }, + }; +} + +// Create the card-based project template +function createProjectTemplate( + title, + category, + techStack, + logoBase64, + categoryIconBase64 +) { + const config = CATEGORY_CONFIG[category] || CATEGORY_CONFIG["vs-extension"]; + const { colors, name: categoryName } = config; + + // Create tech badges + const badges = (techStack || []).slice(0, 5).map((tech) => createBadge(tech, colors.accent)); + + return { + type: "div", + props: { + style: { + width: WIDTH, + height: HEIGHT, + display: "flex", + flexDirection: "column", + position: "relative", + overflow: "hidden", + }, + children: [ + // Background with gradient and pattern + createBackgroundPattern(config), + // Main content container + { + type: "div", + props: { + style: { + position: "relative", + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + width: "100%", + height: "100%", + padding: 60, + }, + children: [ + // Central card + { + type: "div", + props: { + style: { + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "space-between", + backgroundColor: "rgba(0,0,0,0.5)", + borderRadius: 50, + width: 1760, + height: 920, + padding: "60px 0", + border: "3px solid rgba(255,255,255,0.15)", + boxShadow: "0 25px 50px rgba(0,0,0,0.3)", + }, + children: [ + // Top section: Icon, Title, and Badges + { + type: "div", + props: { + style: { + display: "flex", + flexDirection: "column", + alignItems: "center", + }, + children: [ + // Category icon + categoryIconBase64 + ? { + type: "img", + props: { + src: categoryIconBase64, + width: 220, + height: 220, + style: { + objectFit: "contain", + marginBottom: 40, + }, + }, + } + : { + type: "div", + props: { + style: { + width: 220, + height: 220, + marginBottom: 40, + borderRadius: 40, + backgroundColor: "rgba(255,255,255,0.1)", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: 100, + color: colors.accent, + }, + children: "?", + }, + }, + // Project title + { + type: "div", + props: { + style: { + fontSize: 96, + fontWeight: 700, + color: "#FFFFFF", + textAlign: "center", + lineHeight: 1.2, + marginBottom: 40, + textShadow: "0 4px 12px rgba(0,0,0,0.4)", + maxWidth: 1500, + }, + children: title, + }, + }, + // Tech stack badges + badges.length > 0 + ? { + type: "div", + props: { + style: { + display: "flex", + flexWrap: "wrap", + justifyContent: "center", + maxWidth: 1400, + }, + children: badges, + }, + } + : null, + ].filter(Boolean), + }, + }, + // Middle spacer + { + type: "div", + props: { + style: { flex: 1 }, + }, + }, + // Category label with divider + { + type: "div", + props: { + style: { + display: "flex", + alignItems: "center", + gap: 30, + }, + children: [ + { + type: "div", + props: { + style: { + width: 100, + height: 4, + backgroundColor: "rgba(255,255,255,0.4)", + }, + }, + }, + { + type: "span", + props: { + style: { + fontSize: 48, + fontWeight: 400, + color: "rgba(255,255,255,0.8)", + textTransform: "uppercase", + letterSpacing: 5, + }, + children: categoryName, + }, + }, + { + type: "div", + props: { + style: { + width: 100, + height: 4, + backgroundColor: "rgba(255,255,255,0.4)", + }, + }, + }, + ], + }, + }, + ].filter(Boolean), + }, + }, + ], + }, + }, + ].filter(Boolean), + }, + }; +} + +// Generate the cover image +async function generateProjectCover(title, category, techStack) { + const font = loadFont(); + const fontBold = loadFontBold(); + const logoBase64 = loadImageBase64("logo.png"); + + const config = CATEGORY_CONFIG[category] || CATEGORY_CONFIG["vs-extension"]; + const categoryIconBase64 = loadImageBase64(config.icon); + + console.log(` Category: ${config.name}`); + console.log(` Tech Stack: ${techStack?.join(", ") || "none"}`); + + const template = createProjectTemplate( + title, + category, + techStack, + logoBase64, + categoryIconBase64 + ); + + const svg = await satori(template, { + width: WIDTH, + height: HEIGHT, + fonts: [ + { name: "Inter", data: font, weight: 400, style: "normal" }, + { name: "Inter", data: fontBold, weight: 700, style: "normal" }, + ], + }); + + const resvg = new Resvg(svg, { + fitTo: { mode: "width", value: WIDTH }, + }); + + return Buffer.from(resvg.render().asPng()); +} + +// Find all projects +function findProjects() { + const projectsDir = join(process.cwd(), "src", "content", "projects"); + const projects = []; + + if (!existsSync(projectsDir)) { + console.error("Error: Projects directory not found at src/content/projects"); + return projects; + } + + const dirs = readdirSync(projectsDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + + for (const slug of dirs) { + const projectDir = join(projectsDir, slug); + const indexPath = join(projectDir, "index.md"); + + if (existsSync(indexPath)) { + projects.push({ + slug, + path: projectDir, + indexPath, + }); + } + } + + return projects; +} + +// Interactive project selection +async function selectProject(projects) { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const ask = (q) => + new Promise((resolve) => rl.question(q, (a) => resolve(a.trim()))); + + console.log("\nAvailable projects without cover images:\n"); + + const projectsWithoutCover = projects.filter( + (p) => !existsSync(join(p.path, "cover.png")) + ); + + if (projectsWithoutCover.length === 0) { + console.log("All projects have cover images!"); + rl.close(); + return null; + } + + projectsWithoutCover.forEach((p, i) => { + console.log(` ${i + 1}. ${p.slug}`); + }); + + console.log(`\n 0. Generate for a specific path`); + console.log(` a. Generate all missing covers\n`); + + const choice = await ask("Select an option: "); + rl.close(); + + if (choice.toLowerCase() === "a") { + return projectsWithoutCover; + } + + const index = parseInt(choice, 10); + if (index === 0) { + return "manual"; + } + + if (index > 0 && index <= projectsWithoutCover.length) { + return [projectsWithoutCover[index - 1]]; + } + + return null; +} + +// Generate cover for a single project +async function generateForProject(project) { + const content = readFileSync(project.indexPath, "utf-8"); + const frontmatter = parseFrontmatter(content); + + if (!frontmatter.title) { + console.log(` Skipping ${project.slug}: No title found`); + return false; + } + + const title = frontmatter.title; + const category = frontmatter.category || "vs-extension"; + const techStack = frontmatter.techStack || []; + + console.log(` Generating: ${project.slug}`); + console.log(` Title: ${title}`); + + const png = await generateProjectCover(title, category, techStack); + const outputPath = join(project.path, "cover.png"); + writeFileSync(outputPath, png); + + console.log(` Saved: ${outputPath}\n`); + return true; +} + +// Main CLI +async function main() { + const args = process.argv.slice(2); + const postPath = args[0]; + + // Direct path provided + if (postPath) { + const indexPath = join(postPath, "index.md"); + + if (!existsSync(indexPath)) { + console.error(`Error: No index.md found at ${postPath}`); + process.exit(1); + } + + const project = { + path: postPath, + indexPath, + slug: postPath.split(/[/\\]/).pop(), + }; + + await generateForProject(project); + return; + } + + // Interactive mode + const projects = findProjects(); + + if (projects.length === 0) { + console.log("No projects found."); + return; + } + + const selection = await selectProject(projects); + + if (!selection) { + console.log("No selection made."); + return; + } + + if (selection === "manual") { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + const path = await new Promise((resolve) => + rl.question("Enter project path: ", resolve) + ); + rl.close(); + + const indexPath = join(path, "index.md"); + if (!existsSync(indexPath)) { + console.error(`Error: No index.md found at ${path}`); + process.exit(1); + } + + await generateForProject({ + path, + indexPath, + slug: path.split(/[/\\]/).pop(), + }); + return; + } + + // Generate for selected projects + console.log(`\nGenerating ${selection.length} cover(s)...\n`); + + for (const project of selection) { + await generateForProject(project); + } + + console.log("Done!"); +} + +// Export for use in new-project.js +export { generateProjectCover, parseFrontmatter }; + +main().catch(console.error); diff --git a/scripts/new-project.js b/scripts/new-project.js new file mode 100644 index 0000000..2dc351c --- /dev/null +++ b/scripts/new-project.js @@ -0,0 +1,245 @@ +import { createInterface } from "readline"; +import { mkdir, writeFile } from "fs/promises"; +import { existsSync } from "fs"; +import { spawn } from "child_process"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const rl = createInterface({ + input: process.stdin, + output: process.stdout, +}); + +function ask(question) { + return new Promise((resolve) => { + rl.question(question, (answer) => resolve(answer.trim())); + }); +} + +function slugify(text) { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +function getISODate() { + const now = new Date(); + return now.toISOString().split("T")[0]; +} + +// Category options matching the schema +const CATEGORIES = [ + { value: "vs-extension", label: "Visual Studio Extension" }, + { value: "vscode-extension", label: "VS Code Extension" }, + { value: "github-action", label: "GitHub Action" }, + { value: "cli-tool", label: "CLI Tool" }, + { value: "nuget-package", label: "NuGet Package" }, + { value: "desktop-app", label: "Desktop App" }, + { value: "documentation", label: "Documentation" }, +]; + +// Status options matching the schema +const STATUSES = [ + { value: "active", label: "Active (actively developed)" }, + { value: "maintained", label: "Maintained (bug fixes, no new features)" }, + { value: "experimental", label: "Experimental (work in progress)" }, + { value: "archived", label: "Archived (no longer maintained)" }, +]; + +// Marketplace type options +const MARKETPLACE_TYPES = [ + { value: "vs-marketplace", label: "Visual Studio Marketplace" }, + { value: "nuget", label: "NuGet" }, + { value: "npm", label: "npm" }, + { value: "other", label: "Other" }, +]; + +async function selectOption(prompt, options, defaultIndex = 0) { + console.log(`\n${prompt}`); + options.forEach((opt, i) => { + const marker = i === defaultIndex ? "*" : " "; + console.log(` ${marker}${i + 1}. ${opt.label}`); + }); + console.log(); + + const choice = await ask(`Select (1-${options.length}) [${defaultIndex + 1}]: `); + + if (!choice) { + return options[defaultIndex].value; + } + + const index = parseInt(choice, 10) - 1; + if (index >= 0 && index < options.length) { + return options[index].value; + } + + console.log("Invalid selection, using default."); + return options[defaultIndex].value; +} + +async function generateCover(projectDir) { + return new Promise((resolve, reject) => { + const scriptPath = join(__dirname, "generate-project-cover.js"); + const child = spawn("node", [scriptPath, projectDir], { + stdio: "inherit", + }); + child.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`Cover generation failed with code ${code}`)); + }); + }); +} + +async function main() { + console.log("\n New Open Source Project\n"); + + // Required: Title + const title = await ask("Title (required): "); + if (!title) { + console.log("Title is required."); + rl.close(); + return; + } + + // Slug (default: slugified title) + const defaultSlug = slugify(title); + const slugInput = await ask(`Slug [${defaultSlug}]: `); + const slug = slugInput || defaultSlug; + + // Required: Description + const description = await ask("Description (required): "); + if (!description) { + console.log("Description is required."); + rl.close(); + return; + } + + // Optional: Long description + const longDescription = await ask("Long description (optional): "); + + // Category (select from enum) + const category = await selectOption("Category:", CATEGORIES, 0); + + // Required: Repository URL + const repoUrl = await ask("Repository URL (required): "); + if (!repoUrl) { + console.log("Repository URL is required."); + rl.close(); + return; + } + + // Optional URLs + const demoUrl = await ask("Demo URL (optional): "); + const docsUrl = await ask("Docs URL (optional): "); + + // Tech stack (comma-separated) + const techStackInput = await ask("Tech stack (comma-separated, e.g. C#, .NET, VSIX): "); + const techStack = techStackInput + ? techStackInput.split(",").map((t) => t.trim()).filter(Boolean) + : []; + + // Required: Language + const language = await ask("Primary language (required): "); + if (!language) { + console.log("Language is required."); + rl.close(); + return; + } + + // Status (select from enum, default: active) + const status = await selectOption("Status:", STATUSES, 0); + + // Start date (default: today) + const defaultDate = getISODate(); + const startDateInput = await ask(`Start date [${defaultDate}]: `); + const startDate = startDateInput || defaultDate; + + // Optional: GitHub stars + const starsInput = await ask("GitHub stars (optional): "); + const stars = starsInput ? parseInt(starsInput, 10) : null; + + // Optional: Marketplace + const hasMarketplace = await ask("Has marketplace listing? (y/N): "); + let marketplace = null; + if (hasMarketplace.toLowerCase() === "y") { + const marketplaceType = await selectOption("Marketplace type:", MARKETPLACE_TYPES, 0); + const marketplaceUrl = await ask("Marketplace URL: "); + if (marketplaceUrl) { + marketplace = { type: marketplaceType, url: marketplaceUrl }; + } + } + + // Cover image + const includeCover = await ask("Generate cover image? (y/N): "); + const wantsCover = includeCover.toLowerCase() === "y"; + + rl.close(); + + const dir = `src/content/projects/${slug}`; + + if (existsSync(dir)) { + console.log(`\n Directory already exists: ${dir}`); + return; + } + + // Build frontmatter + const frontmatter = [ + "---", + `title: "${title}"`, + `description: "${description}"`, + ]; + + if (longDescription) { + frontmatter.push(`longDescription: "${longDescription}"`); + } + + frontmatter.push(`category: "${category}"`); + frontmatter.push(`repoUrl: "${repoUrl}"`); + + if (demoUrl) { + frontmatter.push(`demoUrl: "${demoUrl}"`); + } + + if (docsUrl) { + frontmatter.push(`docsUrl: "${docsUrl}"`); + } + + frontmatter.push(`techStack: [${techStack.map((t) => `"${t}"`).join(", ")}]`); + frontmatter.push(`language: "${language}"`); + frontmatter.push(`status: "${status}"`); + frontmatter.push(`startDate: "${startDate}"`); + + if (stars !== null && !isNaN(stars)) { + frontmatter.push(`stars: ${stars}`); + } + + if (marketplace) { + frontmatter.push(`marketplace:`); + frontmatter.push(` type: "${marketplace.type}"`); + frontmatter.push(` url: "${marketplace.url}"`); + } + + frontmatter.push("---", "", "Your content here...", ""); + + await mkdir(dir, { recursive: true }); + await writeFile(`${dir}/index.md`, frontmatter.join("\n")); + + console.log(`\n Created: ${dir}/index.md`); + + if (wantsCover) { + console.log(`\n Generating cover image...`); + try { + await generateCover(dir); + } catch (err) { + console.log(` Cover generation failed: ${err.message}`); + console.log(` You can run 'npm run cover:project' later to generate it.`); + } + } + + console.log("\nDone!"); +} + +main(); diff --git a/src/components/ProjectCard.astro b/src/components/ProjectCard.astro new file mode 100644 index 0000000..0f65161 --- /dev/null +++ b/src/components/ProjectCard.astro @@ -0,0 +1,81 @@ +--- +import { Image } from 'astro:assets'; +import type { ImageMetadata } from 'astro'; +import TechStackBadges from './TechStackBadges.astro'; +import { formatCount } from '../lib/projects'; + +interface Props { + title: string; + slug: string; + description: string; + techStack: string[]; + repoUrl: string; + image?: ImageMetadata; + stars?: number; + downloads?: number; +} + +const { title, slug, description, techStack, repoUrl, image, stars, downloads } = Astro.props; +--- + +
+ {image && ( + + {title} + + )} +
+

+ + {title} + +

+ +

{description}

+ +
+ +
+ +
+
+ {stars !== undefined && ( + + + + + {formatCount(stars)} + + )} + {downloads !== undefined && ( + + + + + {formatCount(downloads)} + + )} +
+ + + + + +
+
+
diff --git a/src/components/TechStackBadges.astro b/src/components/TechStackBadges.astro new file mode 100644 index 0000000..780d989 --- /dev/null +++ b/src/components/TechStackBadges.astro @@ -0,0 +1,23 @@ +--- +interface Props { + techStack: string[]; + limit?: number; +} + +const { techStack, limit } = Astro.props; +const displayTech = limit ? techStack.slice(0, limit) : techStack; +const remaining = limit ? techStack.length - limit : 0; +--- + +
+ {displayTech.map(tech => ( + + {tech} + + ))} + {remaining > 0 && ( + + +{remaining} + + )} +
diff --git a/src/content/config.ts b/src/content/config.ts index 570b5ee..e448035 100644 --- a/src/content/config.ts +++ b/src/content/config.ts @@ -15,4 +15,37 @@ const blog = defineCollection({ }), }); -export const collections = { blog }; +const projects = defineCollection({ + loader: glob({ pattern: "**/index.md", base: "./src/content/projects" }), + schema: ({ image }) => z.object({ + title: z.string(), + description: z.string(), + longDescription: z.string().optional(), + category: z.enum([ + 'vs-extension', + 'vscode-extension', + 'github-action', + 'cli-tool', + 'nuget-package', + 'desktop-app', + 'documentation', + ]), + repoUrl: z.string().url(), + demoUrl: z.string().url().optional(), + docsUrl: z.string().url().optional(), + techStack: z.array(z.string()), + language: z.string(), + status: z.enum(['active', 'maintained', 'archived', 'experimental']), + startDate: z.coerce.date(), + lastUpdated: z.coerce.date().optional(), + image: image().optional(), + marketplace: z.object({ + type: z.enum(['vs-marketplace', 'nuget', 'npm', 'other']), + url: z.string().url(), + }).optional(), + stars: z.number().optional(), + downloads: z.number().optional(), + }), +}); + +export const collections = { blog, projects }; diff --git a/src/content/projects/dtvem-cli/cover.png b/src/content/projects/dtvem-cli/cover.png new file mode 100644 index 0000000..8b10f41 Binary files /dev/null and b/src/content/projects/dtvem-cli/cover.png differ diff --git a/src/content/projects/dtvem-cli/index.md b/src/content/projects/dtvem-cli/index.md new file mode 100644 index 0000000..4d499ca --- /dev/null +++ b/src/content/projects/dtvem-cli/index.md @@ -0,0 +1,24 @@ +--- +title: "DTVEM CLI" +description: "Developer Tools Virtual Environment Manager (DTVEM) is a cross-platform virtual environment manager for multiple developer tools, written in Go." +longDescription: "DTVEM simplifies managing multiple versions of developer tools across projects. Written in Go for cross-platform support, it provides first-class support for Windows, macOS, and Linux right out of the box." +category: "cli-tool" +repoUrl: "https://github.com/CodingWithCalvin/dtvem.cli" +docsUrl: "https://dtvem.io" +techStack: ["Go", "CLI", "Cross-platform"] +language: "Go" +status: "active" +startDate: "2025-12-04" +stars: 5 +--- + + + +DTVEM (Developer Tools Virtual Environment Manager) helps you manage multiple versions of developer tools on a per-project basis. Similar to nvm for Node or pyenv for Python, but for a broader range of tools. + +## Features + +- Manage multiple tool versions per project +- Cross-platform support (Windows, macOS, Linux) +- Fast and lightweight (written in Go) +- Simple configuration files diff --git a/src/content/projects/gha-jbmarketplacepublisher/cover.png b/src/content/projects/gha-jbmarketplacepublisher/cover.png new file mode 100644 index 0000000..6a8ed69 Binary files /dev/null and b/src/content/projects/gha-jbmarketplacepublisher/cover.png differ diff --git a/src/content/projects/gha-jbmarketplacepublisher/index.md b/src/content/projects/gha-jbmarketplacepublisher/index.md new file mode 100644 index 0000000..7973aab --- /dev/null +++ b/src/content/projects/gha-jbmarketplacepublisher/index.md @@ -0,0 +1,24 @@ +--- +title: "JetBrains Marketplace Publisher" +description: "GitHub Action to publish plugins to the JetBrains Marketplace." +longDescription: "Automate your JetBrains plugin publishing workflow with this GitHub Action. Publish to the JetBrains Marketplace directly from your CI/CD pipeline." +category: "github-action" +repoUrl: "https://github.com/CodingWithCalvin/GHA-JBMarketplacePublisher" +demoUrl: "https://github.com/marketplace/actions/jetbrains-marketplace-publisher" +techStack: ["TypeScript", "Node.js", "GitHub Actions"] +language: "TypeScript" +status: "maintained" +startDate: "2024-09-24" +stars: 2 +--- + + + +Automate your JetBrains plugin releases with this GitHub Action. No more manual uploads - just push a release and let CI/CD handle the marketplace publishing. + +## Features + +- Publish plugins to JetBrains Marketplace +- Support for all JetBrains IDEs +- Configurable through workflow inputs +- Secure token handling diff --git a/src/content/projects/gha-vsmarketplacepublisher/cover.png b/src/content/projects/gha-vsmarketplacepublisher/cover.png new file mode 100644 index 0000000..4dda132 Binary files /dev/null and b/src/content/projects/gha-vsmarketplacepublisher/cover.png differ diff --git a/src/content/projects/gha-vsmarketplacepublisher/index.md b/src/content/projects/gha-vsmarketplacepublisher/index.md new file mode 100644 index 0000000..87ca3c9 --- /dev/null +++ b/src/content/projects/gha-vsmarketplacepublisher/index.md @@ -0,0 +1,24 @@ +--- +title: "VS Marketplace Publisher" +description: "GitHub Action to publish extensions to the Visual Studio Marketplace." +longDescription: "Automate your Visual Studio extension publishing workflow with this GitHub Action. Publish to the VS Marketplace directly from your CI/CD pipeline." +category: "github-action" +repoUrl: "https://github.com/CodingWithCalvin/GHA-VSMarketplacePublisher" +demoUrl: "https://github.com/marketplace/actions/vs-marketplace-publisher" +techStack: ["TypeScript", "Node.js", "GitHub Actions"] +language: "TypeScript" +status: "maintained" +startDate: "2023-03-24" +stars: 5 +--- + + + +Stop manually publishing your Visual Studio extensions. This GitHub Action automates the entire process, letting you publish to the VS Marketplace as part of your CI/CD workflow. + +## Features + +- Publish VSIX files to VS Marketplace +- Support for update and new releases +- Configurable through workflow inputs +- Secure PAT handling diff --git a/src/content/projects/otel4vsix/cover.png b/src/content/projects/otel4vsix/cover.png new file mode 100644 index 0000000..5bed50c Binary files /dev/null and b/src/content/projects/otel4vsix/cover.png differ diff --git a/src/content/projects/otel4vsix/index.md b/src/content/projects/otel4vsix/index.md new file mode 100644 index 0000000..a44c139 --- /dev/null +++ b/src/content/projects/otel4vsix/index.md @@ -0,0 +1,27 @@ +--- +title: "Otel4Vsix" +description: "OpenTelemetry support library for Visual Studio 2022+ extensions. Add distributed tracing, metrics, logging, and exception tracking to your VSIX with minimal configuration." +longDescription: "Bring observability to your Visual Studio extensions with Otel4Vsix. Add distributed tracing, metrics, logging, and exception tracking with just a few lines of code." +category: "nuget-package" +repoUrl: "https://github.com/CodingWithCalvin/Otel4Vsix" +techStack: ["C#", ".NET", "OpenTelemetry", "VSIX"] +language: "C#" +status: "active" +startDate: "2025-12-23" +stars: 0 +marketplace: + type: "nuget" + url: "https://www.nuget.org/packages/Otel4Vsix" +--- + + + +Otel4Vsix makes it easy to add OpenTelemetry instrumentation to your Visual Studio extensions. Track performance, log events, and capture exceptions with industry-standard observability. + +## Features + +- Distributed tracing support +- Metrics collection +- Structured logging +- Exception tracking +- Minimal configuration required diff --git a/src/content/projects/rnr-cli/cover.png b/src/content/projects/rnr-cli/cover.png new file mode 100644 index 0000000..d51e907 Binary files /dev/null and b/src/content/projects/rnr-cli/cover.png differ diff --git a/src/content/projects/rnr-cli/index.md b/src/content/projects/rnr-cli/index.md new file mode 100644 index 0000000..d830a63 --- /dev/null +++ b/src/content/projects/rnr-cli/index.md @@ -0,0 +1,24 @@ +--- +title: "rnr CLI" +description: "rnr (pronounced 'runner') is a cross-platform task runner that works instantly on any machine. No Node.js. No Python. No global installs. Just clone and go." +longDescription: "rnr is a zero-setup task runner. Clone any repo and run tasks immediately - no runtime dependencies, no global installs. Written in Rust for maximum portability and performance." +category: "cli-tool" +repoUrl: "https://github.com/CodingWithCalvin/rnr.cli" +docsUrl: "https://rnrcli.io" +techStack: ["Rust", "CLI", "Cross-platform"] +language: "Rust" +status: "active" +startDate: "2026-01-09" +stars: 0 +--- + + + +rnr eliminates the "works on my machine" problem for task runners. No need to install Node.js, Python, or any other runtime. Just clone and run. + +## Features + +- Zero dependencies to install +- Cross-platform (Windows, macOS, Linux) +- Fast (written in Rust) +- Simple task configuration diff --git a/src/content/projects/vs-breakpointnotifier/cover.png b/src/content/projects/vs-breakpointnotifier/cover.png new file mode 100644 index 0000000..93c8fae Binary files /dev/null and b/src/content/projects/vs-breakpointnotifier/cover.png differ diff --git a/src/content/projects/vs-breakpointnotifier/index.md b/src/content/projects/vs-breakpointnotifier/index.md new file mode 100644 index 0000000..85b6595 --- /dev/null +++ b/src/content/projects/vs-breakpointnotifier/index.md @@ -0,0 +1,26 @@ +--- +title: "Breakpoint Notifier" +description: "A Visual Studio extension to 'alert' you when a breakpoint is hit while you're debugging - useful if you're multitasking waiting for the breakpoint to be hit!" +longDescription: "Stop constantly checking if your breakpoint was hit. Breakpoint Notifier alerts you with visual and audio cues when debugging pauses at a breakpoint, letting you multitask with confidence." +category: "vs-extension" +repoUrl: "https://github.com/CodingWithCalvin/VS-BreakpointNotifier" +techStack: ["C#", ".NET Framework", "VSIX", "Visual Studio SDK"] +language: "C#" +status: "maintained" +startDate: "2023-04-05" +stars: 5 +marketplace: + type: "vs-marketplace" + url: "https://marketplace.visualstudio.com/items?itemName=CodingWithCalvin.VS-BreakpointNotifier" +--- + + + +When debugging long-running processes or waiting for specific conditions, you don't want to stare at Visual Studio. Breakpoint Notifier lets you work on other things and notifies you when your breakpoint is finally hit. + +## Features + +- Audio notifications when breakpoints are hit +- Visual alerts to grab your attention +- Configurable notification settings +- Works with conditional breakpoints too diff --git a/src/content/projects/vs-couchbaseexplorer/cover.png b/src/content/projects/vs-couchbaseexplorer/cover.png new file mode 100644 index 0000000..00cc05e Binary files /dev/null and b/src/content/projects/vs-couchbaseexplorer/cover.png differ diff --git a/src/content/projects/vs-couchbaseexplorer/index.md b/src/content/projects/vs-couchbaseexplorer/index.md new file mode 100644 index 0000000..645cf24 --- /dev/null +++ b/src/content/projects/vs-couchbaseexplorer/index.md @@ -0,0 +1,26 @@ +--- +title: "Couchbase Explorer" +description: "A Visual Studio extension that adds a host of Couchbase & Capella data management and query capabilities right into your IDE!" +longDescription: "Bring Couchbase database management directly into Visual Studio. Browse buckets, execute N1QL queries, and manage your data without leaving your development environment." +category: "vs-extension" +repoUrl: "https://github.com/CodingWithCalvin/VS-CouchbaseExplorer" +techStack: ["C#", ".NET Framework", "VSIX", "Visual Studio SDK", "Couchbase"] +language: "C#" +status: "maintained" +startDate: "2023-04-13" +stars: 1 +marketplace: + type: "vs-marketplace" + url: "https://marketplace.visualstudio.com/items?itemName=CodingWithCalvin.VS-CouchbaseExplorer" +--- + + + +Couchbase Explorer brings database management capabilities directly into Visual Studio, so you can work with your Couchbase or Capella data without context switching. + +## Features + +- Browse Couchbase buckets and collections +- Execute N1QL queries +- View and edit documents +- Connect to Couchbase and Capella clusters diff --git a/src/content/projects/vs-debugalizers/cover.png b/src/content/projects/vs-debugalizers/cover.png new file mode 100644 index 0000000..6b9175b Binary files /dev/null and b/src/content/projects/vs-debugalizers/cover.png differ diff --git a/src/content/projects/vs-debugalizers/index.md b/src/content/projects/vs-debugalizers/index.md new file mode 100644 index 0000000..4f01785 --- /dev/null +++ b/src/content/projects/vs-debugalizers/index.md @@ -0,0 +1,26 @@ +--- +title: "Debugalizers" +description: "A powerful collection of debug visualizers for Visual Studio, providing beautiful formatting, syntax highlighting, and specialized views for common string data types." +longDescription: "Stop squinting at raw JSON in the debugger! Debugalizers provides beautiful, syntax-highlighted visualizers for JSON, XML, and other common string formats, making debugging a much more pleasant experience." +category: "vs-extension" +repoUrl: "https://github.com/CodingWithCalvin/VS-Debugalizers" +techStack: ["C#", ".NET", "VSIX", "Visual Studio SDK", "Debug Visualizers"] +language: "C#" +status: "active" +startDate: "2026-01-24" +stars: 3 +marketplace: + type: "vs-marketplace" + url: "https://marketplace.visualstudio.com/items?itemName=CodingWithCalvin.VS-Debugalizers" +--- + + + +Debugging strings containing JSON, XML, or other structured data is painful with the default visualizer. Debugalizers provides specialized views with syntax highlighting and proper formatting. + +## Features + +- JSON visualizer with syntax highlighting +- XML visualizer with formatting +- Specialized views for common data types +- Beautiful, readable output diff --git a/src/content/projects/vs-gitranger/cover.png b/src/content/projects/vs-gitranger/cover.png new file mode 100644 index 0000000..ca7f428 Binary files /dev/null and b/src/content/projects/vs-gitranger/cover.png differ diff --git a/src/content/projects/vs-gitranger/index.md b/src/content/projects/vs-gitranger/index.md new file mode 100644 index 0000000..473c3af --- /dev/null +++ b/src/content/projects/vs-gitranger/index.md @@ -0,0 +1,27 @@ +--- +title: "GitRanger" +description: "A visually exciting Git management extension for Visual Studio 2022/2026, bringing GitLens-style functionality with theme-adaptive vibrant colors." +longDescription: "GitRanger brings the best of GitLens to Visual Studio with beautiful, theme-adaptive visualizations. See who changed what and when, right in your editor, with vibrant colors that make Git history a pleasure to explore." +category: "vs-extension" +repoUrl: "https://github.com/CodingWithCalvin/VS-GitRanger" +techStack: ["C#", ".NET", "VSIX", "Visual Studio SDK", "Git"] +language: "C#" +status: "active" +startDate: "2025-12-23" +stars: 3 +marketplace: + type: "vs-marketplace" + url: "https://marketplace.visualstudio.com/items?itemName=CodingWithCalvin.VS-GitRanger" +--- + + + +GitRanger enhances your Git workflow in Visual Studio with rich inline annotations, blame information, and file history - all presented with beautiful, theme-aware colors. + +## Features + +- Inline blame annotations +- File history visualization +- Theme-adaptive colors +- Quick navigation to commits +- Author information at a glance diff --git a/src/content/projects/vs-mcpserver/cover.png b/src/content/projects/vs-mcpserver/cover.png new file mode 100644 index 0000000..ab57e4d Binary files /dev/null and b/src/content/projects/vs-mcpserver/cover.png differ diff --git a/src/content/projects/vs-mcpserver/index.md b/src/content/projects/vs-mcpserver/index.md new file mode 100644 index 0000000..95ee699 --- /dev/null +++ b/src/content/projects/vs-mcpserver/index.md @@ -0,0 +1,26 @@ +--- +title: "VS MCP Server" +description: "VS MCP Server exposes Visual Studio features through the Model Context Protocol (MCP), enabling AI assistants like Claude to interact with your IDE programmatically." +longDescription: "Bridge the gap between AI assistants and your development environment. VS MCP Server exposes Visual Studio's powerful features through the Model Context Protocol, letting tools like Claude open files, read code, build projects, and more - all through natural conversation." +category: "vs-extension" +repoUrl: "https://github.com/CodingWithCalvin/VS-MCPServer" +techStack: ["C#", ".NET", "VSIX", "Visual Studio SDK", "MCP"] +language: "C#" +status: "active" +startDate: "2026-01-12" +stars: 5 +marketplace: + type: "vs-marketplace" + url: "https://marketplace.visualstudio.com/items?itemName=CodingWithCalvin.VS-MCPServer" +--- + + + +VS MCP Server brings AI-powered development to Visual Studio by implementing the Model Context Protocol. This enables AI assistants to directly interact with your IDE, opening new possibilities for AI-assisted coding. + +## Features + +- Open and read files through AI conversations +- Build projects programmatically +- Navigate code semantically +- Works with Claude and other MCP-compatible AI tools diff --git a/src/content/projects/vs-openbinfolder/cover.png b/src/content/projects/vs-openbinfolder/cover.png new file mode 100644 index 0000000..34210d2 Binary files /dev/null and b/src/content/projects/vs-openbinfolder/cover.png differ diff --git a/src/content/projects/vs-openbinfolder/index.md b/src/content/projects/vs-openbinfolder/index.md new file mode 100644 index 0000000..fe29ba4 --- /dev/null +++ b/src/content/projects/vs-openbinfolder/index.md @@ -0,0 +1,26 @@ +--- +title: "Open Bin Folder" +description: "A Visual Studio extension that adds a right-click context menu command that allows you to open the project's output directory (the 'bin' folder) in Windows File Explorer." +longDescription: "Quickly access your project's compiled output without navigating through folders manually. Right-click on any project and instantly open its bin folder in Windows File Explorer." +category: "vs-extension" +repoUrl: "https://github.com/CodingWithCalvin/VS-OpenBinFolder" +techStack: ["C#", ".NET Framework", "VSIX", "Visual Studio SDK"] +language: "C#" +status: "maintained" +startDate: "2023-03-24" +stars: 6 +marketplace: + type: "vs-marketplace" + url: "https://marketplace.visualstudio.com/items?itemName=CodingWithCalvin.VS-OpenBinFolder" +--- + + + +A simple productivity extension that saves you from navigating through your project structure to find the bin folder. Right-click, open, done. + +## Features + +- Opens the project's output directory in File Explorer +- Context menu integration in Solution Explorer +- Works with any project type +- Respects the active build configuration diff --git a/src/content/projects/vs-openinnotepadplusplus/cover.png b/src/content/projects/vs-openinnotepadplusplus/cover.png new file mode 100644 index 0000000..52b45d7 Binary files /dev/null and b/src/content/projects/vs-openinnotepadplusplus/cover.png differ diff --git a/src/content/projects/vs-openinnotepadplusplus/index.md b/src/content/projects/vs-openinnotepadplusplus/index.md new file mode 100644 index 0000000..087e111 --- /dev/null +++ b/src/content/projects/vs-openinnotepadplusplus/index.md @@ -0,0 +1,26 @@ +--- +title: "Open In Notepad++" +description: "A Visual Studio extension that adds a right-click context menu command that allows you to open the solution file, project file, or file in Notepad++." +longDescription: "Sometimes you just need to edit a file in a lightweight editor. This extension adds convenient context menu commands throughout Visual Studio to open any file, project, or solution directly in Notepad++." +category: "vs-extension" +repoUrl: "https://github.com/CodingWithCalvin/VS-OpenInNotepadPlusPlus" +techStack: ["C#", ".NET Framework", "VSIX", "Visual Studio SDK"] +language: "C#" +status: "maintained" +startDate: "2018-08-11" +stars: 12 +marketplace: + type: "vs-marketplace" + url: "https://marketplace.visualstudio.com/items?itemName=CodingWithCalvin.VS-OpenInNotepadPlusPlus" +--- + + + +A simple but essential extension for developers who use Notepad++ alongside Visual Studio. Right-click on any file, project, or solution to instantly open it in Notepad++. + +## Features + +- Open files directly in Notepad++ +- Open project files (.csproj, .vbproj, etc.) +- Open solution files (.sln) +- Context menu integration in Solution Explorer diff --git a/src/content/projects/vs-projectrenamifier/cover.png b/src/content/projects/vs-projectrenamifier/cover.png new file mode 100644 index 0000000..640d354 Binary files /dev/null and b/src/content/projects/vs-projectrenamifier/cover.png differ diff --git a/src/content/projects/vs-projectrenamifier/index.md b/src/content/projects/vs-projectrenamifier/index.md new file mode 100644 index 0000000..a5b98f5 --- /dev/null +++ b/src/content/projects/vs-projectrenamifier/index.md @@ -0,0 +1,27 @@ +--- +title: "Project Renamifier" +description: "A Visual Studio extension that allows you to safely - and COMPLETELY - rename a Project from within Visual Studio!" +longDescription: "Renaming a project in Visual Studio has always been a pain. This extension handles everything: the filename, parent folder name, namespace, and all references throughout your solution. One click, complete rename." +category: "vs-extension" +repoUrl: "https://github.com/CodingWithCalvin/VS-ProjectRenamifier" +techStack: ["C#", ".NET Framework", "VSIX", "Visual Studio SDK", "Roslyn"] +language: "C#" +status: "active" +startDate: "2024-05-17" +stars: 9 +marketplace: + type: "vs-marketplace" + url: "https://marketplace.visualstudio.com/items?itemName=CodingWithCalvin.VS-ProjectRenamifier" +--- + + + +Project Renamifier takes the headache out of renaming projects. Instead of manually updating filenames, folders, namespaces, and references, just use this extension and it handles everything automatically. + +## Features + +- Renames the project file +- Renames the parent folder (if it matches the project name) +- Updates the namespace throughout all files +- Updates references in the solution file +- Updates references in other projects diff --git a/src/content/projects/vs-superclean/cover.png b/src/content/projects/vs-superclean/cover.png new file mode 100644 index 0000000..2644683 Binary files /dev/null and b/src/content/projects/vs-superclean/cover.png differ diff --git a/src/content/projects/vs-superclean/index.md b/src/content/projects/vs-superclean/index.md new file mode 100644 index 0000000..f8ef6ae --- /dev/null +++ b/src/content/projects/vs-superclean/index.md @@ -0,0 +1,26 @@ +--- +title: "Super Clean" +description: "A Visual Studio extension that adds a right-click context menu command to recursively delete the selected project's bin & obj folders, or all projects in the solution." +longDescription: "When 'Clean Solution' isn't enough, Super Clean completely obliterates your bin and obj folders. Perfect for those times when you need a truly fresh build or when mysterious build issues strike." +category: "vs-extension" +repoUrl: "https://github.com/CodingWithCalvin/VS-SuperClean" +techStack: ["C#", ".NET Framework", "VSIX", "Visual Studio SDK"] +language: "C#" +status: "maintained" +startDate: "2023-03-26" +stars: 5 +marketplace: + type: "vs-marketplace" + url: "https://marketplace.visualstudio.com/items?itemName=CodingWithCalvin.VS-SuperClean" +--- + + + +Visual Studio's built-in Clean command doesn't always remove everything. Super Clean takes a more aggressive approach by completely deleting bin and obj folders, ensuring you get a truly clean slate for your next build. + +## Features + +- Clean individual projects +- Clean entire solutions +- Recursively removes bin and obj folders +- Context menu integration in Solution Explorer diff --git a/src/content/projects/vs-vsixmanifestdesigner/cover.png b/src/content/projects/vs-vsixmanifestdesigner/cover.png new file mode 100644 index 0000000..0930fc3 Binary files /dev/null and b/src/content/projects/vs-vsixmanifestdesigner/cover.png differ diff --git a/src/content/projects/vs-vsixmanifestdesigner/index.md b/src/content/projects/vs-vsixmanifestdesigner/index.md new file mode 100644 index 0000000..74debae --- /dev/null +++ b/src/content/projects/vs-vsixmanifestdesigner/index.md @@ -0,0 +1,26 @@ +--- +title: "VSIX Manifest Designer" +description: "The built-in VSIX manifest designer in Visual Studio is old, outdated, and rather ugly. VSIX Manifest Designer is a modern replacement with a clean, intuitive UI." +longDescription: "The default VSIX manifest designer hasn't been updated in years. This extension provides a modern, clean replacement that feels right at home in Visual Studio 2022, making extension development more pleasant." +category: "vs-extension" +repoUrl: "https://github.com/CodingWithCalvin/VS-VsixManifestDesigner" +techStack: ["C#", ".NET", "VSIX", "Visual Studio SDK", "WPF"] +language: "C#" +status: "active" +startDate: "2026-01-04" +stars: 2 +marketplace: + type: "vs-marketplace" + url: "https://marketplace.visualstudio.com/items?itemName=CodingWithCalvin.VS-VsixManifestDesigner" +--- + + + +The built-in VSIX manifest designer feels like it's from another era. VSIX Manifest Designer provides a fresh, modern UI for editing your extension manifests with all the fields you need in an intuitive layout. + +## Features + +- Modern, clean UI design +- All manifest fields easily accessible +- Validation and error checking +- Feels native to Visual Studio 2022 diff --git a/src/content/projects/vsc-mcpserver/cover.png b/src/content/projects/vsc-mcpserver/cover.png new file mode 100644 index 0000000..229f68c Binary files /dev/null and b/src/content/projects/vsc-mcpserver/cover.png differ diff --git a/src/content/projects/vsc-mcpserver/index.md b/src/content/projects/vsc-mcpserver/index.md new file mode 100644 index 0000000..466c879 --- /dev/null +++ b/src/content/projects/vsc-mcpserver/index.md @@ -0,0 +1,27 @@ +--- +title: "VSC MCP Server" +description: "A Visual Studio Code extension that exposes an MCP server, giving AI tools like Claude direct access to VS Code's semantic code understanding capabilities." +longDescription: "VSC MCP Server bridges AI assistants with VS Code's powerful language services. Get go-to-definition, find references, completions, diagnostics, and more - all accessible through natural conversation with your AI tools." +category: "vscode-extension" +repoUrl: "https://github.com/CodingWithCalvin/VSC-MCPServer" +techStack: ["TypeScript", "Node.js", "VS Code Extension API", "MCP"] +language: "TypeScript" +status: "active" +startDate: "2026-01-14" +stars: 0 +marketplace: + type: "vs-marketplace" + url: "https://marketplace.visualstudio.com/items?itemName=CodingWithCalvin.VSC-MCPServer" +--- + + + +VSC MCP Server exposes VS Code's rich language understanding to AI assistants through the Model Context Protocol. This enables semantic code navigation, intelligent completions, and more through AI conversations. + +## Features + +- Go-to-definition through AI conversations +- Find all references +- Get intelligent completions +- Access diagnostics and errors +- Works with Claude, Cursor, and other MCP tools diff --git a/src/content/projects/vscwhere/cover.png b/src/content/projects/vscwhere/cover.png new file mode 100644 index 0000000..2f33852 Binary files /dev/null and b/src/content/projects/vscwhere/cover.png differ diff --git a/src/content/projects/vscwhere/index.md b/src/content/projects/vscwhere/index.md new file mode 100644 index 0000000..1daf24c --- /dev/null +++ b/src/content/projects/vscwhere/index.md @@ -0,0 +1,23 @@ +--- +title: "VSCWhere" +description: "A command-line tool to locate Visual Studio Code installations on Windows, inspired by vswhere." +longDescription: "Inspired by Microsoft's vswhere tool, VSCWhere helps you programmatically locate Visual Studio Code installations on Windows. Perfect for build scripts and automation." +category: "cli-tool" +repoUrl: "https://github.com/CodingWithCalvin/VSCWhere" +techStack: ["Rust", "CLI", "Windows"] +language: "Rust" +status: "active" +startDate: "2026-01-06" +stars: 1 +--- + + + +VSCWhere is a command-line tool that locates VS Code installations on Windows, similar to how vswhere locates Visual Studio. Useful for build scripts, automation, and tooling. + +## Features + +- Locate VS Code installations programmatically +- JSON output for easy parsing +- Fast (written in Rust) +- Inspired by Microsoft's vswhere diff --git a/src/content/projects/vsix-guide/cover.png b/src/content/projects/vsix-guide/cover.png new file mode 100644 index 0000000..fe738d5 Binary files /dev/null and b/src/content/projects/vsix-guide/cover.png differ diff --git a/src/content/projects/vsix-guide/index.md b/src/content/projects/vsix-guide/index.md new file mode 100644 index 0000000..d169b1d --- /dev/null +++ b/src/content/projects/vsix-guide/index.md @@ -0,0 +1,24 @@ +--- +title: "VSIX Guide" +description: "Comprehensive documentation for Visual Studio extension development." +longDescription: "The definitive guide to building Visual Studio extensions. From getting started to advanced topics, VSIX Guide provides comprehensive documentation for extension developers." +category: "documentation" +repoUrl: "https://github.com/CodingWithCalvin/vsix.guide" +demoUrl: "https://vsix.guide" +techStack: ["MDX", "Astro", "Documentation"] +language: "MDX" +status: "active" +startDate: "2026-01-29" +stars: 0 +--- + + + +VSIX Guide is a comprehensive documentation site for Visual Studio extension development. Whether you're just getting started or looking for advanced techniques, this guide has you covered. + +## Features + +- Getting started tutorials +- API reference documentation +- Best practices and patterns +- Real-world examples diff --git a/src/content/projects/vsixsdk/cover.png b/src/content/projects/vsixsdk/cover.png new file mode 100644 index 0000000..bd1eea3 Binary files /dev/null and b/src/content/projects/vsixsdk/cover.png differ diff --git a/src/content/projects/vsixsdk/index.md b/src/content/projects/vsixsdk/index.md new file mode 100644 index 0000000..06c9f4d --- /dev/null +++ b/src/content/projects/vsixsdk/index.md @@ -0,0 +1,27 @@ +--- +title: "VsixSdk" +description: "An MSBuild SDK for building Visual Studio extensions (VSIX) using modern SDK-style projects." +longDescription: "VsixSdk is a modern MSBuild SDK that simplifies Visual Studio extension development by enabling SDK-style projects. Say goodbye to verbose .csproj files and hello to clean, maintainable extension projects with all the benefits of modern .NET tooling." +category: "nuget-package" +repoUrl: "https://github.com/CodingWithCalvin/VsixSdk" +techStack: ["C#", ".NET", "MSBuild", "VSIX", "Visual Studio SDK"] +language: "C#" +status: "active" +startDate: "2025-12-24" +stars: 21 +marketplace: + type: "nuget" + url: "https://www.nuget.org/packages/VsixSdk" +--- + + + +VsixSdk revolutionizes Visual Studio extension development by bringing modern SDK-style projects to VSIX development. No more wrestling with hundreds of lines of XML in your project files. + +## Features + +- SDK-style project support for VSIX projects +- Simplified project files with sensible defaults +- Full compatibility with Visual Studio 2022+ +- Automatic reference management +- Built-in support for common extension scenarios diff --git a/src/content/projects/vstoolbox/cover.png b/src/content/projects/vstoolbox/cover.png new file mode 100644 index 0000000..84da93f Binary files /dev/null and b/src/content/projects/vstoolbox/cover.png differ diff --git a/src/content/projects/vstoolbox/index.md b/src/content/projects/vstoolbox/index.md new file mode 100644 index 0000000..91702d8 --- /dev/null +++ b/src/content/projects/vstoolbox/index.md @@ -0,0 +1,23 @@ +--- +title: "VS Toolbox" +description: "Visual Studio Toolbox is a sleek system tray application for Windows that helps you manage all your Visual Studio installations in one place." +longDescription: "Think of VS Toolbox as your personal command center for Visual Studio. Quickly launch any installed version, access recent solutions, and manage your Visual Studio installations - all from a convenient system tray app." +category: "desktop-app" +repoUrl: "https://github.com/CodingWithCalvin/VSToolbox" +techStack: ["C#", ".NET", "WPF", "Windows"] +language: "C#" +status: "active" +startDate: "2025-12-30" +stars: 4 +--- + + + +VS Toolbox provides a central hub for managing multiple Visual Studio installations. Launch specific versions, access recent projects, and keep track of all your VS installations from the system tray. + +## Features + +- System tray quick access +- Launch any installed Visual Studio version +- Access recent solutions +- Clean, modern UI diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 3c0017f..c086dd9 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -92,8 +92,12 @@ const twitterHandle = "@_CalvinAllen"; + + + + diff --git a/src/layouts/ProjectLayout.astro b/src/layouts/ProjectLayout.astro new file mode 100644 index 0000000..5da5a35 --- /dev/null +++ b/src/layouts/ProjectLayout.astro @@ -0,0 +1,237 @@ +--- +import { Image } from 'astro:assets'; +import type { ImageMetadata } from 'astro'; +import BaseLayout from './BaseLayout.astro'; +import TechStackBadges from '../components/TechStackBadges.astro'; +import { getStatusColor, formatCount } from '../lib/projects'; + +interface Props { + title: string; + slug: string; + description: string; + longDescription?: string; + repoUrl: string; + demoUrl?: string; + docsUrl?: string; + techStack: string[]; + language: string; + status: 'active' | 'maintained' | 'archived' | 'experimental'; + startDate: Date; + lastUpdated?: Date; + image?: ImageMetadata; + marketplace?: { + type: 'vs-marketplace' | 'nuget' | 'npm' | 'other'; + url: string; + }; + stars?: number; + forks?: number; + downloads?: number; + rating?: number; + ratingCount?: number; + latestRelease?: { + version: string; + publishedAt: string; + url: string; + }; +} + +const { + title, + slug, + description, + longDescription, + repoUrl, + demoUrl, + docsUrl, + techStack, + language, + status, + startDate, + lastUpdated, + image, + marketplace, + stars, + forks, + downloads, + rating, + ratingCount, + latestRelease, +} = Astro.props; + +const formattedStartDate = startDate.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + timeZone: 'America/New_York', +}); + +const formattedLastUpdated = lastUpdated?.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'America/New_York', +}); + +const marketplaceLabels = { + 'vs-marketplace': 'VS Marketplace', + 'nuget': 'NuGet', + 'npm': 'npm', + 'other': 'Marketplace', +}; + +// Get the image URL for OG tags +const ogImageUrl = image?.src; +--- + + +
+
+ + + + {image && ( +
+ {title} +
+ )} + +
+ +
+ +
+ + + + + GitHub + + + {marketplace && ( + + + + + {marketplaceLabels[marketplace.type]} + + )} + + {demoUrl && ( + + + + + + Demo + + )} + + {docsUrl && ( + + + + + Docs + + )} +
+ + {(stars !== undefined || forks !== undefined || downloads !== undefined || rating !== undefined || latestRelease) && ( +
+ {stars !== undefined && ( +
+ + + + {formatCount(stars)} + stars +
+ )} + {forks !== undefined && forks > 0 && ( +
+ + + + + {formatCount(forks)} + forks +
+ )} + {downloads !== undefined && ( +
+ + + + {formatCount(downloads)} + installs +
+ )} + {rating !== undefined && ( +
+
+ {[1, 2, 3, 4, 5].map(star => ( + + + + ))} +
+ {rating.toFixed(1)} + {ratingCount !== undefined && ( + ({ratingCount} reviews) + )} +
+ )} + {latestRelease && ( + + + + + {latestRelease.version} + latest + + )} +
+ )} + +

{title}

+ +
+ +
+
+
+
diff --git a/src/lib/marketplace-stats.ts b/src/lib/marketplace-stats.ts new file mode 100644 index 0000000..313e675 --- /dev/null +++ b/src/lib/marketplace-stats.ts @@ -0,0 +1,212 @@ +/** + * 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 new file mode 100644 index 0000000..7f280a0 --- /dev/null +++ b/src/lib/projects.ts @@ -0,0 +1,106 @@ +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 all cover images from project directories +const coverImages = import.meta.glob<{ default: ImageMetadata }>( + '/src/content/projects/**/cover.png', + { eager: true } +); + +export type ProjectWithImage = Awaited>>[number] & { + resolvedImage: ImageMetadata; + marketplaceStats?: MarketplaceStats; + githubStats?: GitHubStats; +}; + +/** + * Get a project's cover image, falling back to cover.png in the project directory, + * then to the default cover image + */ +export function getProjectImage(project: Awaited>>[number]): ImageMetadata { + if (project.data.image) { + return project.data.image; + } + + const coverPath = `/src/content/projects/${project.id}/cover.png`; + const coverModule = coverImages[coverPath]; + + return coverModule?.default ?? defaultCover; +} + +/** + * Get all projects with resolved cover images + */ +export async function getProjectsWithImages(): Promise { + const projects = await getCollection('projects'); + return projects.map(project => ({ + ...project, + resolvedImage: getProjectImage(project), + })); +} + +/** + * Get all projects with resolved cover images AND marketplace/GitHub stats + * Use this for pages that need live data + */ +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; +} + +/** + * Get the slug from a project ID (removes /index suffix if present) + */ +export function getProjectSlug(id: string): string { + return id.replace(/\/index$/, ''); +} + +/** + * Get Tailwind classes for status badge colors + */ +export function getStatusColor(status: 'active' | 'maintained' | 'archived' | 'experimental'): string { + const colors = { + active: 'bg-green-500/20 text-green-400', + maintained: 'bg-blue-500/20 text-blue-400', + archived: 'bg-gray-500/20 text-gray-400', + experimental: 'bg-yellow-500/20 text-yellow-400', + }; + return colors[status]; +} + +/** + * Format download/star counts with K/M suffix + */ +export function formatCount(count: number | undefined): string { + if (count === undefined) return ''; + if (count >= 1000000) { + return `${(count / 1000000).toFixed(1)}M`; + } + if (count >= 1000) { + return `${(count / 1000).toFixed(1)}K`; + } + return count.toString(); +} diff --git a/src/pages/projects.xml.ts b/src/pages/projects.xml.ts new file mode 100644 index 0000000..f849f3e --- /dev/null +++ b/src/pages/projects.xml.ts @@ -0,0 +1,75 @@ +import rss from '@astrojs/rss'; +import { getCollection } from 'astro:content'; +import type { APIContext } from 'astro'; +import sanitizeHtml from 'sanitize-html'; +import MarkdownIt from 'markdown-it'; +import { getProjectImage, getProjectSlug } from '../lib/projects'; + +const parser = new MarkdownIt(); + +function getMimeType(src: string): string { + const ext = src.split('.').pop()?.toLowerCase(); + const mimeTypes: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + avif: 'image/avif', + }; + return mimeTypes[ext || ''] || 'image/png'; +} + +export async function GET(context: APIContext) { + const projects = await getCollection('projects'); + // Sort by GitHub stars (highest first) + const sortedProjects = projects.sort((a, b) => (b.data.stars ?? 0) - (a.data.stars ?? 0)); + const site = context.site!.toString().replace(/\/$/, ''); + + return rss({ + title: 'Coding With Calvin - Open Source Projects', + description: 'Open source projects created and maintained by Calvin Allen', + site: context.site!, + items: sortedProjects.map((project) => { + const slug = getProjectSlug(project.id); + const item: Record = { + title: project.data.title, + pubDate: project.data.startDate, + description: project.data.description, + link: `/projects/${slug}/`, + categories: [project.data.category, ...project.data.techStack], + content: sanitizeHtml(parser.render(project.body ?? ''), { + allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']), + }), + }; + + const image = getProjectImage(project); + const imageUrl = `${site}${image.src}`; + const imageType = getMimeType(image.src); + + item.enclosure = { + url: imageUrl, + type: imageType, + length: 0, + }; + + // Add media:content and project-specific metadata + let customData = ``; + customData += `${project.data.category}`; + customData += `${project.data.language}`; + customData += `${project.data.status}`; + customData += `${project.data.repoUrl}`; + if (project.data.stars) { + customData += `${project.data.stars}`; + } + item.customData = customData; + + return item; + }), + xmlns: { + media: 'http://search.yahoo.com/mrss/', + project: 'https://codingwithcalvin.net/ns/project', + }, + customData: `en-us`, + }); +} diff --git a/src/pages/projects/[...page].astro b/src/pages/projects/[...page].astro new file mode 100644 index 0000000..b01bed6 --- /dev/null +++ b/src/pages/projects/[...page].astro @@ -0,0 +1,53 @@ +--- +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'; + +export const getStaticPaths = (async ({ paginate }) => { + const allProjects = await getProjectsWithImages(); + // Sort by GitHub stars (highest first) + const sortedProjects = allProjects.sort((a, b) => { + return (b.data.stars ?? 0) - (a.data.stars ?? 0); + }); + + return paginate(sortedProjects, { pageSize: 9 }); +}) satisfies GetStaticPaths; + +const { page } = Astro.props; +--- + + +
+
+

Open Source Projects

+

+ A collection of open source projects I've created and maintain, sorted by GitHub stars. +

+ +
+ {page.data.map(project => ( + + ))} +
+ + {page.lastPage > 1 && ( + + )} +
+
+
diff --git a/src/pages/projects/[slug].astro b/src/pages/projects/[slug].astro new file mode 100644 index 0000000..9a0dc0e --- /dev/null +++ b/src/pages/projects/[slug].astro @@ -0,0 +1,56 @@ +--- +import type { GetStaticPaths } from 'astro'; +import { render } from 'astro:content'; +import ProjectLayout from '../../layouts/ProjectLayout.astro'; +import { getProjectsWithStats, getProjectSlug } from '../../lib/projects'; + +export const getStaticPaths = (async () => { + const projects = await getProjectsWithStats(); + return projects.map(project => ({ + params: { slug: getProjectSlug(project.id) }, + props: { project, slug: getProjectSlug(project.id) }, + })); +}) satisfies GetStaticPaths; + +const { project, slug } = Astro.props; +const { Content } = await render(project); + +// Merge frontmatter data with live stats +// Live stats take precedence over static frontmatter values +const marketplaceStats = project.marketplaceStats || {}; +const githubStats = project.githubStats; + +const downloads = marketplaceStats.downloads ?? marketplaceStats.installs ?? project.data.downloads; +const rating = marketplaceStats.rating; +const ratingCount = marketplaceStats.ratingCount; + +// GitHub stats - live data takes precedence +const stars = githubStats?.stars ?? project.data.stars; +const forks = githubStats?.forks; +const latestRelease = githubStats?.latestRelease; +--- + + + +