mirror of
https://github.com/github/awesome-copilot.git
synced 2026-02-22 19:35:13 +00:00
feat: Add GitHub Pages website for browsing resources
- Add static website with pages for agents, prompts, instructions, skills, and collections - Implement client-side fuzzy search across all resources - Add file viewer modal with copy-to-clipboard and install-to-editor functionality - Add Tools page for MCP server and future tools - Add Samples page placeholder for copilot-sdk cookbook migration - Add metadata JSON generation script (eng/generate-website-data.mjs) - Add GitHub Actions workflow for automated Pages deployment - Update package.json with website build scripts
This commit is contained in:
250
website/js/app.js
Normal file
250
website/js/app.js
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Main application logic for the Awesome Copilot website
|
||||
*/
|
||||
|
||||
// Modal state
|
||||
let currentFilePath = null;
|
||||
let currentFileContent = null;
|
||||
let currentFileType = null;
|
||||
|
||||
/**
|
||||
* Initialize the application
|
||||
*/
|
||||
async function init() {
|
||||
// Initialize global search
|
||||
await initGlobalSearch();
|
||||
|
||||
// Load stats for homepage
|
||||
await loadStats();
|
||||
|
||||
// Load featured collections for homepage
|
||||
await loadFeaturedCollections();
|
||||
|
||||
// Setup global search
|
||||
setupGlobalSearch();
|
||||
|
||||
// Setup modal
|
||||
setupModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and display stats on homepage
|
||||
*/
|
||||
async function loadStats() {
|
||||
const statsEl = document.getElementById('stats');
|
||||
if (!statsEl) return;
|
||||
|
||||
const manifest = await fetchData('manifest.json');
|
||||
if (!manifest) return;
|
||||
|
||||
const { counts } = manifest;
|
||||
statsEl.innerHTML = `
|
||||
<div class="stat">
|
||||
<div class="stat-value">${counts.agents}</div>
|
||||
<div class="stat-label">Agents</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">${counts.prompts}</div>
|
||||
<div class="stat-label">Prompts</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">${counts.instructions}</div>
|
||||
<div class="stat-label">Instructions</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">${counts.skills}</div>
|
||||
<div class="stat-label">Skills</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">${counts.collections}</div>
|
||||
<div class="stat-label">Collections</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load featured collections for homepage
|
||||
*/
|
||||
async function loadFeaturedCollections() {
|
||||
const container = document.getElementById('featured-collections');
|
||||
if (!container) return;
|
||||
|
||||
const collections = await fetchData('collections.json');
|
||||
if (!collections) return;
|
||||
|
||||
const featured = collections.filter(c => c.featured).slice(0, 6);
|
||||
|
||||
if (featured.length === 0) {
|
||||
// Show first 6 collections if none are featured
|
||||
featured.push(...collections.slice(0, 6));
|
||||
}
|
||||
|
||||
container.innerHTML = featured.map(collection => `
|
||||
<div class="card" onclick="openCollectionModal('${collection.id}')">
|
||||
<div class="card-icon">📦</div>
|
||||
<h3>${escapeHtml(collection.name)}</h3>
|
||||
<p>${escapeHtml(truncate(collection.description, 100))}</p>
|
||||
${collection.tags?.length ? `
|
||||
<div class="resource-meta">
|
||||
${collection.tags.slice(0, 3).map(tag => `
|
||||
<span class="resource-tag">${escapeHtml(tag)}</span>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup global search functionality
|
||||
*/
|
||||
function setupGlobalSearch() {
|
||||
const searchInput = document.getElementById('global-search');
|
||||
const searchResults = document.getElementById('search-results');
|
||||
|
||||
if (!searchInput || !searchResults) return;
|
||||
|
||||
const performSearch = debounce((query) => {
|
||||
if (!query || query.length < 2) {
|
||||
searchResults.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const results = globalSearch.search(query, { limit: 10 });
|
||||
|
||||
if (results.length === 0) {
|
||||
searchResults.innerHTML = `
|
||||
<div class="search-result-item">
|
||||
<span class="search-result-title">No results found</span>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
searchResults.innerHTML = results.map(item => `
|
||||
<div class="search-result-item" onclick="openFileModal('${item.path}', '${item.type}')">
|
||||
<span class="search-result-type">${item.type}</span>
|
||||
<span class="search-result-title">${globalSearch.highlight(item.title, query)}</span>
|
||||
<span class="search-result-description">${escapeHtml(truncate(item.description, 60))}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
searchResults.classList.remove('hidden');
|
||||
}, 200);
|
||||
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
performSearch(e.target.value);
|
||||
});
|
||||
|
||||
// Close results when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) {
|
||||
searchResults.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle keyboard navigation
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
searchResults.classList.add('hidden');
|
||||
searchInput.blur();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup modal functionality
|
||||
*/
|
||||
function setupModal() {
|
||||
const modal = document.getElementById('file-modal');
|
||||
const closeBtn = document.getElementById('close-modal');
|
||||
const copyBtn = document.getElementById('copy-btn');
|
||||
const installBtn = document.getElementById('install-vscode-btn');
|
||||
|
||||
if (!modal) return;
|
||||
|
||||
closeBtn?.addEventListener('click', closeModal);
|
||||
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) closeModal();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
copyBtn?.addEventListener('click', async () => {
|
||||
if (currentFileContent) {
|
||||
const success = await copyToClipboard(currentFileContent);
|
||||
showToast(success ? 'Copied to clipboard!' : 'Failed to copy', success ? 'success' : 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open file viewer modal
|
||||
*/
|
||||
async function openFileModal(filePath, type) {
|
||||
const modal = document.getElementById('file-modal');
|
||||
const title = document.getElementById('modal-title');
|
||||
const content = document.getElementById('modal-content').querySelector('code');
|
||||
const installBtn = document.getElementById('install-vscode-btn');
|
||||
|
||||
if (!modal) return;
|
||||
|
||||
currentFilePath = filePath;
|
||||
currentFileType = type;
|
||||
|
||||
// Show modal with loading state
|
||||
title.textContent = filePath.split('/').pop();
|
||||
content.textContent = 'Loading...';
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
// Setup install button
|
||||
const installUrl = getVSCodeInstallUrl(type, filePath);
|
||||
if (installUrl && installBtn) {
|
||||
installBtn.href = installUrl;
|
||||
installBtn.style.display = 'inline-flex';
|
||||
} else if (installBtn) {
|
||||
installBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
// Fetch and display content
|
||||
const fileContent = await fetchFileContent(filePath);
|
||||
currentFileContent = fileContent;
|
||||
|
||||
if (fileContent) {
|
||||
content.textContent = fileContent;
|
||||
} else {
|
||||
content.textContent = 'Failed to load file content. Click the button below to view on GitHub.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open collection modal (for homepage)
|
||||
*/
|
||||
async function openCollectionModal(collectionId) {
|
||||
const collections = await fetchData('collections.json');
|
||||
const collection = collections?.find(c => c.id === collectionId);
|
||||
|
||||
if (collection) {
|
||||
openFileModal(collection.path, 'collection');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close modal
|
||||
*/
|
||||
function closeModal() {
|
||||
const modal = document.getElementById('file-modal');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
currentFilePath = null;
|
||||
currentFileContent = null;
|
||||
currentFileType = null;
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
139
website/js/search.js
Normal file
139
website/js/search.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Fuzzy search implementation for the Awesome Copilot website
|
||||
* Simple substring matching on title and description with scoring
|
||||
*/
|
||||
|
||||
class FuzzySearch {
|
||||
constructor(items = []) {
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the items to search
|
||||
*/
|
||||
setItems(items) {
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search items with fuzzy matching
|
||||
* @param {string} query - The search query
|
||||
* @param {object} options - Search options
|
||||
* @returns {array} Matching items sorted by relevance
|
||||
*/
|
||||
search(query, options = {}) {
|
||||
const {
|
||||
fields = ['title', 'description', 'searchText'],
|
||||
limit = 50,
|
||||
minScore = 0,
|
||||
} = options;
|
||||
|
||||
if (!query || query.trim().length === 0) {
|
||||
return this.items.slice(0, limit);
|
||||
}
|
||||
|
||||
const normalizedQuery = query.toLowerCase().trim();
|
||||
const queryWords = normalizedQuery.split(/\s+/);
|
||||
const results = [];
|
||||
|
||||
for (const item of this.items) {
|
||||
const score = this.calculateScore(item, queryWords, fields);
|
||||
if (score > minScore) {
|
||||
results.push({ item, score });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score descending
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
|
||||
return results.slice(0, limit).map(r => r.item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate match score for an item
|
||||
*/
|
||||
calculateScore(item, queryWords, fields) {
|
||||
let totalScore = 0;
|
||||
|
||||
for (const word of queryWords) {
|
||||
let wordScore = 0;
|
||||
|
||||
for (const field of fields) {
|
||||
const value = item[field];
|
||||
if (!value) continue;
|
||||
|
||||
const normalizedValue = String(value).toLowerCase();
|
||||
|
||||
// Exact match in title gets highest score
|
||||
if (field === 'title' && normalizedValue === word) {
|
||||
wordScore = Math.max(wordScore, 100);
|
||||
}
|
||||
// Title starts with word
|
||||
else if (field === 'title' && normalizedValue.startsWith(word)) {
|
||||
wordScore = Math.max(wordScore, 80);
|
||||
}
|
||||
// Title contains word
|
||||
else if (field === 'title' && normalizedValue.includes(word)) {
|
||||
wordScore = Math.max(wordScore, 60);
|
||||
}
|
||||
// Description contains word
|
||||
else if (field === 'description' && normalizedValue.includes(word)) {
|
||||
wordScore = Math.max(wordScore, 30);
|
||||
}
|
||||
// searchText (includes tags, tools, etc) contains word
|
||||
else if (field === 'searchText' && normalizedValue.includes(word)) {
|
||||
wordScore = Math.max(wordScore, 20);
|
||||
}
|
||||
}
|
||||
|
||||
totalScore += wordScore;
|
||||
}
|
||||
|
||||
// Bonus for matching all words
|
||||
const matchesAllWords = queryWords.every(word =>
|
||||
fields.some(field => {
|
||||
const value = item[field];
|
||||
return value && String(value).toLowerCase().includes(word);
|
||||
})
|
||||
);
|
||||
|
||||
if (matchesAllWords && queryWords.length > 1) {
|
||||
totalScore *= 1.5;
|
||||
}
|
||||
|
||||
return totalScore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight matching text in a string
|
||||
*/
|
||||
highlight(text, query) {
|
||||
if (!query || !text) return escapeHtml(text || '');
|
||||
|
||||
const normalizedQuery = query.toLowerCase().trim();
|
||||
const words = normalizedQuery.split(/\s+/);
|
||||
let result = escapeHtml(text);
|
||||
|
||||
for (const word of words) {
|
||||
if (word.length < 2) continue;
|
||||
const regex = new RegExp(`(${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
||||
result = result.replace(regex, '<mark>$1</mark>');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Global search instance
|
||||
const globalSearch = new FuzzySearch();
|
||||
|
||||
/**
|
||||
* Initialize global search with search index
|
||||
*/
|
||||
async function initGlobalSearch() {
|
||||
const searchIndex = await fetchData('search-index.json');
|
||||
if (searchIndex) {
|
||||
globalSearch.setItems(searchIndex);
|
||||
}
|
||||
return globalSearch;
|
||||
}
|
||||
168
website/js/utils.js
Normal file
168
website/js/utils.js
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 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 template
|
||||
const VSCODE_INSTALL_URLS = {
|
||||
instructions: 'https://aka.ms/awesome-copilot/install/instructions',
|
||||
prompt: 'https://aka.ms/awesome-copilot/install/prompt',
|
||||
agent: 'https://aka.ms/awesome-copilot/install/agent',
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch JSON data from the data directory
|
||||
*/
|
||||
async function fetchData(filename) {
|
||||
try {
|
||||
const basePath = window.location.pathname.includes('/pages/') ? '..' : '.';
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch raw file content from GitHub
|
||||
*/
|
||||
async function fetchFileContent(filePath) {
|
||||
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
|
||||
*/
|
||||
async function copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Fallback for older browsers
|
||||
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
|
||||
*/
|
||||
function getVSCodeInstallUrl(type, filePath) {
|
||||
const baseUrl = VSCODE_INSTALL_URLS[type];
|
||||
if (!baseUrl) return null;
|
||||
return `${baseUrl}?url=${encodeURIComponent(`${REPO_BASE_URL}/${filePath}`)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get GitHub URL for a file
|
||||
*/
|
||||
function getGitHubUrl(filePath) {
|
||||
return `${REPO_GITHUB_URL}/${filePath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a toast notification
|
||||
*/
|
||||
function showToast(message, type = 'success') {
|
||||
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
|
||||
*/
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text with ellipsis
|
||||
*/
|
||||
function truncate(text, maxLength) {
|
||||
if (!text || text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength).trim() + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resource type from file path
|
||||
*/
|
||||
function getResourceType(filePath) {
|
||||
if (filePath.endsWith('.agent.md')) return 'agent';
|
||||
if (filePath.endsWith('.prompt.md')) return 'prompt';
|
||||
if (filePath.endsWith('.instructions.md')) return 'instruction';
|
||||
if (filePath.includes('/skills/') && filePath.endsWith('SKILL.md')) return 'skill';
|
||||
if (filePath.endsWith('.collection.yml')) return 'collection';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a resource type for display
|
||||
*/
|
||||
function formatResourceType(type) {
|
||||
const labels = {
|
||||
agent: '🤖 Agent',
|
||||
prompt: '🎯 Prompt',
|
||||
instruction: '📋 Instruction',
|
||||
skill: '⚡ Skill',
|
||||
collection: '📦 Collection',
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon for resource type
|
||||
*/
|
||||
function getResourceIcon(type) {
|
||||
const icons = {
|
||||
agent: '🤖',
|
||||
prompt: '🎯',
|
||||
instruction: '📋',
|
||||
skill: '⚡',
|
||||
collection: '📦',
|
||||
};
|
||||
return icons[type] || '📄';
|
||||
}
|
||||
Reference in New Issue
Block a user