#!/usr/bin/env node /** * Generate JSON metadata files for the GitHub Pages website. * This script extracts metadata from agents, instructions, skills, hooks, and plugins * and writes them to website/data/ for client-side search and display. */ import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { execSync } from "child_process"; import { AGENTS_DIR, COOKBOOK_DIR, EXTENSIONS_DIR, HOOKS_DIR, INSTRUCTIONS_DIR, PLUGINS_DIR, ROOT_FOLDER, SKILLS_DIR, WORKFLOWS_DIR, } from "./constants.mjs"; import { getGitFileDates } from "./utils/git-dates.mjs"; import { parseFrontmatter, parseHookMetadata, parseSkillMetadata, parseWorkflowMetadata, parseYamlFile, } from "./yaml-parser.mjs"; const __filename = fileURLToPath(import.meta.url); const WEBSITE_DIR = path.join(ROOT_FOLDER, "website"); const WEBSITE_DATA_DIR = path.join(WEBSITE_DIR, "public", "data"); const WEBSITE_SOURCE_DATA_DIR = path.join(WEBSITE_DIR, "data"); /** * Ensure the output directory exists */ function ensureDataDir() { if (!fs.existsSync(WEBSITE_DATA_DIR)) { fs.mkdirSync(WEBSITE_DATA_DIR, { recursive: true }); } } /** * Extract title from filename or frontmatter */ function extractTitle(filePath, frontmatter) { if (frontmatter?.name) { return frontmatter.name .split("-") .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); } // Fallback to filename const basename = path.basename(filePath); const name = basename .replace(/\.(agent|prompt|instructions)\.md$/, "") .replace(/\.md$/, ""); return name .split("-") .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); } /** * Convert kebab/snake names into readable titles. */ function formatDisplayName(value) { const acronymMap = new Map([ ["ai", "AI"], ["api", "API"], ["cli", "CLI"], ["css", "CSS"], ["html", "HTML"], ["json", "JSON"], ["llm", "LLM"], ["mcp", "MCP"], ["ui", "UI"], ["ux", "UX"], ["vscode", "VS Code"], ]); return value .split(/[-_]+/) .filter(Boolean) .map((part) => { const lower = part.toLowerCase(); if (acronymMap.has(lower)) { return acronymMap.get(lower); } return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(); }) .join(" "); } function normalizeText(value, fallback = "") { return typeof value === "string" ? value.trim() : fallback; } /** * Find the latest git-modified date for any file under a directory. */ function getDirectoryLastUpdated(gitDates, relativeDirPath) { const prefix = `${relativeDirPath}/`; let latestDate = null; let latestTime = 0; for (const [filePath, date] of gitDates.entries()) { if (!filePath.startsWith(prefix)) continue; const timestamp = Date.parse(date); if (!Number.isNaN(timestamp) && timestamp > latestTime) { latestTime = timestamp; latestDate = date; } } return latestDate; } /** * Get the current commit SHA for the checked-out repository. */ function getCurrentCommitSha() { return execSync("git --no-pager rev-parse HEAD", { cwd: ROOT_FOLDER, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], }).trim(); } /** * Generate agents metadata */ function generateAgentsData(gitDates) { const agents = []; const files = fs .readdirSync(AGENTS_DIR) .filter((f) => f.endsWith(".agent.md")); // Track all unique values for filters const allModels = new Set(); const allTools = new Set(); for (const file of files) { const filePath = path.join(AGENTS_DIR, file); const frontmatter = parseFrontmatter(filePath); const relativePath = path .relative(ROOT_FOLDER, filePath) .replace(/\\/g, "/"); const model = frontmatter?.model || null; const tools = frontmatter?.tools || []; const handoffs = frontmatter?.handoffs || []; // Track unique values if (model) allModels.add(model); tools.forEach((t) => allTools.add(t)); agents.push({ id: file.replace(".agent.md", ""), title: extractTitle(filePath, frontmatter), description: frontmatter?.description || "", model: model, tools: tools, hasHandoffs: handoffs.length > 0, handoffs: handoffs.map((h) => ({ label: h.label || "", agent: h.agent || "", })), mcpServers: frontmatter?.["mcp-servers"] ? Object.keys(frontmatter["mcp-servers"]) : [], path: relativePath, filename: file, lastUpdated: gitDates.get(relativePath) || null, }); } // Sort and return with filter metadata const sortedAgents = agents.sort((a, b) => a.title.localeCompare(b.title)); return { items: sortedAgents, filters: { models: ["(none)", ...Array.from(allModels).sort()], tools: Array.from(allTools).sort(), }, }; } /** * Generate hooks metadata */ /** * Generate hooks metadata (similar to skills - folder-based) */ function generateHooksData(gitDates) { const hooks = []; // Check if hooks directory exists if (!fs.existsSync(HOOKS_DIR)) { return { items: hooks, filters: { hooks: [], tags: [], }, }; } // Get all hook folders (directories) const hookFolders = fs.readdirSync(HOOKS_DIR).filter((file) => { const filePath = path.join(HOOKS_DIR, file); return fs.statSync(filePath).isDirectory(); }); // Track all unique values for filters const allHookTypes = new Set(); const allTags = new Set(); for (const folder of hookFolders) { const hookPath = path.join(HOOKS_DIR, folder); const metadata = parseHookMetadata(hookPath); if (!metadata) continue; const relativePath = path .relative(ROOT_FOLDER, hookPath) .replace(/\\/g, "/"); const readmeRelativePath = `${relativePath}/README.md`; // Track unique values (metadata.hooks || []).forEach((h) => allHookTypes.add(h)); (metadata.tags || []).forEach((t) => allTags.add(t)); hooks.push({ id: folder, title: metadata.name, description: metadata.description, hooks: metadata.hooks || [], tags: metadata.tags || [], assets: metadata.assets || [], path: relativePath, readmeFile: readmeRelativePath, lastUpdated: gitDates.get(readmeRelativePath) || null, }); } // Sort and return with filter metadata const sortedHooks = hooks.sort((a, b) => a.title.localeCompare(b.title)); return { items: sortedHooks, filters: { hooks: Array.from(allHookTypes).sort(), tags: Array.from(allTags).sort(), }, }; } /** * Generate workflows metadata (flat .md files) */ function generateWorkflowsData(gitDates) { const workflows = []; if (!fs.existsSync(WORKFLOWS_DIR)) { return { items: workflows, filters: { triggers: [], }, }; } const workflowFiles = fs.readdirSync(WORKFLOWS_DIR).filter((file) => { return file.endsWith(".md") && file !== ".gitkeep"; }); const allTriggers = new Set(); for (const file of workflowFiles) { const filePath = path.join(WORKFLOWS_DIR, file); const metadata = parseWorkflowMetadata(filePath); if (!metadata) continue; const relativePath = path .relative(ROOT_FOLDER, filePath) .replace(/\\/g, "/"); (metadata.triggers || []).forEach((t) => allTriggers.add(t)); const id = path.basename(file, ".md"); workflows.push({ id, title: metadata.name, description: metadata.description, triggers: metadata.triggers || [], path: relativePath, lastUpdated: gitDates.get(relativePath) || null, }); } const sortedWorkflows = workflows.sort((a, b) => a.title.localeCompare(b.title) ); return { items: sortedWorkflows, filters: { triggers: Array.from(allTriggers).sort(), }, }; } /** * Parse applyTo field into an array of patterns */ function parseApplyToPatterns(applyTo) { if (!applyTo) return []; // Handle array format if (Array.isArray(applyTo)) { return applyTo.map((p) => p.trim()).filter((p) => p.length > 0); } // Handle string format (comma-separated) if (typeof applyTo === "string") { return applyTo .split(",") .map((p) => p.trim()) .filter((p) => p.length > 0); } return []; } /** * Extract file extension from a glob pattern */ function extractExtensionFromPattern(pattern) { // Match patterns like **.ts, **/*.js, *.py, etc. const match = pattern.match(/\*\.(\w+)$/); if (match) return `.${match[1]}`; // Match patterns like **/*.{ts,tsx} const braceMatch = pattern.match(/\*\.\{([^}]+)\}$/); if (braceMatch) { return braceMatch[1].split(",").map((ext) => `.${ext.trim()}`); } return null; } /** * Generate instructions metadata */ function generateInstructionsData(gitDates) { const instructions = []; const files = fs .readdirSync(INSTRUCTIONS_DIR) .filter((f) => f.endsWith(".instructions.md")); // Track all unique patterns and extensions for filters const allPatterns = new Set(); const allExtensions = new Set(); for (const file of files) { const filePath = path.join(INSTRUCTIONS_DIR, file); const frontmatter = parseFrontmatter(filePath); const relativePath = path .relative(ROOT_FOLDER, filePath) .replace(/\\/g, "/"); const applyToRaw = frontmatter?.applyTo || null; const applyToPatterns = parseApplyToPatterns(applyToRaw); // Extract extensions from patterns const extensions = []; for (const pattern of applyToPatterns) { allPatterns.add(pattern); const ext = extractExtensionFromPattern(pattern); if (ext) { if (Array.isArray(ext)) { ext.forEach((e) => { extensions.push(e); allExtensions.add(e); }); } else { extensions.push(ext); allExtensions.add(ext); } } } instructions.push({ id: file.replace(".instructions.md", ""), title: extractTitle(filePath, frontmatter), description: frontmatter?.description || "", applyTo: applyToRaw, applyToPatterns: applyToPatterns, extensions: [...new Set(extensions)], path: relativePath, filename: file, lastUpdated: gitDates.get(relativePath) || null, }); } const sortedInstructions = instructions.sort((a, b) => a.title.localeCompare(b.title) ); return { items: sortedInstructions, filters: { patterns: Array.from(allPatterns).sort(), extensions: ["(none)", ...Array.from(allExtensions).sort()], }, }; } /** * Generate skills metadata */ function generateSkillsData(gitDates) { const skills = []; if (!fs.existsSync(SKILLS_DIR)) { return { items: [], filters: { hasAssets: ["Yes", "No"] } }; } const folders = fs .readdirSync(SKILLS_DIR) .filter((f) => fs.statSync(path.join(SKILLS_DIR, f)).isDirectory()); for (const folder of folders) { const skillPath = path.join(SKILLS_DIR, folder); const metadata = parseSkillMetadata(skillPath); if (metadata) { const relativePath = path .relative(ROOT_FOLDER, skillPath) .replace(/\\/g, "/"); // Get all files in the skill folder recursively const files = getSkillFiles(skillPath, relativePath); // Get last updated from SKILL.md file const skillFilePath = `${relativePath}/SKILL.md`; const title = metadata.name .split("-") .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); const searchText = [ title, metadata.description, folder, metadata.name, relativePath, ] .join(" ") .toLowerCase(); skills.push({ id: folder, name: metadata.name, title, description: metadata.description, assets: metadata.assets, hasAssets: metadata.assets.length > 0, assetCount: metadata.assets.length, path: relativePath, skillFile: skillFilePath, files: files, lastUpdated: gitDates.get(skillFilePath) || null, searchText, }); } } const sortedSkills = skills.sort((a, b) => a.title.localeCompare(b.title)); return { items: sortedSkills, filters: { hasAssets: ["Yes", "No"], }, }; } /** * Get all files in a skill folder recursively */ function getSkillFiles(skillPath, relativePath) { const files = []; function walkDir(dir, relDir) { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); const relPath = relDir ? `${relDir}/${entry.name}` : entry.name; if (entry.isDirectory()) { walkDir(fullPath, relPath); } else { // Get file size const stats = fs.statSync(fullPath); files.push({ path: `${relativePath}/${relPath}`, name: relPath, size: stats.size, }); } } } walkDir(skillPath, ""); return files; } /** * Get all agent markdown files from a folder */ function getAgentFiles(agentDir, pluginRootPath) { if (!fs.existsSync(agentDir)) return []; return fs .readdirSync(agentDir) .filter((f) => f.endsWith(".md")) .map((f) => ({ kind: "agent", path: `${pluginRootPath}/agents/${f}`, })); } /** * Generate plugins metadata */ function generatePluginsData(gitDates) { const plugins = []; if (!fs.existsSync(PLUGINS_DIR)) { return { items: [], filters: { tags: [] } }; } const pluginDirs = fs .readdirSync(PLUGINS_DIR, { withFileTypes: true }) .filter((d) => d.isDirectory()); for (const dir of pluginDirs) { const pluginDir = path.join(PLUGINS_DIR, dir.name); const jsonPath = path.join(pluginDir, ".github/plugin", "plugin.json"); if (!fs.existsSync(jsonPath)) continue; try { const data = JSON.parse(fs.readFileSync(jsonPath, "utf-8")); const relPath = `plugins/${dir.name}`; const dates = gitDates[relPath] || gitDates[`${relPath}/`] || {}; const agentItems = (data.agents || []).flatMap((agent) => { const agentPath = agent.replace("./", ""); const fullPath = path.join(pluginDir, agentPath); if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) { return getAgentFiles(fullPath, relPath); } return [ { kind: "agent", path: `${relPath}/${agentPath}`, }, ]; }); // Build items list from spec fields (agents, commands, skills) const items = [ ...agentItems, ...(data.commands || []).map((p) => ({ kind: "prompt", path: p })), ...(data.skills || []).map((p) => ({ kind: "skill", path: p })), ]; const tags = data.keywords || data.tags || []; plugins.push({ id: dir.name, name: data.name || dir.name, description: data.description || "", path: relPath, tags: tags, itemCount: items.length, items: items, lastUpdated: dates.lastModified || null, searchText: `${data.name || dir.name} ${data.description || "" } ${tags.join(" ")}`.toLowerCase(), }); } catch (e) { console.warn(`Failed to parse plugin: ${dir.name}`, e.message); } } // Load external plugins from plugins/external.json const externalJsonPath = path.join(PLUGINS_DIR, "external.json"); if (fs.existsSync(externalJsonPath)) { try { const externalPlugins = JSON.parse( fs.readFileSync(externalJsonPath, "utf-8") ); if (Array.isArray(externalPlugins)) { let addedCount = 0; for (const ext of externalPlugins) { if (!ext.name || !ext.description) { console.warn( `Skipping external plugin with missing name/description` ); continue; } // Skip if a local plugin with the same name already exists if (plugins.some((p) => p.id === ext.name)) { console.warn( `Skipping external plugin "${ext.name}" — local plugin with same name exists` ); continue; } const tags = ext.keywords || ext.tags || []; plugins.push({ id: ext.name, name: ext.name, description: ext.description || "", path: `plugins/${ext.name}`, tags: tags, itemCount: 0, items: [], external: true, repository: ext.repository || null, homepage: ext.homepage || null, author: ext.author || null, license: ext.license || null, source: ext.source || null, lastUpdated: null, searchText: `${ext.name} ${ext.description || ""} ${tags.join( " " )} ${ext.author?.name || ""} ${ext.repository || ""}`.toLowerCase(), }); addedCount++; } console.log( ` ✓ Loaded ${addedCount} external plugin(s)` ); } } catch (e) { console.warn(`Failed to parse external plugins: ${e.message}`); } } // Collect all unique tags const allTags = [...new Set(plugins.flatMap((p) => p.tags))].sort(); const sortedPlugins = plugins.sort((a, b) => a.name.localeCompare(b.name)); return { items: sortedPlugins, filters: { tags: allTags }, }; } /** * Generate canvas extensions metadata */ function getImageMimeType(filePath) { const extension = path.extname(filePath).toLowerCase(); const mimeByExtension = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", }; return mimeByExtension[extension] || "application/octet-stream"; } function resolveImageUrl(value, ref) { const normalized = normalizeText(value); if (!normalized) return null; if (/^https?:\/\//i.test(normalized)) { return normalized; } const repoPath = normalized.replace(/\\/g, "/").replace(/^\/+/, ""); return buildRepoImageUrl(repoPath, ref); } function getImageAssetFiles(extensionDir) { const assetDir = path.join(extensionDir, "assets"); if (!fs.existsSync(assetDir)) { return []; } const imageExtensions = new Set([ ".png", ".jpg", ".jpeg", ".webp", ".gif", ]); return fs .readdirSync(assetDir) .filter((file) => imageExtensions.has(path.extname(file).toLowerCase())) .sort((a, b) => a.localeCompare(b)); } function pickAssetFile(files, preferredNames) { const preferredLookup = new Set(preferredNames.map((name) => name.toLowerCase())); for (const file of files) { if (preferredLookup.has(file.toLowerCase())) { return file; } } return files[0] || null; } function getExtensionAssetInfo(extensionDir, relPath, ref) { const files = getImageAssetFiles(extensionDir); if (files.length === 0) { return null; } const iconAsset = pickAssetFile(files, [ "icon.png", "icon.jpg", "icon.jpeg", "icon.webp", "icon.gif", "preview.png", "preview.jpg", "preview.jpeg", "preview.webp", "preview.gif", "screenshot.png", "screenshot.jpg", "screenshot.jpeg", "screenshot.webp", "screenshot.gif", "image.png", "image.jpg", "image.jpeg", "image.webp", "image.gif", ]); const galleryAsset = pickAssetFile(files, [ "gallery.png", "gallery.jpg", "gallery.jpeg", "gallery.webp", "gallery.gif", "preview.png", "preview.jpg", "preview.jpeg", "preview.webp", "preview.gif", "screenshot.png", "screenshot.jpg", "screenshot.jpeg", "screenshot.webp", "screenshot.gif", "image.png", "image.jpg", "image.jpeg", "image.webp", "image.gif", ]); const iconFile = iconAsset || galleryAsset; const galleryFile = galleryAsset || iconAsset; const iconPath = iconFile ? `${relPath}/assets/${iconFile}` : null; const galleryPath = galleryFile ? `${relPath}/assets/${galleryFile}` : null; return { screenshots: { icon: iconPath ? { path: iconPath, type: getImageMimeType(iconPath), } : null, gallery: galleryPath ? { path: galleryPath, type: getImageMimeType(galleryPath), } : null, }, assetPath: iconPath, imageUrl: iconPath ? buildRepoImageUrl(iconPath, ref) : null, }; } function buildRepoImageUrl(assetPath, ref) { const encodedAssetPath = assetPath .split("/") .map((segment) => encodeURIComponent(segment)) .join("/"); return `https://raw.githubusercontent.com/github/awesome-copilot/${ref}/${encodedAssetPath}`; } function extractCanvasMetadataFromSource(source) { const constants = new Map(); const constantPattern = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|`([^`$]*)`)\s*;/g; let constantMatch = constantPattern.exec(source); while (constantMatch) { const key = constantMatch[1]; const value = constantMatch[2] ?? constantMatch[3] ?? constantMatch[4] ?? ""; constants.set(key, value.replace(/\\n/g, "\n").trim()); constantMatch = constantPattern.exec(source); } function resolveExpression(expr) { const trimmed = normalizeText(expr); if (!trimmed) return null; if ( (trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'")) ) { return trimmed .slice(1, -1) .replace(/\\n/g, "\n") .replace(/\\"/g, '"') .replace(/\\'/g, "'"); } if (trimmed.startsWith("`") && trimmed.endsWith("`") && !trimmed.includes("${")) { return trimmed.slice(1, -1); } return constants.get(trimmed) || null; } function findMatchingBrace(startIndex) { let depth = 0; let inSingle = false; let inDouble = false; let inTemplate = false; let escaped = false; for (let i = startIndex; i < source.length; i++) { const char = source[i]; if (escaped) { escaped = false; continue; } if (char === "\\") { escaped = true; continue; } if (!inDouble && !inTemplate && char === "'" && !inSingle) { inSingle = true; continue; } if (inSingle && char === "'") { inSingle = false; continue; } if (!inSingle && !inTemplate && char === '"' && !inDouble) { inDouble = true; continue; } if (inDouble && char === '"') { inDouble = false; continue; } if (!inSingle && !inDouble && char === "`" && !inTemplate) { inTemplate = true; continue; } if (inTemplate && char === "`") { inTemplate = false; continue; } if (inSingle || inDouble || inTemplate) { continue; } if (char === "{") depth++; if (char === "}") { depth--; if (depth === 0) return i; } } return -1; } function readProp(head, key) { const pattern = new RegExp(`\\b${key}\\s*:\\s*([^,\\n]+)`); const match = pattern.exec(head); return resolveExpression(match?.[1]); } const canvases = []; let cursor = 0; while (cursor < source.length) { const createCanvasIndex = source.indexOf("createCanvas(", cursor); if (createCanvasIndex === -1) { break; } const objectStart = source.indexOf("{", createCanvasIndex); if (objectStart === -1) { break; } const objectEnd = findMatchingBrace(objectStart); if (objectEnd === -1) { break; } const objectContent = source.slice(objectStart + 1, objectEnd); const header = objectContent.slice(0, 1400); const id = readProp(header, "id"); const displayName = readProp(header, "displayName"); const description = readProp(header, "description"); if (id || displayName || description) { canvases.push({ id: id || null, displayName: displayName || null, description: description || null, }); } cursor = objectEnd + 1; } return canvases; } function getExtensionCanvasFiles(extensionDir) { const queue = [extensionDir]; const files = []; while (queue.length > 0) { const currentDir = queue.shift(); const entries = fs.readdirSync(currentDir, { withFileTypes: true }); for (const entry of entries) { const absolutePath = path.join(currentDir, entry.name); if (entry.isDirectory()) { queue.push(absolutePath); } else if (entry.isFile() && entry.name.endsWith(".mjs")) { files.push(absolutePath); } } } return files.sort((a, b) => a.localeCompare(b)); } function normalizeExternalScreenshotRole(value, ref) { if (!value) return null; if (typeof value === "string") { const type = getImageMimeType(value); return { path: value.replace(/\\/g, "/"), type, imageUrl: resolveImageUrl(value, ref), }; } const pathValue = normalizeText(value.path); const urlValue = normalizeText(value.url); if (!pathValue && !urlValue) return null; const imagePath = pathValue ? pathValue.replace(/\\/g, "/") : null; const type = normalizeText(value.type) || getImageMimeType(imagePath || urlValue); const imageUrl = resolveImageUrl(urlValue || imagePath, ref); return { path: imagePath, type, imageUrl, }; } function generateCanvasManifest(gitDates, commitSha) { const items = []; if (!fs.existsSync(EXTENSIONS_DIR)) { return { items: [], filters: { keywords: [] } }; } const extensionDirs = fs .readdirSync(EXTENSIONS_DIR, { withFileTypes: true }) .filter((entry) => { if (!entry.isDirectory()) return false; const extensionEntryPoint = path.join( EXTENSIONS_DIR, entry.name, "extension.mjs" ); return fs.existsSync(extensionEntryPoint); }) .sort((a, b) => a.name.localeCompare(b.name)); for (const dir of extensionDirs) { const relPath = `extensions/${dir.name}`; const extensionDir = path.join(EXTENSIONS_DIR, dir.name); const packageJsonPath = path.join(extensionDir, "package.json"); const packageJson = fs.existsSync(packageJsonPath) ? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) : {}; const keywords = Array.isArray(packageJson.keywords) ? [...new Set(packageJson.keywords.filter((keyword) => typeof keyword === "string").map((keyword) => keyword.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b)) : []; const extensionDescription = normalizeText(packageJson.description, "Canvas extension"); const extensionName = normalizeText(packageJson.name, dir.name); const extensionVersion = normalizeText(packageJson.version, "1.0.0"); const screenshots = getExtensionAssetInfo(extensionDir, relPath, commitSha); const canvasFiles = getExtensionCanvasFiles(extensionDir); const canvases = []; for (const canvasFile of canvasFiles) { const source = fs.readFileSync(canvasFile, "utf-8"); canvases.push(...extractCanvasMetadataFromSource(source)); } const canvasEntries = canvases.length > 0 ? canvases : [{ id: dir.name, displayName: formatDisplayName(dir.name), description: extensionDescription }]; const installUrl = `https://github.com/github/awesome-copilot/tree/${commitSha}/${relPath.replace( /\\/g, "/" )}`; for (const canvas of canvasEntries) { const canvasId = normalizeText(canvas.id, dir.name); const canvasName = normalizeText(canvas.displayName, formatDisplayName(canvasId)); const canvasDescription = normalizeText(extensionDescription, canvas.description); items.push({ id: canvasId, canvasId, extensionId: dir.name, extensionName, name: canvasName, version: extensionVersion, description: canvasDescription, path: relPath, ref: commitSha, lastUpdated: getDirectoryLastUpdated(gitDates, relPath), screenshots: screenshots?.screenshots || { icon: null, gallery: null }, imageUrl: screenshots?.imageUrl || null, assetPath: screenshots?.assetPath || null, installUrl, sourceUrl: null, external: false, keywords, }); } } const externalJsonPath = path.join(EXTENSIONS_DIR, "external.json"); if (fs.existsSync(externalJsonPath)) { try { const externalExtensions = JSON.parse( fs.readFileSync(externalJsonPath, "utf-8") ); if (Array.isArray(externalExtensions)) { for (const ext of externalExtensions) { const name = normalizeText(ext?.name); const installUrl = normalizeText(ext?.installUrl); const sourceUrl = normalizeText(ext?.sourceUrl || installUrl); if (!name || !installUrl) { continue; } const id = normalizeText(ext?.id || name.toLowerCase().replace(/\s+/g, "-")); const keywords = Array.isArray(ext?.keywords) ? [...new Set(ext.keywords.filter((keyword) => typeof keyword === "string").map((keyword) => keyword.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b)) : Array.isArray(ext?.tags) ? [...new Set(ext.tags.filter((keyword) => typeof keyword === "string").map((keyword) => keyword.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b)) : []; const iconScreenshot = normalizeExternalScreenshotRole(ext?.screenshots?.icon, commitSha) || normalizeExternalScreenshotRole(ext?.iconPath, commitSha) || normalizeExternalScreenshotRole(ext?.imagePath, commitSha) || normalizeExternalScreenshotRole(ext?.iconUrl, commitSha) || normalizeExternalScreenshotRole(ext?.imageUrl, commitSha); const galleryScreenshot = normalizeExternalScreenshotRole(ext?.screenshots?.gallery, commitSha) || normalizeExternalScreenshotRole(ext?.galleryPath, commitSha) || normalizeExternalScreenshotRole(ext?.galleryUrl, commitSha) || iconScreenshot; const screenshots = { icon: iconScreenshot ? { path: iconScreenshot.path, type: iconScreenshot.type, } : null, gallery: galleryScreenshot ? { path: galleryScreenshot.path, type: galleryScreenshot.type, } : null, }; const imageUrl = iconScreenshot?.imageUrl || null; const assetPath = iconScreenshot?.path || null; const canvasId = normalizeText(ext?.canvasId, id); items.push({ id, canvasId, extensionId: id, extensionName: name, name, version: normalizeText(ext?.version, "1.0.0"), description: normalizeText(ext?.description, "External canvas extension"), path: null, ref: null, lastUpdated: null, screenshots, imageUrl, assetPath, installUrl, sourceUrl: sourceUrl || null, external: true, keywords, }); } } } catch (e) { console.warn(`Failed to parse external extensions: ${e.message}`); } } const sortedItems = items.sort((a, b) => a.name.localeCompare(b.name)); const keywordFilters = [...new Set(sortedItems.flatMap((item) => item.keywords || []))] .filter(Boolean) .sort((a, b) => a.localeCompare(b)); return { items: sortedItems, filters: { keywords: keywordFilters, }, }; } function generateExtensionsData(canvasManifestData) { if (!canvasManifestData || !Array.isArray(canvasManifestData.items)) { return { items: [], filters: { keywords: [] } }; } const items = canvasManifestData.items.map((item) => ({ ...item, keywords: Array.isArray(item.keywords) ? item.keywords : [], screenshots: item.screenshots || { icon: null, gallery: null }, })); const filters = { keywords: [...new Set(items.flatMap((item) => item.keywords))] .filter(Boolean) .sort((a, b) => a.localeCompare(b)), }; return { items, filters }; } function writePerExtensionCanvasManifests(canvasManifestData) { const manifests = new Map(); function toExtensionRelativePath(assetPath, extensionId) { const normalizedPath = normalizeText(assetPath).replace(/\\/g, "/"); if (!normalizedPath) return null; const prefix = `extensions/${extensionId}/`; return normalizedPath.startsWith(prefix) ? normalizedPath.slice(prefix.length) : normalizedPath; } function toRelativeScreenshots(screenshots, extensionId) { if (!screenshots) return { icon: null, gallery: null }; const toRelativeEntry = (entry) => entry ? { ...entry, path: toExtensionRelativePath(entry.path, extensionId), } : null; return { icon: toRelativeEntry(screenshots.icon), gallery: toRelativeEntry(screenshots.gallery), }; } for (const item of canvasManifestData.items || []) { if (!item || item.external || !item.extensionId || !item.path) { continue; } // We assume one canvas per extension folder. if (manifests.has(item.extensionId)) { continue; } manifests.set(item.extensionId, { id: item.canvasId || item.id, name: item.name, description: item.description || "Canvas extension", version: item.version || "1.0.0", keywords: Array.isArray(item.keywords) ? [...new Set(item.keywords)].sort((a, b) => a.localeCompare(b)) : [], screenshots: toRelativeScreenshots( item.screenshots || { icon: null, gallery: null }, item.extensionId ), }); } for (const [extensionId, manifest] of manifests.entries()) { const canvasManifestPath = path.join( EXTENSIONS_DIR, extensionId, "canvas.json" ); fs.writeFileSync(canvasManifestPath, JSON.stringify(manifest, null, 2)); } } /** * Generate tools metadata from website/data/tools.yml */ function generateToolsData() { const toolsFile = path.join(WEBSITE_SOURCE_DATA_DIR, "tools.yml"); if (!fs.existsSync(toolsFile)) { console.warn("No tools.yml file found at", toolsFile); return { items: [], filters: { categories: [], tags: [] } }; } const data = parseYamlFile(toolsFile); if (!data || !data.tools) { return { items: [], filters: { categories: [], tags: [] } }; } const allCategories = new Set(); const allTags = new Set(); const tools = data.tools.map((tool) => { const category = tool.category || "Other"; allCategories.add(category); const tags = tool.tags || []; tags.forEach((t) => allTags.add(t)); return { id: tool.id, name: tool.name, description: tool.description || "", category: category, featured: tool.featured || false, requirements: tool.requirements || [], features: tool.features || [], links: tool.links || {}, configuration: tool.configuration || null, tags: tags, }; }); // Sort with featured first, then alphabetically const sortedTools = tools.sort((a, b) => { if (a.featured && !b.featured) return -1; if (!a.featured && b.featured) return 1; return a.name.localeCompare(b.name); }); return { items: sortedTools, filters: { categories: Array.from(allCategories).sort(), tags: Array.from(allTags).sort(), }, }; } /** * Generate a combined index for search */ function generateSearchIndex( agents, instructions, hooks, workflows, skills, plugins ) { const index = []; for (const agent of agents) { index.push({ type: "agent", id: agent.id, title: agent.title, description: agent.description, path: agent.path, lastUpdated: agent.lastUpdated, searchText: `${agent.title} ${agent.description} ${agent.tools.join( " " )}`.toLowerCase(), }); } for (const instruction of instructions) { index.push({ type: "instruction", id: instruction.id, title: instruction.title, description: instruction.description, path: instruction.path, lastUpdated: instruction.lastUpdated, searchText: `${instruction.title} ${instruction.description} ${instruction.applyTo || "" }`.toLowerCase(), }); } for (const hook of hooks) { index.push({ type: "hook", id: hook.id, title: hook.title, description: hook.description, path: hook.readmeFile, lastUpdated: hook.lastUpdated, searchText: `${hook.title} ${hook.description} ${hook.hooks.join( " " )} ${hook.tags.join(" ")}`.toLowerCase(), }); } for (const workflow of workflows) { index.push({ type: "workflow", id: workflow.id, title: workflow.title, description: workflow.description, path: workflow.path, lastUpdated: workflow.lastUpdated, searchText: `${workflow.title} ${workflow.description } ${workflow.triggers.join(" ")}`.toLowerCase(), }); } for (const skill of skills) { index.push({ type: "skill", id: skill.id, title: skill.title, description: skill.description, path: skill.skillFile, lastUpdated: skill.lastUpdated, searchText: skill.searchText, }); } for (const plugin of plugins) { index.push({ type: "plugin", id: plugin.id, title: plugin.name, description: plugin.description, path: plugin.path, tags: plugin.tags, lastUpdated: plugin.lastUpdated, searchText: plugin.searchText, }); } return index; } /** * Generate samples/cookbook data from cookbook.yml */ function generateSamplesData() { const cookbookYamlPath = path.join(COOKBOOK_DIR, "cookbook.yml"); if (!fs.existsSync(cookbookYamlPath)) { console.warn( "Warning: cookbook/cookbook.yml not found, skipping samples generation" ); return { cookbooks: [], totalRecipes: 0, totalCookbooks: 0, filters: { languages: [], tags: [] }, }; } const cookbookManifest = parseYamlFile(cookbookYamlPath); if (!cookbookManifest || !cookbookManifest.cookbooks) { console.warn("Warning: Invalid cookbook.yml format"); return { cookbooks: [], totalRecipes: 0, totalCookbooks: 0, filters: { languages: [], tags: [] }, }; } const allLanguages = new Set(); const allTags = new Set(); let totalRecipes = 0; // First pass: collect all known language IDs across cookbooks cookbookManifest.cookbooks.forEach((cookbook) => { cookbook.languages.forEach((lang) => allLanguages.add(lang.id)); }); const cookbooks = cookbookManifest.cookbooks.map((cookbook) => { // Process recipes and add file paths const recipes = cookbook.recipes.map((recipe) => { // Collect tags if (recipe.tags) { recipe.tags.forEach((tag) => allTags.add(tag)); } totalRecipes++; // External recipes link to an external URL — skip local file resolution if (recipe.external) { if (recipe.url) { try { new URL(recipe.url); } catch { console.warn(`Warning: Invalid URL for external recipe "${recipe.id}": ${recipe.url}`); } } else { console.warn(`Warning: External recipe "${recipe.id}" is missing a url`); } // Derive languages from tags that match known language IDs const recipeLanguages = (recipe.tags || []).filter((tag) => allLanguages.has(tag)); return { id: recipe.id, name: recipe.name, description: recipe.description, tags: recipe.tags || [], languages: recipeLanguages, external: true, url: recipe.url || null, author: recipe.author || null, variants: {}, }; } // Build variants with file paths for each language const variants = {}; cookbook.languages.forEach((lang) => { const docPath = `${cookbook.path}/${lang.id}/${recipe.id}.md`; const examplePath = `${cookbook.path}/${lang.id}/recipe/${recipe.id}${lang.extension}`; // Check if files exist const docFullPath = path.join(ROOT_FOLDER, docPath); const exampleFullPath = path.join(ROOT_FOLDER, examplePath); if (fs.existsSync(docFullPath)) { variants[lang.id] = { doc: docPath, example: fs.existsSync(exampleFullPath) ? examplePath : null, }; } }); return { id: recipe.id, name: recipe.name, description: recipe.description, tags: recipe.tags || [], languages: Object.keys(variants), variants, }; }); return { id: cookbook.id, name: cookbook.name, description: cookbook.description, path: cookbook.path, featured: cookbook.featured || false, languages: cookbook.languages, recipes, }; }); return { cookbooks, totalRecipes, totalCookbooks: cookbooks.length, filters: { languages: Array.from(allLanguages).sort(), tags: Array.from(allTags).sort(), }, }; } /** * Main function */ async function main() { console.log("Generating website data...\n"); ensureDataDir(); // Load git dates for all resource files (single efficient git command) console.log("Loading git history for last updated dates..."); const gitDates = getGitFileDates( [ "agents/", "instructions/", "hooks/", "workflows/", "skills/", "extensions/", "plugins/", ], ROOT_FOLDER ); console.log(`✓ Loaded dates for ${gitDates.size} files\n`); // Generate all data const commitSha = getCurrentCommitSha(); const agentsData = generateAgentsData(gitDates); const agents = agentsData.items; console.log( `✓ Generated ${agents.length} agents (${agentsData.filters.models.length} models, ${agentsData.filters.tools.length} tools)` ); const hooksData = generateHooksData(gitDates); const hooks = hooksData.items; console.log( `✓ Generated ${hooks.length} hooks (${hooksData.filters.hooks.length} hook types, ${hooksData.filters.tags.length} tags)` ); const workflowsData = generateWorkflowsData(gitDates); const workflows = workflowsData.items; console.log( `✓ Generated ${workflows.length} workflows (${workflowsData.filters.triggers.length} triggers)` ); const instructionsData = generateInstructionsData(gitDates); const instructions = instructionsData.items; console.log( `✓ Generated ${instructions.length} instructions (${instructionsData.filters.extensions.length} extensions)` ); const skillsData = generateSkillsData(gitDates); const skills = skillsData.items; console.log(`✓ Generated ${skills.length} skills`); const pluginsData = generatePluginsData(gitDates); const plugins = pluginsData.items; console.log( `✓ Generated ${plugins.length} plugins (${pluginsData.filters.tags.length} tags)` ); const canvasManifestData = generateCanvasManifest(gitDates, commitSha); const extensionsData = generateExtensionsData(canvasManifestData); const extensions = extensionsData.items; console.log( `✓ Generated ${extensions.length} extensions (${extensionsData.filters.keywords.length} keywords)` ); const toolsData = generateToolsData(); const tools = toolsData.items; console.log( `✓ Generated ${tools.length} tools (${toolsData.filters.categories.length} categories)` ); const samplesData = generateSamplesData(); console.log( `✓ Generated ${samplesData.totalRecipes} recipes in ${samplesData.totalCookbooks} cookbooks (${samplesData.filters.languages.length} languages, ${samplesData.filters.tags.length} tags)` ); // Count contributors from .all-contributorsrc for manifest stats const contributorsRcPath = path.join(ROOT_FOLDER, ".all-contributorsrc"); const contributorCount = fs.existsSync(contributorsRcPath) ? (JSON.parse(fs.readFileSync(contributorsRcPath, "utf-8")).contributors || []).length : 0; const searchIndex = generateSearchIndex( agents, instructions, hooks, workflows, skills, plugins ); console.log(`✓ Generated search index with ${searchIndex.length} items`); // Write JSON files fs.writeFileSync( path.join(WEBSITE_DATA_DIR, "agents.json"), JSON.stringify(agentsData, null, 2) ); fs.writeFileSync( path.join(WEBSITE_DATA_DIR, "hooks.json"), JSON.stringify(hooksData, null, 2) ); fs.writeFileSync( path.join(WEBSITE_DATA_DIR, "workflows.json"), JSON.stringify(workflowsData, null, 2) ); fs.writeFileSync( path.join(WEBSITE_DATA_DIR, "instructions.json"), JSON.stringify(instructionsData, null, 2) ); fs.writeFileSync( path.join(WEBSITE_DATA_DIR, "skills.json"), JSON.stringify(skillsData, null, 2) ); fs.writeFileSync( path.join(WEBSITE_DATA_DIR, "plugins.json"), JSON.stringify(pluginsData, null, 2) ); fs.writeFileSync( path.join(WEBSITE_DATA_DIR, "extensions.json"), JSON.stringify(extensionsData, null, 2) ); writePerExtensionCanvasManifests(canvasManifestData); fs.writeFileSync( path.join(WEBSITE_DATA_DIR, "tools.json"), JSON.stringify(toolsData, null, 2) ); fs.writeFileSync( path.join(WEBSITE_DATA_DIR, "samples.json"), JSON.stringify(samplesData, null, 2) ); fs.writeFileSync( path.join(WEBSITE_DATA_DIR, "search-index.json"), JSON.stringify(searchIndex, null, 2) ); // Generate a manifest with counts and timestamps const manifest = { generated: new Date().toISOString(), counts: { agents: agents.length, instructions: instructions.length, skills: skills.length, hooks: hooks.length, workflows: workflows.length, plugins: plugins.length, extensions: extensions.length, tools: tools.length, contributors: contributorCount, samples: samplesData.totalRecipes, total: searchIndex.length, }, }; fs.writeFileSync( path.join(WEBSITE_DATA_DIR, "manifest.json"), JSON.stringify(manifest, null, 2) ); console.log(`\n✓ All data written to website/public/data/`); } main().catch((err) => { console.error("Error generating website data:", err); process.exit(1); });