import { getEmbeddedData as getEmbeddedPageData } from "./embedded-data"; /** * Utility functions for the Awesome Copilot website */ const REPO_BASE_URL = "https://raw.githubusercontent.com/github/awesome-copilot/main"; const REPO_GITHUB_URL = "https://github.com/github/awesome-copilot/blob/main"; // VS Code install URL configurations const VSCODE_INSTALL_CONFIG: Record< string, { baseUrl: string; scheme: string } > = { instructions: { baseUrl: "https://aka.ms/awesome-copilot/install/instructions", scheme: "chat-instructions", }, instruction: { baseUrl: "https://aka.ms/awesome-copilot/install/instructions", scheme: "chat-instructions", }, agent: { baseUrl: "https://aka.ms/awesome-copilot/install/agent", scheme: "chat-agent", }, }; /** * Get the base path for the site */ export function getBasePath(): string { // In Astro, import.meta.env.BASE_URL is available at build time // At runtime, we use a data attribute on the body if (typeof document !== "undefined") { return document.body.dataset.basePath || "/"; } return "/"; } /** * Fetch JSON data from the data directory */ export async function fetchData( filename: string ): Promise { const embeddedData = getEmbeddedPageData(filename); if (embeddedData !== null) return embeddedData; try { const basePath = getBasePath(); const response = await fetch(`${basePath}data/${filename}`); if (!response.ok) throw new Error(`Failed to fetch ${filename}`); return await response.json(); } catch (error) { console.error(`Error fetching ${filename}:`, error); return null; } } let jsZipPromise: Promise | null = null; /** * Lazy-load JSZip only when downloads are requested */ export async function loadJSZip() { jsZipPromise ??= import("./jszip"); const { default: JSZip } = await jsZipPromise; return JSZip; } export interface ZipDownloadFile { name: string; path: string; } function triggerBlobDownload(blob: Blob, filename: string): void { const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } export async function downloadZipBundle( bundleName: string, files: ZipDownloadFile[] ): Promise { if (files.length === 0) { throw new Error("No files found for this download."); } const JSZip = await loadJSZip(); const zip = new JSZip(); const folder = zip.folder(bundleName); const fetchPromises = files.map(async (file) => { try { const response = await fetch(getRawGitHubUrl(file.path)); if (!response.ok) return null; return { name: file.name, content: await response.text(), }; } catch { return null; } }); const results = await Promise.all(fetchPromises); let addedFiles = 0; for (const result of results) { if (result && folder) { folder.file(result.name, result.content); addedFiles++; } } if (addedFiles === 0) { throw new Error("Failed to fetch any files"); } const blob = await zip.generateAsync({ type: "blob" }); triggerBlobDownload(blob, `${bundleName}.zip`); } /** * Fetch raw file content from GitHub */ export async function fetchFileContent( filePath: string ): Promise { try { const response = await fetch(`${REPO_BASE_URL}/${filePath}`); if (!response.ok) throw new Error(`Failed to fetch ${filePath}`); return await response.text(); } catch (error) { console.error(`Error fetching file content:`, error); return null; } } /** * Copy text to clipboard */ export async function copyToClipboard(text: string): Promise { try { await navigator.clipboard.writeText(text); return true; } catch { // Deprecated fallback for older browsers that lack the async clipboard API. const textarea = document.createElement("textarea"); textarea.value = text; textarea.style.position = "fixed"; textarea.style.opacity = "0"; document.body.appendChild(textarea); textarea.select(); const success = document.execCommand("copy"); document.body.removeChild(textarea); return success; } } /** * Generate VS Code install URL * @param type - Resource type (agent, instructions) * @param filePath - Path to the file * @param insiders - Whether to use VS Code Insiders */ export function getVSCodeInstallUrl( type: string, filePath: string, insiders = false ): string | null { const config = VSCODE_INSTALL_CONFIG[type]; if (!config) return null; const rawUrl = `${REPO_BASE_URL}/${filePath}`; const vscodeScheme = insiders ? "vscode-insiders" : "vscode"; const innerUrl = `${vscodeScheme}:${ config.scheme }/install?url=${encodeURIComponent(rawUrl)}`; return `${config.baseUrl}?url=${encodeURIComponent(innerUrl)}`; } /** * Get GitHub URL for a file */ export function getGitHubUrl(filePath: string): string { return `${REPO_GITHUB_URL}/${filePath}`; } /** * Get raw GitHub URL for a file (for fetching content) */ export function getRawGitHubUrl(filePath: string): string { return `${REPO_BASE_URL}/${filePath}`; } /** * Download a file from its path */ export async function downloadFile(filePath: string): Promise { try { const response = await fetch(`${REPO_BASE_URL}/${filePath}`); if (!response.ok) throw new Error("Failed to fetch file"); const content = await response.text(); const filename = filePath.split("/").pop() || "file.md"; const blob = new Blob([content], { type: "text/markdown" }); triggerBlobDownload(blob, filename); return true; } catch (error) { console.error("Download failed:", error); return false; } } /** * Share/copy link to clipboard (deep link to current page with file hash) */ export async function shareFile(filePath: string): Promise { const deepLinkUrl = `${window.location.origin}${ window.location.pathname }#file=${encodeURIComponent(filePath)}`; return copyToClipboard(deepLinkUrl); } /** * Show a toast notification */ export function showToast( message: string, type: "success" | "error" = "success" ): void { const existing = document.querySelector(".toast"); if (existing) existing.remove(); const toast = document.createElement("div"); toast.className = `toast ${type}`; toast.textContent = message; document.body.appendChild(toast); setTimeout(() => { toast.remove(); }, 3000); } /** * Debounce function for search input */ export function debounce void>( func: T, wait: number ): (...args: Parameters) => void { let timeout: ReturnType; return function executedFunction(...args: Parameters) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } /** * Escape HTML to prevent XSS */ export function escapeHtml(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } /** * Validate and sanitize URLs to prevent XSS attacks * Only allows http/https protocols, returns '#' for invalid URLs */ export function sanitizeUrl(url: string | null | undefined): string { if (!url) return '#'; try { const parsed = new URL(url); // Only allow http and https protocols if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { return url; } } catch { // Invalid URL } return '#'; } /** * Truncate text with ellipsis */ export function truncate(text: string | undefined, maxLength: number): string { if (!text || text.length <= maxLength) return text || ""; return text.slice(0, maxLength).trim() + "..."; } /** * Get resource type from file path */ export function getResourceType(filePath: string): string { if (filePath.endsWith(".agent.md")) return "agent"; if (filePath.endsWith(".instructions.md")) return "instruction"; if (/(^|\/)skills\//.test(filePath)) return "skill"; if (/(^|\/)hooks\//.test(filePath)) return "hook"; if (/(^|\/)workflows\//.test(filePath) && filePath.endsWith(".md")) return "workflow"; // Check for plugin directories (e.g., plugins/, plugins//) if (/(^|\/)plugins\/[^/]+\/?$/.test(filePath)) return "plugin"; // Check for plugin.json files (e.g., plugins//.github/plugin/plugin.json) if (filePath.endsWith("/.github/plugin/plugin.json")) return "plugin"; return "unknown"; } /** * Format a resource type for display */ export function formatResourceType(type: string): string { const labels: Record = { agent: "🤖 Agent", instruction: "📋 Instruction", skill: "⚡ Skill", hook: "🪝 Hook", workflow: "⚡ Workflow", plugin: "🔌 Plugin", }; return labels[type] || type; } /** * Get icon for resource type (returns SVG icon name) */ export function getResourceIcon(type: string): string { const icons: Record = { agent: "robot", instruction: "document", skill: "lightning", hook: "hook", workflow: "workflow", plugin: "plug", }; return icons[type] || "document"; } // Icon definitions with fill/stroke type info const iconDefs: Record = { // Agent icon - GitHub Primer's agent-24 robot: { fill: true, path: '', }, document: { path: '', }, lightning: { path: '', }, // Hook icon - GitHub Primer's sync-24 hook: { fill: true, path: '', }, // Workflow icon - GitHub Primer's workflow-24 workflow: { fill: true, path: '', }, // Plug icon - GitHub Primer's plug-24 plug: { fill: true, path: '', }, }; /** * Get SVG icon HTML for resource type */ export function getResourceIconSvg(type: string, size = 20): string { const iconName = getResourceIcon(type); const icon = iconDefs[iconName] || iconDefs.document; const fill = icon.fill ? 'fill="currentColor"' : 'fill="none"'; return ``; } /** * Generate HTML for install dropdown button */ export function getInstallDropdownHtml( type: string, filePath: string, small = false ): string { const vscodeUrl = getVSCodeInstallUrl(type, filePath, false); const insidersUrl = getVSCodeInstallUrl(type, filePath, true); if (!vscodeUrl) return ""; const sizeClass = small ? "install-dropdown-small" : ""; const uniqueId = `install-${filePath.replace(/[^a-zA-Z0-9]/g, "-")}`; return ` `; } /** * Setup dropdown close handlers for dynamically created dropdowns */ export function setupDropdownCloseHandlers(): void { if (dropdownHandlersReady) return; dropdownHandlersReady = true; document.addEventListener( "click", (e) => { const target = e.target as HTMLElement; const dropdown = target.closest( '.install-dropdown[data-install-scope="list"]' ); const toggle = target.closest( ".install-btn-toggle" ) as HTMLButtonElement | null; const menuLink = target.closest( ".install-dropdown-menu a" ) as HTMLAnchorElement | null; if (dropdown) { e.stopPropagation(); if (toggle) { e.preventDefault(); const isOpen = dropdown.classList.toggle("open"); toggle.setAttribute("aria-expanded", String(isOpen)); return; } if (menuLink) { dropdown.classList.remove("open"); const toggleBtn = dropdown.querySelector( ".install-btn-toggle" ); toggleBtn?.setAttribute("aria-expanded", "false"); return; } return; } document .querySelectorAll('.install-dropdown[data-install-scope="list"].open') .forEach((openDropdown) => { openDropdown.classList.remove("open"); const toggleBtn = openDropdown.querySelector( ".install-btn-toggle" ); toggleBtn?.setAttribute("aria-expanded", "false"); }); }, true ); } /** * Generate HTML for action buttons (download, share) in list view */ export function getActionButtonsHtml(filePath: string, small = false): string { const btnClass = small ? "btn-small" : ""; const iconSize = small ? 14 : 16; return ` `; } /** * Setup global action handlers for download and share buttons */ export function setupActionHandlers(): void { if (actionHandlersReady) return; actionHandlersReady = true; document.addEventListener( "click", async (e) => { const target = (e.target as HTMLElement).closest( ".action-download, .action-share" ) as HTMLElement | null; if (!target) return; e.preventDefault(); e.stopPropagation(); const path = target.dataset.path; if (!path) return; if (target.classList.contains("action-download")) { const success = await downloadFile(path); showToast( success ? "Download started!" : "Download failed", success ? "success" : "error" ); return; } const success = await shareFile(path); showToast( success ? "Link copied!" : "Failed to copy link", success ? "success" : "error" ); }, true ); } let dropdownHandlersReady = false; let actionHandlersReady = false; /** * Format a date as relative time (e.g., "3 days ago") * @param isoDate - ISO 8601 date string * @returns Relative time string */ export function formatRelativeTime(isoDate: string | null | undefined): string { if (!isoDate) return "Unknown"; const date = new Date(isoDate); if (isNaN(date.getTime())) return "Unknown"; const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffSeconds = Math.floor(diffMs / 1000); const diffMinutes = Math.floor(diffSeconds / 60); const diffHours = Math.floor(diffMinutes / 60); const diffDays = Math.floor(diffHours / 24); const diffWeeks = Math.floor(diffDays / 7); const diffMonths = Math.floor(diffDays / 30); const diffYears = Math.floor(diffDays / 365); if (diffDays === 0) { if (diffHours === 0) { if (diffMinutes === 0) return "just now"; return diffMinutes === 1 ? "1 minute ago" : `${diffMinutes} minutes ago`; } return diffHours === 1 ? "1 hour ago" : `${diffHours} hours ago`; } if (diffDays === 1) return "yesterday"; if (diffDays < 7) return `${diffDays} days ago`; if (diffWeeks === 1) return "1 week ago"; if (diffWeeks < 4) return `${diffWeeks} weeks ago`; if (diffMonths === 1) return "1 month ago"; if (diffMonths < 12) return `${diffMonths} months ago`; if (diffYears === 1) return "1 year ago"; return `${diffYears} years ago`; } /** * Format a date for display (e.g., "January 15, 2026") * @param isoDate - ISO 8601 date string * @returns Formatted date string */ export function formatFullDate(isoDate: string | null | undefined): string { if (!isoDate) return "Unknown"; const date = new Date(isoDate); if (isNaN(date.getTime())) return "Unknown"; return date.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", }); } /** * Generate HTML for displaying last updated time with hover tooltip * @param isoDate - ISO 8601 date string * @returns HTML string with relative time and title attribute */ export function getLastUpdatedHtml(isoDate: string | null | undefined): string { const relativeTime = formatRelativeTime(isoDate); const fullDate = formatFullDate(isoDate); if (relativeTime === "Unknown") { return `Updated: Unknown`; } return `Updated ${relativeTime}`; }