Files
awesome-copilot/eng/generate-website-data.mjs
T
Ashley Wolf e9c8e37041 Add contributor attribution to canvas extension cards (#2111)
Show "by @handle" on each canvas extension card and in the details
modal, linking to the contributor's GitHub profile. Author metadata
lives in each extension's canvas.json (and external.json for external
extensions), where the rest of the canvas metadata is stored.

- Store author {name, url} in canvas.json / external.json
- Read author from canvas.json in the website data generator and emit
  it to extensions.json
- Render the GitHub @handle, derived from the profile URL, as the link
  text, with the contributor's name as the link title
- Escape the sanitized author URL before interpolating it into href

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 14:52:58 +10:00

1705 lines
47 KiB
JavaScript
Executable File

#!/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;
}
/**
* Normalize an author value (npm string form or { name, url } object) to
* { name, url? } | null. Returns null when no usable name is present.
*/
function normalizeAuthor(value) {
if (!value) return null;
if (typeof value === "string") {
const name = value.trim();
return name ? { name } : null;
}
if (typeof value === "object") {
const name = normalizeText(value.name);
if (!name) return null;
const url = normalizeText(value.url);
return url ? { name, url } : { name };
}
return null;
}
/**
* 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 canvasJsonPath = path.join(extensionDir, "canvas.json");
const canvasJson = fs.existsSync(canvasJsonPath)
? JSON.parse(fs.readFileSync(canvasJsonPath, "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/main/${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,
author: normalizeAuthor(canvasJson.author),
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,
author: normalizeAuthor(ext?.author),
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",
...(item.author ? { author: item.author } : {}),
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);
});