mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-14 18:11:01 +00:00
e03696a5bf
* feat(website): dedicated agent detail pages instead of modal Replace the popup/modal viewer for agents with dedicated per-agent pages at /agent/<id>/ for real URLs and better deep linking. - Build-time rendered docs (marked + gray-matter) with a details sidebar - Sidebar Actions card: Install split-button (VS Code/Insiders/Download/Copy markdown), Share, View on GitHub - Cards now link natively via anchors (no modal); card-render gains optional href (backward compatible for other types) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(website): dedicated instruction detail pages + shared detail layout Extend the dedicated detail-page pattern (introduced for agents) to instructions, and factor the shared behavior/styles out for reuse: - Add instruction/[id].astro with build-time markdown render, breadcrumb, install split-button (VS Code/Insiders/Download/Copy markdown), Share, View on GitHub, and a details sidebar (Applies to / Source / Last updated) plus collapsible frontmatter. - Extract shared client behavior into resource-detail.ts (renamed from agent-detail.ts) keyed on [data-resource-detail]. - Move detail-page CSS into global.css under .resource-detail-page. - Point the agent detail page at the shared script/styles. - Instruction cards now link to /instruction/<id>/ and the modal is removed from the instructions listing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(website): extract shared detail-page components Break the agent and instruction detail pages down into reusable Astro components under src/components/pages: Breadcrumb, Header, Main, Sidebar, SidebarChips, InstallButtons, and RawMarkdown. Both detail pages now compose these components instead of duplicating markup. Fix RawMarkdown to read the `markdown` prop (matching both call sites) and emit exact text via set:text, which restores the Copy markdown action that had silently broken when the hidden textarea stopped rendering. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(website): share detail-page build logic in lib/detail-page Both detail pages duplicated the install/GitHub URL builders, the build-time markdown read+render, and the last-updated formatting. Move all of it into a DOM-free build-time helper (src/lib/detail-page.ts) exposing loadDetailPage(item, type), and reduce each [id].astro to a single call plus its type-specific chip data. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add dedicated skill detail pages with multi-file browser Migrate skills from the popup/modal viewer to dedicated per-item pages (/skill/<id>/) matching the agent and instruction detail pages. Skills need a few skill-specific mechanics: - Install via `gh skills install github/awesome-copilot <id>` (copyable), since skills have no VS Code install URL. - Download ZIP for the multi-file skill contents. - A file browser that defaults to SKILL.md and lets you inspect other files, with Shiki syntax highlighting for code and marked-rendered markdown. SKILL.md is embedded (rendered + raw) at build time; other files are lazy-fetched on demand and cached. Deep links via #file=. Also fixes a production build regression in the shared detail-page helper: repoRoot now resolves via process.cwd() instead of import.meta .url, which resolved incorrectly once bundled and silently returned empty markdown (breaking rendered docs + copy-markdown for agents and instructions too). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Replace skill file list with a dropdown selector The two-column file browser (side list + content pane) was cramped, splitting the already-narrow main column in half. Replace the side list with a dropdown in the file view header so the content pane spans the full width. - Multi-file skills get a <select> grouped by folder via <optgroup>, SKILL.md first. Single-file skills keep a static filename label. - Client script drives selection from the <select> change event instead of the removed file buttons; deep links, copy-file, Download ZIP, and Share all read the file list from the select options. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Fix skill sidebar card overflowing its column The install command in the actions card used white-space: nowrap, which gave the grid tracks a large min-content size. Grid items default to min-width: auto (won't shrink below content), so the actions card grew past the 352px sidebar, making it look wider than the agent/instruction sidebars. Add min-width: 0 down the sidebar grid chain so the command box stays within the column and scrolls horizontally instead. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Make skill file content pane grow to full page height Remove the fixed max-height/overflow on .skill-file-content so file content flows to natural height and the page scrolls, instead of a nested inner scroll region. Shiki <pre> keeps its own horizontal scroll. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Center-align skill file picker label with the select Reset the Starlight-injected margin-top on .skill-file-select so the 'File' label and the dropdown share a common vertical midline. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Render code files edge-to-edge in skill file viewer For code files, drop the container padding and the pre border/radius so the highlighted code fills the full column width. Markdown files keep their padded, bordered layout. Toggled via an is-code class on the content pane based on the selected file kind. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add dedicated detail pages for hooks with a reusable file browser Hooks had no good install path (manual copy or ZIP), so mirror the Skills dedicated-page pattern: a multi-file browser plus a Download ZIP action, replacing the modal on the hooks listing. Generalise the Skills-specific file browser so both resource types share one implementation: - Rename SkillFileBrowser.astro -> FileBrowser.astro with neutral props. - Rename skill-detail.ts -> file-browser.ts with neutral data attributes (data-file-browser-page, data-bundle-id, data-primary-file). - Rescope install-slot styles under a shared .bundle-detail-page class. - generate-website-data: rename getSkillFiles -> getFolderFiles and emit a files[] + readmeFileName for each hook. Hooks list cards now deep-link to /hook/<id>/ instead of opening a modal. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add dedicated detail pages for workflows Migrate agentic workflows from the popup/modal viewer to dedicated per-item pages (/workflow/<id>/) with deep linking, matching the pattern used for agents, instructions, skills, and hooks. Workflows are single .md files, so they reuse the single-file detail components (Main, Sidebar, Header, Breadcrumb, RawMarkdown) and the resource-detail client script. Since workflows have no VS Code install, the install slot instead documents the gh aw CLI flow and offers Download + Copy markdown actions. Also rescope the shared install-slot CSS from .bundle-detail-page to .detail-actions-card so skill, hook, and workflow pages share it without a page-specific class, and drop the now-unused bundle-detail-page class from the skill and hook pages. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Format workflow install note Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add dedicated plugin detail pages Replace the plugins modal with dedicated /plugin/<id>/ pages that deep link, render the bundled README, and surface an Included items section linking each constituent agent/skill/instruction/hook to its own detail page (falling back to GitHub for items without a page, e.g. extensions). - generate-website-data: resolve plugin items to detail URLs + titles, add readmeFile for local and extension-derived plugins - new IncludedItems component groups bundled resources by kind - plugin/[id].astro handles local, extension-derived, and external plugins with VS Code + CLI install actions - resource-detail: copy-install handler shared with detail pages - plugins list cards now deep link; modal wiring removed Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Show plugin version, ref, and commit in detail sidebar Capture version from plugin.json (local + extension-derived) and external.json, and surface it in the plugin detail Details card. For external plugins, also show the pinned source ref and short commit SHA when present. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add dedicated canvas extension detail pages with preview gallery Replace the extensions modal with per-extension pages at /extension/<id>/, mirroring the skill/plugin detail layout. Each page shows a preview image gallery, README docs (or an About fallback), and a sidebar with install actions and details (version, canvas ID, keywords, author, commit). - generate-website-data.mjs: emit readmeFile for extensions - extensions-render.ts: deep-link cards to detail pages - extensions.ts: strip modal/gallery wiring, keep filter/sort/copy actions - resource-detail.ts: add copy-install-url action - extension-gallery.ts: thumbnail switching for multi-image previews Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add VS Code Insiders and GitHub Copilot app install links Introduce a shared PluginInstall component used by both plugin and canvas extension detail pages. It renders a split-button dropdown deep-linking into VS Code, VS Code Insiders, and the GitHub Copilot app (ghapp://), plus the CLI command for internal items. - Internal plugins/extensions: ghapp://plugins/install?source=<id>@awesome-copilot - External plugins: ghapp://plugins/marketplace/add?source=<owner/repo> - ghapp source values are URL-encoded per the app's deep-link contract Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Make GitHub Copilot app the default install option Promote the ghapp:// deep link to the primary split-button action and list it first in the dropdown, ahead of VS Code and VS Code Insiders. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Ignoring impeccable files * Make Extensions grid Copy Install a direct Copilot app install Replace the CLI-command copy button on extension cards with an 'Install in Copilot app' deep link (ghapp://plugins/install) for internal extensions, matching the detail page. External extensions keep the Copy URL fallback since they have no Copilot app install path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address PR review feedback on detail pages - Remove DOM innerHTML read/write round trip in file browser cache (CodeQL js/xss-through-dom); seed primary file from raw text and render lazily. - Use Shiki dual light/dark themes in the file browser and add dark mode CSS overrides. - Guard decodeURIComponent for #file= deep links against malformed percent-encoding. - Slugify SidebarChips title before using it in the tag class name. - Use a neutral aria-label on the shared detail Sidebar. - URL-encode the external plugin source in VS Code and Insiders install links. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Prevent client-side path traversal in file deep links Addresses the CSPT-to-XSS class reported in github/open-source#1739. A '#file=' hash value containing '../' sequences could resolve outside the awesome-copilot repo prefix once normalized by fetch, loading attacker-controlled content that was then rendered into the page. - Add isSafeRepoFilePath() and enforce it at the raw-URL choke point (getRawGitHubUrl, fetchFileContent, downloadFile, getVSCodeInstallUrl), so no consumer can escape the repo prefix. - Validate the decoded '#file=' path in the modal hash handler and guard its decodeURIComponent against malformed input. The new hash-based file browser already restricts deep links to an allowlist of build-time file descriptors; the choke-point guard is an additional backstop. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Fix accessibility violations on resource detail pages Run the a11y audit against the new detail pages and resolve every critical/serious axe violation it surfaced: - Nest the detail column as a <div> instead of a second <main>, fixing the duplicate/non-top-level/non-unique landmark rules on every detail page. - Add a shared enhanceMarkdownA11y() helper that makes rendered <pre>/<table> blocks keyboard focusable and gives task-list checkboxes a state-based accessible name; apply it at build time and in the client file browser. - Make Shiki-highlighted code and install-command <code> blocks focusable. - Underline links inside rendered docs and install notes so they are distinguishable without color, overriding the global #main-content reset. - Extend the a11y audit to cover one representative page per detail type. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Fix file browser GitHub URL handling Encode selected file paths before assigning GitHub detail links and render load errors with DOM APIs so DOM-derived file names are not reinterpreted as markup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address PR review feedback on detail pages and file browser - Deep-link plugin extension items to their /extension/<id>/ detail pages: generate canvas extensions before building the resource index, and index extensions (by id and folder basename) so resolvePluginItem can resolve them. - Render image files in the bundle file browser via an <img> tag built from the safe raw URL instead of decoding binary assets as UTF-8 text; skip the copy-file action for images. - Use a neutral 'Install' heading on internal plugin and extension pages so the split-button primary label accurately communicates the target. - Warn (with the file path) when readResourceMarkdown fails instead of swallowing the error silently, keeping the build unbroken. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address PR review feedback on install links, dropdown a11y, and ZIP downloads - externalRepoUrl (plugin detail pages) now builds the 'View on GitHub' tree URL from the pinned source.ref or source.sha, falling back to main only when neither is present, so the link matches the sidebar Ref/Commit chips. - Install split-button dropdown on resource detail pages is now keyboard accessible: opening focuses the first item, ArrowUp/ArrowDown wrap, Home/End jump, and Escape closes and restores focus to the toggle, mirroring modal.ts. - downloadZipBundle fetches each file as an ArrayBuffer and hands it to JSZip so binary assets (PNG/JPG/etc.) are preserved instead of corrupted by UTF-8 text decoding. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address PR review feedback on external repo links and workflow install note - externalRepoUrl now uses a pinned source.ref/sha even when the external plugin has no path, returning /tree/<ref> so "View on GitHub" points at the pinned revision and stays consistent with the sidebar Ref/Commit chips. - Reflow the workflow install note so each inline code and link element stays on a single line (using explicit whitespace expressions for word spacing), removing the split end-tag artifacts while preserving the exact rendered text and spacing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Harden repo path validation and fix Included Items file links - isSafeRepoFilePath now fails closed on any "%" so percent-encoded dot-segments (%2e%2e and double-encoded %252e%252e) cannot be normalized back into path traversal by the browser URL parser during fetch, and also rejects "." and empty path segments. Legitimate repo paths never contain these. - IncludedItems githubHref now links file paths via /blob/ and directories via /tree/, detecting files by a trailing extension on the last path segment, so the fallback links for agent/instruction/command items no longer 404. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Harden detail pages: sanitize markdown, fix search deep-links and external URLs Addresses rubber-duck review items 1-5 for the detail-page migration: 1. Sanitize rendered markdown as untrusted HTML. Add isomorphic sanitize-html helper (isomorphic-dompurify) applied in the build-time pipeline (detail-page.ts) and the client file browser before a11y enhancement, so marked output can no longer inject scripts/handlers. 2. Point Pagefind search results at canonical /type/id/ detail pages instead of inert #file= listing hashes for types that have a detail page. 3. Sanitize external/generated URLs on plugin and extension detail pages and their render scripts so only http(s) links are emitted. 4. Add a shared externalRepoUrl helper that pins GitHub links to the source ref/sha (preferring sha) with encoded path segments, replacing the duplicated always-main logic in the pages, modal, and renderers. 5. Handle #file= hash navigation after initial load in the file browser via a hashchange listener. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Detail pages: clickable filter chips + code-block copy buttons (#2255) * Add clickable filter chips on detail pages Turn read-only metadata chips into navigation: tags (hooks/plugins), keywords (extensions), and extensions (instructions) now link to their list page pre-filtered by that value (e.g. /hooks/?tag=testing). SidebarChips gains optional filterBase/filterParam props; when both are set each chip renders as an <a> with an aria-label and hover/focus styling, otherwise it stays a plain <span>. Agent/skill/workflow chips are unchanged. Filtering was verified end to end against each list page's existing query-param handling. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add copy buttons to documentation code blocks Detail-page markdown is rendered as plain <pre><code> with no syntax highlighter, so code and config snippets had no copy affordance. Add a hover-revealed copy button to every code block in the rendered docs: copies to the clipboard, shows a toast, and swaps to a check icon for confirmation. Buttons are keyboard-accessible, always visible on touch devices, and respect reduced-motion. The sidebar frontmatter block is left untouched. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix detail-page GitHub links, empty-state link contrast, and file cache Addresses four PR review comments on the detail-page migration: - skill/hook detail pages: build the sidebar "View on GitHub" link from a /tree/main base instead of /blob/main, since item.path is a directory and /blob/<dir> URLs 404. The FileBrowser githubBase stays on /blob for individual file links. - global.css: include .detail-empty a in the underline override so links in empty-state notes stay distinguishable without relying on color alone (WCAG 1.4.1 / axe link-in-text-block). - file-browser.ts: seed the primary file's cache with its already-rendered (frontmatter-stripped) HTML in addition to raw text, so re-selecting the primary file after navigating away no longer re-renders the raw source and surfaces frontmatter. Copy still uses the full raw text. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com>
1924 lines
54 KiB
JavaScript
Executable File
1924 lines
54 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`;
|
|
|
|
// Get all files in the hook folder recursively (for the file browser and
|
|
// ZIP download on the detail page).
|
|
const files = getFolderFiles(hookPath, relativePath);
|
|
|
|
// 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 || [],
|
|
files,
|
|
path: relativePath,
|
|
readmeFile: readmeRelativePath,
|
|
readmeFileName: "README.md",
|
|
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 = getFolderFiles(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 resource folder (skill or hook) recursively.
|
|
*/
|
|
function getFolderFiles(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}`,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Build a lookup index of resource id -> { title, url } for the kinds that have
|
|
* dedicated detail pages, so plugin items can deep-link to them.
|
|
*/
|
|
function buildResourceIndex({ agents, skills, instructions, hooks, extensions }) {
|
|
const toMap = (items, urlPrefix) => {
|
|
const map = new Map();
|
|
for (const item of items || []) {
|
|
if (!item?.id) continue;
|
|
map.set(item.id, {
|
|
title: item.title || item.id,
|
|
url: `/${urlPrefix}/${item.id}/`,
|
|
});
|
|
}
|
|
return map;
|
|
};
|
|
const extensionMap = new Map();
|
|
for (const item of extensions || []) {
|
|
if (!item?.id) continue;
|
|
|
|
const entry = {
|
|
title: item.name || item.title || item.id,
|
|
url: `/extension/${item.id}/`,
|
|
};
|
|
const keys = [
|
|
item.id,
|
|
item.extensionId,
|
|
item.path ? pluginItemCandidateId(item.path) : null,
|
|
].filter(Boolean);
|
|
|
|
for (const key of keys) {
|
|
if (!extensionMap.has(key)) {
|
|
extensionMap.set(key, entry);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
agent: toMap(agents, "agent"),
|
|
skill: toMap(skills, "skill"),
|
|
instruction: toMap(instructions, "instruction"),
|
|
hook: toMap(hooks, "hook"),
|
|
extension: extensionMap,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Derive the candidate resource id for a plugin item path (basename without a
|
|
* known resource extension), e.g. "./skills/foo/" -> "foo",
|
|
* "plugins/x/agents/bar.md" -> "bar".
|
|
*/
|
|
function pluginItemCandidateId(itemPath) {
|
|
const trimmed = String(itemPath || "")
|
|
.replace(/^\.\/+/, "")
|
|
.replace(/\/+$/, "");
|
|
const base = trimmed.split("/").pop() || "";
|
|
return base
|
|
.replace(/\.agent\.md$/i, "")
|
|
.replace(/\.prompt\.md$/i, "")
|
|
.replace(/\.instructions\.md$/i, "")
|
|
.replace(/\.md$/i, "");
|
|
}
|
|
|
|
/**
|
|
* Enrich a plugin item ({ kind, path }) with a display title and, when the item
|
|
* resolves to a known resource with a detail page, a detailUrl.
|
|
*/
|
|
function resolvePluginItem(item, resourceIndex) {
|
|
const candidateId = pluginItemCandidateId(item.path);
|
|
const lookup = resourceIndex?.[item.kind];
|
|
const match = lookup?.get(candidateId);
|
|
|
|
return {
|
|
...item,
|
|
title: match?.title || candidateId || item.path,
|
|
detailUrl: match?.url || null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate plugins metadata
|
|
*/
|
|
function generatePluginsData(gitDates, resourceIndex = {}) {
|
|
const plugins = [];
|
|
const extensionEntriesByName = new Map();
|
|
|
|
if (!fs.existsSync(PLUGINS_DIR)) {
|
|
return { items: [], filters: { tags: [] } };
|
|
}
|
|
|
|
const pluginDirs = fs
|
|
.readdirSync(PLUGINS_DIR, { withFileTypes: true })
|
|
.filter((d) => d.isDirectory());
|
|
|
|
if (fs.existsSync(EXTENSIONS_DIR)) {
|
|
const extensionDirs = fs.readdirSync(EXTENSIONS_DIR, { withFileTypes: true })
|
|
.filter((entry) => {
|
|
if (!entry.isDirectory()) return false;
|
|
return fs.existsSync(path.join(EXTENSIONS_DIR, entry.name, "extension.mjs"));
|
|
})
|
|
.map((entry) => entry.name)
|
|
.sort((a, b) => a.localeCompare(b));
|
|
|
|
for (const extensionDirName of extensionDirs) {
|
|
const extensionDir = path.join(EXTENSIONS_DIR, extensionDirName);
|
|
const pluginJsonPath = path.join(extensionDir, ".github", "plugin", "plugin.json");
|
|
if (!fs.existsSync(pluginJsonPath)) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const extensionPlugin = JSON.parse(fs.readFileSync(pluginJsonPath, "utf-8"));
|
|
const pluginName = normalizeText(extensionPlugin.name, extensionDirName);
|
|
const pluginDescription = normalizeText(extensionPlugin.description, "Canvas extension");
|
|
const extensionKeywords = Array.isArray(extensionPlugin.keywords)
|
|
? [...new Set(extensionPlugin.keywords.filter((keyword) => typeof keyword === "string").map((keyword) => keyword.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b))
|
|
: [];
|
|
const relPath = `extensions/${extensionDirName}`;
|
|
const extensionItem = resolvePluginItem(
|
|
{
|
|
kind: "extension",
|
|
path: relPath,
|
|
},
|
|
resourceIndex
|
|
);
|
|
const extReadmePath = path.join(extensionDir, "README.md");
|
|
const extReadmeFile = fs.existsSync(extReadmePath)
|
|
? `${relPath}/README.md`
|
|
: null;
|
|
|
|
extensionEntriesByName.set(pluginName, {
|
|
id: pluginName,
|
|
name: pluginName,
|
|
description: pluginDescription,
|
|
path: relPath,
|
|
readmeFile: extReadmeFile,
|
|
version: normalizeText(extensionPlugin.version, null),
|
|
tags: extensionKeywords,
|
|
itemCount: 1,
|
|
items: [extensionItem],
|
|
generatedFromExtension: true,
|
|
lastUpdated: getDirectoryLastUpdated(gitDates, relPath),
|
|
searchText: `${pluginName} ${pluginDescription} ${extensionKeywords.join(" ")} canvas extension`.toLowerCase(),
|
|
});
|
|
} catch (e) {
|
|
console.warn(`Failed to parse extension plugin manifest for ${extensionDirName}: ${e.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 extensionRefs = Array.isArray(data?.["x-awesome-copilot"]?.extensions)
|
|
? data["x-awesome-copilot"].extensions
|
|
: [];
|
|
const extensionItems = extensionRefs
|
|
.map((entry) => normalizeText(entry))
|
|
.filter(Boolean)
|
|
.map((entry) => entry.replace(/^\.\/+/, "").replace(/\/$/, ""))
|
|
.filter((entry) => entry.startsWith("extensions/"))
|
|
.map((entry) => ({
|
|
kind: "extension",
|
|
path: entry,
|
|
}));
|
|
|
|
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 })),
|
|
...extensionItems,
|
|
].map((item) => resolvePluginItem(item, resourceIndex));
|
|
|
|
const tags = data.keywords || data.tags || [];
|
|
const pluginName = data.name || dir.name;
|
|
|
|
const readmePath = path.join(pluginDir, "README.md");
|
|
const readmeFile = fs.existsSync(readmePath)
|
|
? `${relPath}/README.md`
|
|
: null;
|
|
|
|
plugins.push({
|
|
id: dir.name,
|
|
name: pluginName,
|
|
description: data.description || "",
|
|
path: relPath,
|
|
readmeFile,
|
|
version: normalizeText(data.version, null),
|
|
tags: tags,
|
|
itemCount: items.length,
|
|
items: items,
|
|
lastUpdated: getDirectoryLastUpdated(gitDates, relPath),
|
|
searchText: `${pluginName} ${data.description || ""
|
|
} ${tags.join(" ")}`.toLowerCase(),
|
|
});
|
|
extensionEntriesByName.delete(pluginName);
|
|
} catch (e) {
|
|
console.warn(`Failed to parse plugin: ${dir.name}`, e.message);
|
|
}
|
|
}
|
|
|
|
for (const extensionPlugin of extensionEntriesByName.values()) {
|
|
plugins.push(extensionPlugin);
|
|
}
|
|
|
|
// 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}`,
|
|
version: normalizeText(ext.version, null),
|
|
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 normalizeExtensionScreenshotRole(value, relPath, ref) {
|
|
if (!value) return null;
|
|
if (typeof value === "string") {
|
|
if (/^https?:\/\//i.test(value)) {
|
|
return {
|
|
path: null,
|
|
type: getImageMimeType(value),
|
|
imageUrl: value,
|
|
};
|
|
}
|
|
|
|
const normalized = value.replace(/\\/g, "/").replace(/^\.\/+/, "");
|
|
const repoPath = normalized.startsWith(`${relPath}/`) ? normalized : `${relPath}/${normalized}`;
|
|
return {
|
|
path: repoPath,
|
|
type: getImageMimeType(repoPath),
|
|
imageUrl: buildRepoImageUrl(repoPath, ref),
|
|
};
|
|
}
|
|
|
|
const pathValue = normalizeText(value.path);
|
|
const urlValue = normalizeText(value.url);
|
|
if (!pathValue && !urlValue) return null;
|
|
const pathEntry = pathValue
|
|
? normalizeExtensionScreenshotRole(pathValue, relPath, ref)
|
|
: null;
|
|
const urlEntry = urlValue
|
|
? normalizeExtensionScreenshotRole(urlValue, relPath, ref)
|
|
: null;
|
|
|
|
return {
|
|
path: pathEntry?.path || null,
|
|
type: normalizeText(value.type) || pathEntry?.type || urlEntry?.type || null,
|
|
imageUrl: urlEntry?.imageUrl || pathEntry?.imageUrl || null,
|
|
};
|
|
}
|
|
|
|
function resolveExtensionScreenshots(pluginJson, extensionDir, relPath, ref) {
|
|
const inferredAssets = getExtensionAssetInfo(extensionDir, relPath, ref);
|
|
const inferredIcon = inferredAssets?.screenshots?.icon
|
|
? {
|
|
path: inferredAssets.screenshots.icon.path,
|
|
type: inferredAssets.screenshots.icon.type,
|
|
imageUrl: inferredAssets.screenshots.icon.path
|
|
? buildRepoImageUrl(inferredAssets.screenshots.icon.path, ref)
|
|
: null,
|
|
}
|
|
: null;
|
|
const inferredGallery = inferredAssets?.screenshots?.gallery
|
|
? {
|
|
path: inferredAssets.screenshots.gallery.path,
|
|
type: inferredAssets.screenshots.gallery.type,
|
|
imageUrl: inferredAssets.screenshots.gallery.path
|
|
? buildRepoImageUrl(inferredAssets.screenshots.gallery.path, ref)
|
|
: null,
|
|
}
|
|
: null;
|
|
|
|
const logoEntry = normalizeExtensionScreenshotRole(pluginJson?.logo, relPath, ref);
|
|
const screenshotConfig = pluginJson?.["x-awesome-copilot"]?.screenshots || {};
|
|
const iconEntry = normalizeExtensionScreenshotRole(screenshotConfig.icon, relPath, ref);
|
|
const galleryRaw = screenshotConfig.gallery;
|
|
const firstGalleryEntry = Array.isArray(galleryRaw) ? galleryRaw[0] : galleryRaw;
|
|
const galleryEntry = normalizeExtensionScreenshotRole(firstGalleryEntry, relPath, ref);
|
|
|
|
const finalIcon = iconEntry || logoEntry || inferredIcon;
|
|
const finalGallery = galleryEntry || logoEntry || inferredGallery || finalIcon;
|
|
|
|
return {
|
|
screenshots: {
|
|
icon: finalIcon
|
|
? {
|
|
path: finalIcon.path,
|
|
type: finalIcon.type,
|
|
}
|
|
: null,
|
|
gallery: finalGallery
|
|
? {
|
|
path: finalGallery.path,
|
|
type: finalGallery.type,
|
|
}
|
|
: null,
|
|
},
|
|
assetPath: finalIcon?.path || inferredAssets?.assetPath || null,
|
|
imageUrl: finalIcon?.imageUrl || inferredAssets?.imageUrl || null,
|
|
};
|
|
}
|
|
|
|
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 pluginJsonPath = path.join(extensionDir, ".github", "plugin", "plugin.json");
|
|
const pluginJson = fs.existsSync(pluginJsonPath)
|
|
? JSON.parse(fs.readFileSync(pluginJsonPath, "utf-8"))
|
|
: {};
|
|
const keywordsSource = Array.isArray(pluginJson.keywords)
|
|
? pluginJson.keywords
|
|
: Array.isArray(packageJson.keywords)
|
|
? packageJson.keywords
|
|
: [];
|
|
const keywords = [...new Set(
|
|
keywordsSource
|
|
.filter((keyword) => typeof keyword === "string")
|
|
.map((keyword) => keyword.trim())
|
|
.filter(Boolean)
|
|
)].sort((a, b) => a.localeCompare(b));
|
|
const extensionDescription = normalizeText(
|
|
pluginJson.description,
|
|
normalizeText(packageJson.description, "Canvas extension")
|
|
);
|
|
const extensionName = normalizeText(pluginJson.name, normalizeText(packageJson.name, dir.name));
|
|
const extensionVersion = normalizeText(pluginJson.version, normalizeText(packageJson.version, "1.0.0"));
|
|
const readmeFile = fs.existsSync(path.join(extensionDir, "README.md"))
|
|
? `${relPath}/README.md`
|
|
: null;
|
|
const screenshots = resolveExtensionScreenshots(pluginJson, 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,
|
|
"/"
|
|
)}`;
|
|
const installCommand = `copilot plugin install ${extensionName}@awesome-copilot`;
|
|
|
|
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,
|
|
pluginName: extensionName,
|
|
name: canvasName,
|
|
version: extensionVersion,
|
|
readmeFile,
|
|
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,
|
|
installCommand,
|
|
sourceUrl: null,
|
|
external: false,
|
|
author: normalizeAuthor(pluginJson.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,
|
|
pluginName: null,
|
|
name,
|
|
version: normalizeText(ext?.version, "1.0.0"),
|
|
readmeFile: null,
|
|
description: normalizeText(ext?.description, "External canvas extension"),
|
|
path: null,
|
|
ref: null,
|
|
lastUpdated: null,
|
|
screenshots,
|
|
imageUrl,
|
|
assetPath,
|
|
installUrl,
|
|
installCommand: null,
|
|
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(extensionManifestData) {
|
|
if (!extensionManifestData || !Array.isArray(extensionManifestData.items)) {
|
|
return { items: [], filters: { keywords: [] } };
|
|
}
|
|
|
|
const items = extensionManifestData.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 };
|
|
}
|
|
|
|
/**
|
|
* 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 extensionManifestData = generateCanvasManifest(gitDates, commitSha);
|
|
const extensionsData = generateExtensionsData(extensionManifestData);
|
|
const extensions = extensionsData.items;
|
|
console.log(
|
|
`✓ Generated ${extensions.length} extensions (${extensionsData.filters.keywords.length} keywords)`
|
|
);
|
|
|
|
const resourceIndex = buildResourceIndex({
|
|
agents,
|
|
skills,
|
|
instructions,
|
|
hooks,
|
|
extensions,
|
|
});
|
|
const pluginsData = generatePluginsData(gitDates, resourceIndex);
|
|
const plugins = pluginsData.items;
|
|
console.log(
|
|
`✓ Generated ${plugins.length} plugins (${pluginsData.filters.tags.length} tags)`
|
|
);
|
|
|
|
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)
|
|
);
|
|
|
|
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);
|
|
});
|