diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 7fae37fc..e7f7e81d 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -8,14 +8,15 @@ on: push: branches: ["main"] paths: - - 'website/**' - - 'agents/**' - - 'prompts/**' - - 'instructions/**' - - 'skills/**' - - 'collections/**' - - 'eng/generate-website-data.mjs' - - '.github/workflows/deploy-website.yml' + - "website/**" + - "agents/**" + - "prompts/**" + - "instructions/**" + - "skills/**" + - "collections/**" + - "cookbook/**" + - "eng/generate-website-data.mjs" + - ".github/workflows/deploy-website.yml" # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -43,8 +44,8 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' - cache: 'npm' + node-version: "20" + cache: "npm" - name: Install root dependencies run: npm ci @@ -66,7 +67,7 @@ jobs: - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: - path: './website/dist' + path: "./website/dist" # Deployment job deploy: diff --git a/eng/generate-website-data.mjs b/eng/generate-website-data.mjs index ae8e2ec2..fdd14fc0 100644 --- a/eng/generate-website-data.mjs +++ b/eng/generate-website-data.mjs @@ -7,21 +7,20 @@ */ import fs from "fs"; -import path from "path"; +import path, { dirname } from "path"; import { fileURLToPath } from "url"; -import { dirname } from "path"; import { AGENTS_DIR, - INSTRUCTIONS_DIR, - PROMPTS_DIR, - SKILLS_DIR, COLLECTIONS_DIR, COOKBOOK_DIR, + INSTRUCTIONS_DIR, + PROMPTS_DIR, ROOT_FOLDER, + SKILLS_DIR, } from "./constants.mjs"; import { - parseFrontmatter, parseCollectionYaml, + parseFrontmatter, parseSkillMetadata, parseYamlFile, } from "./yaml-parser.mjs"; @@ -63,17 +62,6 @@ function extractTitle(filePath, frontmatter) { .join(" "); } -/** - * Get file content (for preview/full content) - */ -function getFileContent(filePath) { - try { - return fs.readFileSync(filePath, "utf8"); - } catch (e) { - return null; - } -} - /** * Generate agents metadata */ @@ -90,7 +78,9 @@ function generateAgentsData() { 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 relativePath = path + .relative(ROOT_FOLDER, filePath) + .replace(/\\/g, "/"); const model = frontmatter?.model || null; const tools = frontmatter?.tools || []; @@ -146,7 +136,9 @@ function generatePromptsData() { for (const file of files) { const filePath = path.join(PROMPTS_DIR, file); const frontmatter = parseFrontmatter(filePath); - const relativePath = path.relative(ROOT_FOLDER, filePath).replace(/\\/g, "/"); + const relativePath = path + .relative(ROOT_FOLDER, filePath) + .replace(/\\/g, "/"); const tools = frontmatter?.tools || []; tools.forEach((t) => allTools.add(t)); @@ -181,12 +173,15 @@ function parseApplyToPatterns(applyTo) { // Handle array format if (Array.isArray(applyTo)) { - return applyTo.map(p => p.trim()).filter(p => p.length > 0); + 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); + if (typeof applyTo === "string") { + return applyTo + .split(",") + .map((p) => p.trim()) + .filter((p) => p.length > 0); } return []; @@ -203,7 +198,7 @@ function extractExtensionFromPattern(pattern) { // Match patterns like **/*.{ts,tsx} const braceMatch = pattern.match(/\*\.\{([^}]+)\}$/); if (braceMatch) { - return braceMatch[1].split(',').map(ext => `.${ext.trim()}`); + return braceMatch[1].split(",").map((ext) => `.${ext.trim()}`); } return null; @@ -225,7 +220,9 @@ function generateInstructionsData() { 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 relativePath = path + .relative(ROOT_FOLDER, filePath) + .replace(/\\/g, "/"); const applyToRaw = frontmatter?.applyTo || null; const applyToPatterns = parseApplyToPatterns(applyToRaw); @@ -237,7 +234,7 @@ function generateInstructionsData() { const ext = extractExtensionFromPattern(pattern); if (ext) { if (Array.isArray(ext)) { - ext.forEach(e => { + ext.forEach((e) => { extensions.push(e); allExtensions.add(e); }); @@ -260,7 +257,9 @@ function generateInstructionsData() { }); } - const sortedInstructions = instructions.sort((a, b) => a.title.localeCompare(b.title)); + const sortedInstructions = instructions.sort((a, b) => + a.title.localeCompare(b.title) + ); return { items: sortedInstructions, @@ -277,16 +276,42 @@ function generateInstructionsData() { function categorizeSkill(name, description) { const text = `${name} ${description}`.toLowerCase(); - if (text.includes('azure') || text.includes('appinsights')) return 'Azure'; - if (text.includes('github') || text.includes('gh-cli') || text.includes('git-commit') || text.includes('git ')) return 'Git & GitHub'; - if (text.includes('vscode') || text.includes('vs code')) return 'VS Code'; - if (text.includes('test') || text.includes('qa') || text.includes('playwright')) return 'Testing'; - if (text.includes('microsoft') || text.includes('m365') || text.includes('workiq')) return 'Microsoft'; - if (text.includes('cli') || text.includes('command')) return 'CLI Tools'; - if (text.includes('diagram') || text.includes('plantuml') || text.includes('visual')) return 'Diagrams'; - if (text.includes('nuget') || text.includes('dotnet') || text.includes('.net')) return '.NET'; + if (text.includes("azure") || text.includes("appinsights")) return "Azure"; + if ( + text.includes("github") || + text.includes("gh-cli") || + text.includes("git-commit") || + text.includes("git ") + ) + return "Git & GitHub"; + if (text.includes("vscode") || text.includes("vs code")) return "VS Code"; + if ( + text.includes("test") || + text.includes("qa") || + text.includes("playwright") + ) + return "Testing"; + if ( + text.includes("microsoft") || + text.includes("m365") || + text.includes("workiq") + ) + return "Microsoft"; + if (text.includes("cli") || text.includes("command")) return "CLI Tools"; + if ( + text.includes("diagram") || + text.includes("plantuml") || + text.includes("visual") + ) + return "Diagrams"; + if ( + text.includes("nuget") || + text.includes("dotnet") || + text.includes(".net") + ) + return ".NET"; - return 'Other'; + return "Other"; } /** @@ -296,7 +321,7 @@ function generateSkillsData() { const skills = []; if (!fs.existsSync(SKILLS_DIR)) { - return { items: [], filters: { categories: [], hasAssets: ['Yes', 'No'] } }; + return { items: [], filters: { categories: [], hasAssets: ["Yes", "No"] } }; } const folders = fs @@ -310,7 +335,9 @@ function generateSkillsData() { const metadata = parseSkillMetadata(skillPath); if (metadata) { - const relativePath = path.relative(ROOT_FOLDER, skillPath).replace(/\\/g, "/"); + const relativePath = path + .relative(ROOT_FOLDER, skillPath) + .replace(/\\/g, "/"); const category = categorizeSkill(metadata.name, metadata.description); allCategories.add(category); @@ -342,7 +369,7 @@ function generateSkillsData() { items: sortedSkills, filters: { categories: Array.from(allCategories).sort(), - hasAssets: ['Yes', 'No'], + hasAssets: ["Yes", "No"], }, }; } @@ -373,7 +400,7 @@ function getSkillFiles(skillPath, relativePath) { } } - walkDir(skillPath, ''); + walkDir(skillPath, ""); return files; } @@ -397,7 +424,9 @@ function generateCollectionsData() { for (const file of files) { const filePath = path.join(COLLECTIONS_DIR, file); const data = parseCollectionYaml(filePath); - const relativePath = path.relative(ROOT_FOLDER, filePath).replace(/\\/g, "/"); + const relativePath = path + .relative(ROOT_FOLDER, filePath) + .replace(/\\/g, "/"); if (data) { const tags = data.tags || []; @@ -443,14 +472,14 @@ function generateCollectionsData() { */ 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: [] } }; } @@ -461,7 +490,7 @@ function generateToolsData() { const tools = data.tools.map((tool) => { const category = tool.category || "Other"; allCategories.add(category); - + const tags = tool.tags || []; tags.forEach((t) => allTags.add(t)); @@ -498,7 +527,13 @@ function generateToolsData() { /** * Generate a combined index for search */ -function generateSearchIndex(agents, prompts, instructions, skills, collections) { +function generateSearchIndex( + agents, + prompts, + instructions, + skills, + collections +) { const index = []; for (const agent of agents) { @@ -508,7 +543,9 @@ function generateSearchIndex(agents, prompts, instructions, skills, collections) title: agent.title, description: agent.description, path: agent.path, - searchText: `${agent.title} ${agent.description} ${agent.tools.join(" ")}`.toLowerCase(), + searchText: `${agent.title} ${agent.description} ${agent.tools.join( + " " + )}`.toLowerCase(), }); } @@ -530,7 +567,9 @@ function generateSearchIndex(agents, prompts, instructions, skills, collections) title: instruction.title, description: instruction.description, path: instruction.path, - searchText: `${instruction.title} ${instruction.description} ${instruction.applyTo || ""}`.toLowerCase(), + searchText: `${instruction.title} ${instruction.description} ${ + instruction.applyTo || "" + }`.toLowerCase(), }); } @@ -553,7 +592,9 @@ function generateSearchIndex(agents, prompts, instructions, skills, collections) description: collection.description, path: collection.path, tags: collection.tags, - searchText: `${collection.name} ${collection.description} ${collection.tags.join(" ")}`.toLowerCase(), + searchText: `${collection.name} ${ + collection.description + } ${collection.tags.join(" ")}`.toLowerCase(), }); } @@ -565,47 +606,59 @@ function generateSearchIndex(agents, prompts, instructions, skills, collections) */ 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: [] } }; + 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: [] } }; + return { + cookbooks: [], + totalRecipes: 0, + totalCookbooks: 0, + filters: { languages: [], tags: [] }, + }; } const allLanguages = new Set(); const allTags = new Set(); let totalRecipes = 0; - const cookbooks = cookbookManifest.cookbooks.map(cookbook => { + const cookbooks = cookbookManifest.cookbooks.map((cookbook) => { // Collect languages - cookbook.languages.forEach(lang => allLanguages.add(lang.id)); + cookbook.languages.forEach((lang) => allLanguages.add(lang.id)); // Process recipes and add file paths - const recipes = cookbook.recipes.map(recipe => { + const recipes = cookbook.recipes.map((recipe) => { // Collect tags if (recipe.tags) { - recipe.tags.forEach(tag => allTags.add(tag)); + recipe.tags.forEach((tag) => allTags.add(tag)); } // Build variants with file paths for each language const variants = {}; - cookbook.languages.forEach(lang => { + 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 + example: fs.existsSync(exampleFullPath) ? examplePath : null, }; } }); @@ -617,7 +670,7 @@ function generateSamplesData() { name: recipe.name, description: recipe.description, tags: recipe.tags || [], - variants + variants, }; }); @@ -628,7 +681,7 @@ function generateSamplesData() { path: cookbook.path, featured: cookbook.featured || false, languages: cookbook.languages, - recipes + recipes, }; }); @@ -638,8 +691,8 @@ function generateSamplesData() { totalCookbooks: cookbooks.length, filters: { languages: Array.from(allLanguages).sort(), - tags: Array.from(allTags).sort() - } + tags: Array.from(allTags).sort(), + }, }; } @@ -654,32 +707,52 @@ async function main() { // Generate all data const agentsData = generateAgentsData(); const agents = agentsData.items; - console.log(`✓ Generated ${agents.length} agents (${agentsData.filters.models.length} models, ${agentsData.filters.tools.length} tools)`); + console.log( + `✓ Generated ${agents.length} agents (${agentsData.filters.models.length} models, ${agentsData.filters.tools.length} tools)` + ); const promptsData = generatePromptsData(); const prompts = promptsData.items; - console.log(`✓ Generated ${prompts.length} prompts (${promptsData.filters.tools.length} tools)`); + console.log( + `✓ Generated ${prompts.length} prompts (${promptsData.filters.tools.length} tools)` + ); const instructionsData = generateInstructionsData(); const instructions = instructionsData.items; - console.log(`✓ Generated ${instructions.length} instructions (${instructionsData.filters.extensions.length} extensions)`); + console.log( + `✓ Generated ${instructions.length} instructions (${instructionsData.filters.extensions.length} extensions)` + ); const skillsData = generateSkillsData(); const skills = skillsData.items; - console.log(`✓ Generated ${skills.length} skills (${skillsData.filters.categories.length} categories)`); + console.log( + `✓ Generated ${skills.length} skills (${skillsData.filters.categories.length} categories)` + ); const collectionsData = generateCollectionsData(); const collections = collectionsData.items; - console.log(`✓ Generated ${collections.length} collections (${collectionsData.filters.tags.length} tags)`); + console.log( + `✓ Generated ${collections.length} collections (${collectionsData.filters.tags.length} tags)` + ); const toolsData = generateToolsData(); const tools = toolsData.items; - console.log(`✓ Generated ${tools.length} tools (${toolsData.filters.categories.length} categories)`); + 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)`); + console.log( + `✓ Generated ${samplesData.totalRecipes} recipes in ${samplesData.totalCookbooks} cookbooks (${samplesData.filters.languages.length} languages, ${samplesData.filters.tags.length} tags)` + ); - const searchIndex = generateSearchIndex(agents, prompts, instructions, skills, collections); + const searchIndex = generateSearchIndex( + agents, + prompts, + instructions, + skills, + collections + ); console.log(`✓ Generated search index with ${searchIndex.length} items`); // Write JSON files diff --git a/website/astro.config.mjs b/website/astro.config.mjs index e0176df0..87c9e4e6 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -3,8 +3,8 @@ import { defineConfig } from "astro/config"; // https://astro.build/config export default defineConfig({ - site: "https://github.github.io", - base: "/", + site: "https://github.github.io/awesome-copilot", + base: "/awesome-copilot/", output: "static", integrations: [sitemap()], build: { diff --git a/website/public/styles/global.css b/website/public/styles/global.css index edca1783..81f598c7 100644 --- a/website/public/styles/global.css +++ b/website/public/styles/global.css @@ -24,6 +24,12 @@ --color-text: #e4e4ec; --color-text-muted: #9090a8; --color-text-emphasis: #ffffff; + --color-text-primary: var(--color-text); + --color-text-secondary: var(--color-text-muted); + --color-bg-primary: var(--color-bg); + --color-primary: var(--color-accent); + --color-purple-light: #C898FD; + --color-purple-dark: #43179E; --color-link: #B870FF; --color-link-hover: #C898FD; --color-accent: #8534F3; @@ -46,12 +52,14 @@ --border-radius-lg: 16px; --border-radius-xl: 24px; --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.2), 0 2px 4px -2px rgba(0, 0, 0, 0.2); + --shadow-md: 0 12px 24px -10px rgba(0, 0, 0, 0.4); --shadow-lg: 0 20px 40px -12px rgba(0, 0, 0, 0.5); --shadow-glow: 0 0 40px -10px rgba(133, 52, 243, 0.5); --transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1); --transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1); --container-width: 1200px; --header-height: 72px; + --font-mono: 'Monaspace Argon NF', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; } /* Light theme */ @@ -735,6 +743,17 @@ a:hover { border-color: var(--color-border); } +.btn-outline { + background: transparent; + color: var(--color-text); + border-color: var(--color-border); +} + +.btn-outline:hover { + background: var(--color-bg-tertiary); + border-color: var(--color-link); +} + .btn-icon { padding: 8px; background: transparent; @@ -1360,6 +1379,7 @@ a:hover { text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; + line-clamp: 2; -webkit-box-orient: vertical; } diff --git a/website/src/layouts/BaseLayout.astro b/website/src/layouts/BaseLayout.astro index c2b95c18..a8bc2b5c 100644 --- a/website/src/layouts/BaseLayout.astro +++ b/website/src/layouts/BaseLayout.astro @@ -5,80 +5,161 @@ interface Props { activeNav?: string; } -const { title, description = 'Community-driven collection of custom agents, prompts, and instructions for GitHub Copilot', activeNav = '' } = Astro.props; +const { + title, + description = "Community-driven collection of custom agents, prompts, and instructions for GitHub Copilot", + activeNav = "", +} = Astro.props; const base = import.meta.env.BASE_URL; --- - + - - - - {title} - Awesome GitHub Copilot - - - - - - - - + - + - + - - + + diff --git a/website/src/scripts/modal.ts b/website/src/scripts/modal.ts index 367e808f..38c8e288 100644 --- a/website/src/scripts/modal.ts +++ b/website/src/scripts/modal.ts @@ -2,7 +2,18 @@ * Modal functionality for file viewing */ -import { fetchFileContent, fetchData, getVSCodeInstallUrl, copyToClipboard, showToast, downloadFile, shareFile, getResourceType, escapeHtml, getResourceIcon } from './utils'; +import { + fetchFileContent, + fetchData, + getVSCodeInstallUrl, + copyToClipboard, + showToast, + downloadFile, + shareFile, + getResourceType, + escapeHtml, + getResourceIcon, +} from "./utils"; // Modal state let currentFilePath: string | null = null; @@ -37,29 +48,30 @@ let collectionsCache: CollectionsData | null = null; */ function getFocusableElements(container: HTMLElement): HTMLElement[] { const focusableSelectors = [ - 'button:not([disabled])', - 'a[href]', - 'input:not([disabled])', - 'select:not([disabled])', - 'textarea:not([disabled])', - '[tabindex]:not([tabindex="-1"])' - ].join(', '); - - return Array.from(container.querySelectorAll(focusableSelectors)) - .filter(el => el.offsetParent !== null); // Filter out hidden elements + "button:not([disabled])", + "a[href]", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + '[tabindex]:not([tabindex="-1"])', + ].join(", "); + + return Array.from( + container.querySelectorAll(focusableSelectors) + ).filter((el) => el.offsetParent !== null); // Filter out hidden elements } /** * Handle keyboard navigation within modal (focus trap) */ function handleModalKeydown(e: KeyboardEvent, modal: HTMLElement): void { - if (e.key === 'Tab') { + if (e.key === "Tab") { const focusableElements = getFocusableElements(modal); if (focusableElements.length === 0) return; - + const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; - + if (e.shiftKey) { // Shift+Tab: if on first element, wrap to last if (document.activeElement === firstElement) { @@ -80,23 +92,23 @@ function handleModalKeydown(e: KeyboardEvent, modal: HTMLElement): void { * Setup modal functionality */ export function setupModal(): void { - const modal = document.getElementById('file-modal'); - const closeBtn = document.getElementById('close-modal'); - const copyBtn = document.getElementById('copy-btn'); - const downloadBtn = document.getElementById('download-btn'); - const shareBtn = document.getElementById('share-btn'); + const modal = document.getElementById("file-modal"); + const closeBtn = document.getElementById("close-modal"); + const copyBtn = document.getElementById("copy-btn"); + const downloadBtn = document.getElementById("download-btn"); + const shareBtn = document.getElementById("share-btn"); if (!modal) return; - closeBtn?.addEventListener('click', closeModal); - - modal.addEventListener('click', (e) => { + closeBtn?.addEventListener("click", closeModal); + + modal.addEventListener("click", (e) => { if (e.target === modal) closeModal(); }); - document.addEventListener('keydown', (e) => { - if (!modal.classList.contains('hidden')) { - if (e.key === 'Escape') { + document.addEventListener("keydown", (e) => { + if (!modal.classList.contains("hidden")) { + if (e.key === "Escape") { closeModal(); } else { handleModalKeydown(e, modal); @@ -104,32 +116,41 @@ export function setupModal(): void { } }); - copyBtn?.addEventListener('click', async () => { + copyBtn?.addEventListener("click", async () => { if (currentFileContent) { const success = await copyToClipboard(currentFileContent); - showToast(success ? 'Copied to clipboard!' : 'Failed to copy', success ? 'success' : 'error'); + showToast( + success ? "Copied to clipboard!" : "Failed to copy", + success ? "success" : "error" + ); } }); - downloadBtn?.addEventListener('click', async () => { + downloadBtn?.addEventListener("click", async () => { if (currentFilePath) { const success = await downloadFile(currentFilePath); - showToast(success ? 'Download started!' : 'Download failed', success ? 'success' : 'error'); + showToast( + success ? "Download started!" : "Download failed", + success ? "success" : "error" + ); } }); - shareBtn?.addEventListener('click', async () => { + shareBtn?.addEventListener("click", async () => { if (currentFilePath) { const success = await shareFile(currentFilePath); - showToast(success ? 'Link copied to clipboard!' : 'Failed to copy link', success ? 'success' : 'error'); + showToast( + success ? "Link copied to clipboard!" : "Failed to copy link", + success ? "success" : "error" + ); } }); // Setup install dropdown toggle - setupInstallDropdown('install-dropdown'); + setupInstallDropdown("install-dropdown"); // Handle browser back/forward navigation - window.addEventListener('hashchange', handleHashChange); + window.addEventListener("hashchange", handleHashChange); // Check for deep link on initial load handleHashChange(); @@ -140,14 +161,14 @@ export function setupModal(): void { */ function handleHashChange(): void { const hash = window.location.hash; - - if (hash && hash.startsWith('#file=')) { + + if (hash && hash.startsWith("#file=")) { const filePath = decodeURIComponent(hash.slice(6)); if (filePath && filePath !== currentFilePath) { const type = getResourceType(filePath); openFileModal(filePath, type, false); // Don't update hash since we're responding to it } - } else if (!hash || hash === '#') { + } else if (!hash || hash === "#") { // No hash or empty hash - close modal if open if (currentFilePath) { closeModal(false); // Don't update hash since we're responding to it @@ -162,11 +183,15 @@ function updateHash(filePath: string | null): void { if (filePath) { const newHash = `#file=${encodeURIComponent(filePath)}`; if (window.location.hash !== newHash) { - history.pushState(null, '', newHash); + history.pushState(null, "", newHash); } } else { if (window.location.hash) { - history.pushState(null, '', window.location.pathname + window.location.search); + history.pushState( + null, + "", + window.location.pathname + window.location.search + ); } } } @@ -178,16 +203,19 @@ export function setupInstallDropdown(containerId: string): void { const container = document.getElementById(containerId); if (!container) return; - const toggle = container.querySelector('.install-btn-toggle'); - const menu = container.querySelector('.install-dropdown-menu'); - const menuItems = container.querySelectorAll('.install-dropdown-menu a'); - - toggle?.addEventListener('click', (e) => { + const toggle = container.querySelector( + ".install-btn-toggle" + ); + const menuItems = container.querySelectorAll( + ".install-dropdown-menu a" + ); + + toggle?.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); - const isOpen = container.classList.toggle('open'); - toggle.setAttribute('aria-expanded', String(isOpen)); - + const isOpen = container.classList.toggle("open"); + toggle.setAttribute("aria-expanded", String(isOpen)); + // Focus first menu item when opening if (isOpen && menuItems.length > 0) { menuItems[0].focus(); @@ -195,11 +223,11 @@ export function setupInstallDropdown(containerId: string): void { }); // Keyboard navigation for dropdown - toggle?.addEventListener('keydown', (e) => { - if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { + toggle?.addEventListener("keydown", (e) => { + if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") { e.preventDefault(); - container.classList.add('open'); - toggle.setAttribute('aria-expanded', 'true'); + container.classList.add("open"); + toggle.setAttribute("aria-expanded", "true"); if (menuItems.length > 0) { menuItems[0].focus(); } @@ -208,15 +236,15 @@ export function setupInstallDropdown(containerId: string): void { // Keyboard navigation within menu menuItems.forEach((item, index) => { - item.addEventListener('keydown', (e) => { + item.addEventListener("keydown", (e) => { switch (e.key) { - case 'ArrowDown': + case "ArrowDown": e.preventDefault(); if (index < menuItems.length - 1) { menuItems[index + 1].focus(); } break; - case 'ArrowUp': + case "ArrowUp": e.preventDefault(); if (index > 0) { menuItems[index - 1].focus(); @@ -224,34 +252,34 @@ export function setupInstallDropdown(containerId: string): void { toggle?.focus(); } break; - case 'Escape': + case "Escape": e.preventDefault(); - container.classList.remove('open'); - toggle?.setAttribute('aria-expanded', 'false'); + container.classList.remove("open"); + toggle?.setAttribute("aria-expanded", "false"); toggle?.focus(); break; - case 'Tab': + case "Tab": // Close menu on tab out - container.classList.remove('open'); - toggle?.setAttribute('aria-expanded', 'false'); + container.classList.remove("open"); + toggle?.setAttribute("aria-expanded", "false"); break; } }); }); // Close dropdown when clicking outside - document.addEventListener('click', (e) => { + document.addEventListener("click", (e) => { if (!container.contains(e.target as Node)) { - container.classList.remove('open'); - toggle?.setAttribute('aria-expanded', 'false'); + container.classList.remove("open"); + toggle?.setAttribute("aria-expanded", "false"); } }); // Close dropdown when clicking a menu item - container.querySelectorAll('.install-dropdown-menu a').forEach(link => { - link.addEventListener('click', () => { - container.classList.remove('open'); - toggle?.setAttribute('aria-expanded', 'false'); + container.querySelectorAll(".install-dropdown-menu a").forEach((link) => { + link.addEventListener("click", () => { + container.classList.remove("open"); + toggle?.setAttribute("aria-expanded", "false"); }); }); } @@ -263,84 +291,103 @@ export function setupInstallDropdown(containerId: string): void { * @param updateUrl - Whether to update the URL hash (default: true) * @param trigger - The element that triggered the modal (for focus return) */ -export async function openFileModal(filePath: string, type: string, updateUrl = true, trigger?: HTMLElement): Promise { - const modal = document.getElementById('file-modal'); - const title = document.getElementById('modal-title'); - const modalContent = document.getElementById('modal-content'); - const contentEl = modalContent?.querySelector('code'); - const installDropdown = document.getElementById('install-dropdown'); - const installBtnMain = document.getElementById('install-btn-main') as HTMLAnchorElement | null; - const installVscode = document.getElementById('install-vscode') as HTMLAnchorElement | null; - const installInsiders = document.getElementById('install-insiders') as HTMLAnchorElement | null; - const copyBtn = document.getElementById('copy-btn'); - const downloadBtn = document.getElementById('download-btn'); - const closeBtn = document.getElementById('close-modal'); +export async function openFileModal( + filePath: string, + type: string, + updateUrl = true, + trigger?: HTMLElement +): Promise { + const modal = document.getElementById("file-modal"); + const title = document.getElementById("modal-title"); + const modalContent = document.getElementById("modal-content"); + const contentEl = modalContent?.querySelector("code"); + const installDropdown = document.getElementById("install-dropdown"); + const installBtnMain = document.getElementById( + "install-btn-main" + ) as HTMLAnchorElement | null; + const installVscode = document.getElementById( + "install-vscode" + ) as HTMLAnchorElement | null; + const installInsiders = document.getElementById( + "install-insiders" + ) as HTMLAnchorElement | null; + const copyBtn = document.getElementById("copy-btn"); + const downloadBtn = document.getElementById("download-btn"); + const closeBtn = document.getElementById("close-modal"); if (!modal || !title || !modalContent) return; currentFilePath = filePath; currentFileType = type; - + // Track trigger element for focus return - triggerElement = trigger || document.activeElement as HTMLElement; - + triggerElement = trigger || (document.activeElement as HTMLElement); + // Update URL for deep linking if (updateUrl) { updateHash(filePath); } - + // Show modal with loading state - title.textContent = filePath.split('/').pop() || filePath; - modal.classList.remove('hidden'); - + title.textContent = filePath.split("/").pop() || filePath; + modal.classList.remove("hidden"); + // Set focus to close button for accessibility setTimeout(() => { closeBtn?.focus(); }, 0); // Handle collections differently - show as item list - if (type === 'collection') { - await openCollectionModal(filePath, title, modalContent, installDropdown, copyBtn, downloadBtn); + if (type === "collection") { + await openCollectionModal( + filePath, + title, + modalContent, + installDropdown, + copyBtn, + downloadBtn + ); return; } // Regular file modal if (contentEl) { - contentEl.textContent = 'Loading...'; + contentEl.textContent = "Loading..."; } - + // Show copy/download buttons for regular files - if (copyBtn) copyBtn.style.display = 'inline-flex'; - if (downloadBtn) downloadBtn.style.display = 'inline-flex'; - + if (copyBtn) copyBtn.style.display = "inline-flex"; + if (downloadBtn) downloadBtn.style.display = "inline-flex"; + // Restore pre/code structure if it was replaced by collection view - if (!modalContent.querySelector('pre')) { + if (!modalContent.querySelector("pre")) { modalContent.innerHTML = ''; } - const codeEl = modalContent.querySelector('code'); + const codeEl = modalContent.querySelector("code"); // Setup install dropdown const vscodeUrl = getVSCodeInstallUrl(type, filePath, false); const insidersUrl = getVSCodeInstallUrl(type, filePath, true); - + if (vscodeUrl && installDropdown) { - installDropdown.style.display = 'inline-flex'; - installDropdown.classList.remove('open'); + installDropdown.style.display = "inline-flex"; + installDropdown.classList.remove("open"); if (installBtnMain) installBtnMain.href = vscodeUrl; if (installVscode) installVscode.href = vscodeUrl; - if (installInsiders) installInsiders.href = insidersUrl || '#'; + if (installInsiders) installInsiders.href = insidersUrl || "#"; } else if (installDropdown) { - installDropdown.style.display = 'none'; + installDropdown.style.display = "none"; } // Fetch and display content const fileContent = await fetchFileContent(filePath); currentFileContent = fileContent; - + if (fileContent && codeEl) { codeEl.textContent = fileContent; } else if (codeEl) { - codeEl.textContent = 'Failed to load file content. Click the button below to view on GitHub.'; + codeEl.textContent = + "Failed to load file content. Click the button below to view on GitHub."; } } @@ -356,27 +403,30 @@ async function openCollectionModal( downloadBtn: HTMLElement | null ): Promise { // Hide install dropdown and copy/download for collections - if (installDropdown) installDropdown.style.display = 'none'; - if (copyBtn) copyBtn.style.display = 'none'; - if (downloadBtn) downloadBtn.style.display = 'none'; + if (installDropdown) installDropdown.style.display = "none"; + if (copyBtn) copyBtn.style.display = "none"; + if (downloadBtn) downloadBtn.style.display = "none"; // Show loading - modalContent.innerHTML = '
Loading collection...
'; + modalContent.innerHTML = + '
Loading collection...
'; // Load collections data if not cached if (!collectionsCache) { - collectionsCache = await fetchData('collections.json'); + collectionsCache = await fetchData("collections.json"); } if (!collectionsCache) { - modalContent.innerHTML = '
Failed to load collection data.
'; + modalContent.innerHTML = + '
Failed to load collection data.
'; return; } // Find the collection - const collection = collectionsCache.items.find(c => c.path === filePath); + const collection = collectionsCache.items.find((c) => c.path === filePath); if (!collection) { - modalContent.innerHTML = '
Collection not found.
'; + modalContent.innerHTML = + '
Collection not found.
'; return; } @@ -386,33 +436,57 @@ async function openCollectionModal( // Render collection view modalContent.innerHTML = `
-
${escapeHtml(collection.description || '')}
- ${collection.tags && collection.tags.length > 0 ? ` +
${escapeHtml( + collection.description || "" + )}
+ ${ + collection.tags && collection.tags.length > 0 + ? `
- ${collection.tags.map(t => `${escapeHtml(t)}`).join('')} + ${collection.tags + .map((t) => `${escapeHtml(t)}`) + .join("")}
- ` : ''} + ` + : "" + }
${collection.items.length} items in this collection
- ${collection.items.map(item => ` -
- ${getResourceIcon(item.kind)} + ${collection.items + .map( + (item) => ` +
+ ${getResourceIcon( + item.kind + )}
-
${escapeHtml(item.path.split('/').pop() || item.path)}
- ${item.usage ? `
${escapeHtml(item.usage)}
` : ''} +
${escapeHtml( + item.path.split("/").pop() || item.path + )}
+ ${ + item.usage + ? `
${escapeHtml( + item.usage + )}
` + : "" + }
${escapeHtml(item.kind)}
- `).join('')} + ` + ) + .join("")}
`; // Add click handlers to collection items - modalContent.querySelectorAll('.collection-item').forEach(el => { - el.addEventListener('click', () => { + modalContent.querySelectorAll(".collection-item").forEach((el) => { + el.addEventListener("click", () => { const path = (el as HTMLElement).dataset.path; const itemType = (el as HTMLElement).dataset.type; if (path && itemType) { @@ -427,26 +501,30 @@ async function openCollectionModal( * @param updateUrl - Whether to update the URL hash (default: true) */ export function closeModal(updateUrl = true): void { - const modal = document.getElementById('file-modal'); - const installDropdown = document.getElementById('install-dropdown'); - + const modal = document.getElementById("file-modal"); + const installDropdown = document.getElementById("install-dropdown"); + if (modal) { - modal.classList.add('hidden'); + modal.classList.add("hidden"); } if (installDropdown) { - installDropdown.classList.remove('open'); + installDropdown.classList.remove("open"); } - + // Update URL for deep linking if (updateUrl) { updateHash(null); } - + // Return focus to trigger element - if (triggerElement && typeof triggerElement.focus === 'function') { + if ( + triggerElement && + triggerElement.isConnected && + typeof triggerElement.focus === "function" + ) { triggerElement.focus(); } - + currentFilePath = null; currentFileContent = null; currentFileType = null; diff --git a/website/src/scripts/pages/samples.ts b/website/src/scripts/pages/samples.ts index 1ab73a7b..8a896284 100644 --- a/website/src/scripts/pages/samples.ts +++ b/website/src/scripts/pages/samples.ts @@ -2,9 +2,9 @@ * Samples/Cookbook page functionality */ -import { FuzzySearch, type SearchableItem } from '../search'; -import { fetchData, escapeHtml } from '../utils'; -import { createChoices, getChoicesValues, type Choices } from '../choices'; +import { FuzzySearch, type SearchableItem } from "../search"; +import { fetchData, escapeHtml } from "../utils"; +import { createChoices, getChoicesValues, type Choices } from "../choices"; // Types interface Language { @@ -59,36 +59,44 @@ let tagChoices: Choices | null = null; * Initialize the samples page */ export async function initSamplesPage(): Promise { - // Load samples data - samplesData = await fetchData('samples.json'); - - if (!samplesData || samplesData.cookbooks.length === 0) { + try { + // Load samples data + samplesData = await fetchData("samples.json"); + + if (!samplesData || samplesData.cookbooks.length === 0) { + showEmptyState(); + return; + } + + // Initialize search with all recipes + const allRecipes = samplesData.cookbooks.flatMap((cookbook) => + cookbook.recipes.map( + (recipe) => + ({ + ...recipe, + title: recipe.name, + cookbookId: cookbook.id, + } as SearchableItem & { cookbookId: string }) + ) + ); + search = new FuzzySearch(allRecipes); + + // Setup UI + setupFilters(); + setupSearch(); + renderCookbooks(); + updateResultsCount(); + } catch (error) { + console.error("Failed to initialize samples page:", error); showEmptyState(); - return; } - - // Initialize search with all recipes - const allRecipes = samplesData.cookbooks.flatMap(cookbook => - cookbook.recipes.map(recipe => ({ - ...recipe, - title: recipe.name, - cookbookId: cookbook.id - } as SearchableItem & { cookbookId: string })) - ); - search = new FuzzySearch(allRecipes); - - // Setup UI - setupFilters(); - setupSearch(); - renderCookbooks(); - updateResultsCount(); } /** * Show empty state when no cookbooks are available */ function showEmptyState(): void { - const container = document.getElementById('samples-list'); + const container = document.getElementById("samples-list"); if (container) { container.innerHTML = `
@@ -97,10 +105,10 @@ function showEmptyState(): void {
`; } - + // Hide filters - const filtersBar = document.getElementById('filters-bar'); - if (filtersBar) filtersBar.style.display = 'none'; + const filtersBar = document.getElementById("filters-bar"); + if (filtersBar) filtersBar.style.display = "none"; } /** @@ -110,12 +118,14 @@ function setupFilters(): void { if (!samplesData) return; // Language filter - const languageSelect = document.getElementById('filter-language') as HTMLSelectElement; + const languageSelect = document.getElementById( + "filter-language" + ) as HTMLSelectElement; if (languageSelect) { // Get unique languages across all cookbooks const languages = new Map(); - samplesData.cookbooks.forEach(cookbook => { - cookbook.languages.forEach(lang => { + samplesData.cookbooks.forEach((cookbook) => { + cookbook.languages.forEach((lang) => { if (!languages.has(lang.id)) { languages.set(lang.id, lang); } @@ -124,13 +134,13 @@ function setupFilters(): void { languageSelect.innerHTML = ''; languages.forEach((lang, id) => { - const option = document.createElement('option'); + const option = document.createElement("option"); option.value = id; option.textContent = `${lang.icon} ${lang.name}`; languageSelect.appendChild(option); }); - languageSelect.addEventListener('change', () => { + languageSelect.addEventListener("change", () => { selectedLanguage = languageSelect.value || null; renderCookbooks(); updateResultsCount(); @@ -138,18 +148,18 @@ function setupFilters(): void { } // Tag filter (multi-select with Choices.js) - const tagSelect = document.getElementById('filter-tag') as HTMLSelectElement; + const tagSelect = document.getElementById("filter-tag") as HTMLSelectElement; if (tagSelect && samplesData.filters.tags.length > 0) { // Initialize Choices.js - tagChoices = createChoices('#filter-tag', { placeholderValue: 'All Tags' }); + tagChoices = createChoices("#filter-tag", { placeholderValue: "All Tags" }); tagChoices.setChoices( - samplesData.filters.tags.map(tag => ({ value: tag, label: tag })), - 'value', - 'label', + samplesData.filters.tags.map((tag) => ({ value: tag, label: tag })), + "value", + "label", true ); - tagSelect.addEventListener('change', () => { + tagSelect.addEventListener("change", () => { selectedTags = getChoicesValues(tagChoices!); renderCookbooks(); updateResultsCount(); @@ -157,19 +167,21 @@ function setupFilters(): void { } // Clear filters button - const clearBtn = document.getElementById('clear-filters'); - clearBtn?.addEventListener('click', clearFilters); + const clearBtn = document.getElementById("clear-filters"); + clearBtn?.addEventListener("click", clearFilters); } /** * Setup search functionality */ function setupSearch(): void { - const searchInput = document.getElementById('search-input') as HTMLInputElement; + const searchInput = document.getElementById( + "search-input" + ) as HTMLInputElement; if (!searchInput) return; let debounceTimer: number; - searchInput.addEventListener('input', () => { + searchInput.addEventListener("input", () => { clearTimeout(debounceTimer); debounceTimer = window.setTimeout(() => { renderCookbooks(); @@ -185,16 +197,20 @@ function clearFilters(): void { selectedLanguage = null; selectedTags = []; - const languageSelect = document.getElementById('filter-language') as HTMLSelectElement; - if (languageSelect) languageSelect.value = ''; + const languageSelect = document.getElementById( + "filter-language" + ) as HTMLSelectElement; + if (languageSelect) languageSelect.value = ""; // Clear Choices.js selection if (tagChoices) { tagChoices.removeActiveItems(); } - const searchInput = document.getElementById('search-input') as HTMLInputElement; - if (searchInput) searchInput.value = ''; + const searchInput = document.getElementById( + "search-input" + ) as HTMLInputElement; + if (searchInput) searchInput.value = ""; renderCookbooks(); updateResultsCount(); @@ -203,44 +219,53 @@ function clearFilters(): void { /** * Get filtered recipes */ -function getFilteredRecipes(): { cookbook: Cookbook; recipe: Recipe; highlighted?: string }[] { +function getFilteredRecipes(): { + cookbook: Cookbook; + recipe: Recipe; + highlighted?: string; +}[] { if (!samplesData || !search) return []; - const searchInput = document.getElementById('search-input') as HTMLInputElement; - const query = searchInput?.value.trim() || ''; + const searchInput = document.getElementById( + "search-input" + ) as HTMLInputElement; + const query = searchInput?.value.trim() || ""; - let results: { cookbook: Cookbook; recipe: Recipe; highlighted?: string }[] = []; + let results: { cookbook: Cookbook; recipe: Recipe; highlighted?: string }[] = + []; if (query) { // Use fuzzy search - returns SearchableItem[] directly const searchResults = search.search(query); - results = searchResults.map(item => { + results = searchResults.map((item) => { const recipe = item as SearchableItem & { cookbookId: string }; - const cookbook = samplesData!.cookbooks.find(c => c.id === recipe.cookbookId)!; + const cookbook = samplesData!.cookbooks.find( + (c) => c.id === recipe.cookbookId + )!; return { cookbook, recipe: recipe as unknown as Recipe, - highlighted: search!.highlight(recipe.title, query) + highlighted: search!.highlight(recipe.title, query), }; }); } else { // No search query - return all recipes - results = samplesData.cookbooks.flatMap(cookbook => - cookbook.recipes.map(recipe => ({ cookbook, recipe })) + results = samplesData.cookbooks.flatMap((cookbook) => + cookbook.recipes.map((recipe) => ({ cookbook, recipe })) ); } // Apply language filter if (selectedLanguage) { - results = results.filter(({ recipe }) => - recipe.variants[selectedLanguage!] + results = results.filter( + ({ recipe }) => recipe.variants[selectedLanguage!] ); } // Apply tag filter if (selectedTags.length > 0) { results = results.filter(({ recipe }) => - selectedTags.some(tag => recipe.tags.includes(tag)) + selectedTags.some((tag) => recipe.tags.includes(tag)) ); } @@ -251,7 +276,7 @@ function getFilteredRecipes(): { cookbook: Cookbook; recipe: Recipe; highlighted * Render cookbooks and recipes */ function renderCookbooks(): void { - const container = document.getElementById('samples-list'); + const container = document.getElementById("samples-list"); if (!container || !samplesData) return; const filteredResults = getFilteredRecipes(); @@ -267,7 +292,10 @@ function renderCookbooks(): void { } // Group by cookbook - const byCookbook = new Map(); + const byCookbook = new Map< + string, + { cookbook: Cookbook; recipes: { recipe: Recipe; highlighted?: string }[] } + >(); filteredResults.forEach(({ cookbook, recipe, highlighted }) => { if (!byCookbook.has(cookbook.id)) { byCookbook.set(cookbook.id, { cookbook, recipes: [] }); @@ -275,7 +303,7 @@ function renderCookbooks(): void { byCookbook.get(cookbook.id)!.recipes.push({ recipe, highlighted }); }); - let html = ''; + let html = ""; byCookbook.forEach(({ cookbook, recipes }) => { html += renderCookbookSection(cookbook, recipes); }); @@ -289,18 +317,27 @@ function renderCookbooks(): void { /** * Render a cookbook section */ -function renderCookbookSection(cookbook: Cookbook, recipes: { recipe: Recipe; highlighted?: string }[]): string { - const languageTabs = cookbook.languages.map(lang => ` - - `).join(''); + ` + ) + .join(""); - const recipeCards = recipes.map(({ recipe, highlighted }) => - renderRecipeCard(cookbook, recipe, highlighted) - ).join(''); + const recipeCards = recipes + .map(({ recipe, highlighted }) => + renderRecipeCard(cookbook, recipe, highlighted) + ) + .join(""); return `
@@ -323,25 +360,36 @@ function renderCookbookSection(cookbook: Cookbook, recipes: { recipe: Recipe; hi /** * Render a recipe card */ -function renderRecipeCard(cookbook: Cookbook, recipe: Recipe, highlightedName?: string): string { +function renderRecipeCard( + cookbook: Cookbook, + recipe: Recipe, + highlightedName?: string +): string { const recipeKey = `${cookbook.id}-${recipe.id}`; const isExpanded = expandedRecipes.has(recipeKey); - + // Determine which language to show - const displayLang = selectedLanguage || cookbook.languages[0]?.id || 'nodejs'; + const displayLang = selectedLanguage || cookbook.languages[0]?.id || "nodejs"; const variant = recipe.variants[displayLang]; - - const tags = recipe.tags.map(tag => - `${escapeHtml(tag)}` - ).join(''); + + const tags = recipe.tags + .map((tag) => `${escapeHtml(tag)}`) + .join(""); const langIndicators = cookbook.languages - .filter(lang => recipe.variants[lang.id]) - .map(lang => `${lang.icon}`) - .join(''); + .filter((lang) => recipe.variants[lang.id]) + .map( + (lang) => + `${lang.icon}` + ) + .join(""); return ` -
+

${highlightedName || escapeHtml(recipe.name)}

${langIndicators}
@@ -349,29 +397,41 @@ function renderRecipeCard(cookbook: Cookbook, recipe: Recipe, highlightedName?:

${escapeHtml(recipe.description)}

${tags}
- ${variant ? ` - - ${variant.example ? ` + ${ + variant.example + ? ` - ` : ''} - GitHub - ` : 'Not available for selected language'} + ` + : 'Not available for selected language' + }
`; @@ -382,35 +442,37 @@ function renderRecipeCard(cookbook: Cookbook, recipe: Recipe, highlightedName?: */ function setupRecipeListeners(): void { // View recipe buttons - document.querySelectorAll('.view-recipe-btn').forEach(btn => { - btn.addEventListener('click', async (e) => { + document.querySelectorAll(".view-recipe-btn").forEach((btn) => { + btn.addEventListener("click", async (e) => { e.stopPropagation(); const docPath = (btn as HTMLElement).dataset.doc; if (docPath) { - await showRecipeContent(docPath, 'recipe'); + await showRecipeContent(docPath, "recipe"); } }); }); // View example buttons - document.querySelectorAll('.view-example-btn').forEach(btn => { - btn.addEventListener('click', async (e) => { + document.querySelectorAll(".view-example-btn").forEach((btn) => { + btn.addEventListener("click", async (e) => { e.stopPropagation(); const examplePath = (btn as HTMLElement).dataset.example; if (examplePath) { - await showRecipeContent(examplePath, 'example'); + await showRecipeContent(examplePath, "example"); } }); }); // Language tab clicks - document.querySelectorAll('.lang-tab').forEach(tab => { - tab.addEventListener('click', (e) => { + document.querySelectorAll(".lang-tab").forEach((tab) => { + tab.addEventListener("click", (e) => { const langId = (tab as HTMLElement).dataset.lang; if (langId) { selectedLanguage = langId; // Update language filter select - const languageSelect = document.getElementById('filter-language') as HTMLSelectElement; + const languageSelect = document.getElementById( + "filter-language" + ) as HTMLSelectElement; if (languageSelect) languageSelect.value = langId; renderCookbooks(); updateResultsCount(); @@ -422,9 +484,12 @@ function setupRecipeListeners(): void { /** * Show recipe/example content in modal */ -async function showRecipeContent(filePath: string, type: 'recipe' | 'example'): Promise { +async function showRecipeContent( + filePath: string, + type: "recipe" | "example" +): Promise { // Use existing modal infrastructure - const { openFileModal } = await import('../modal'); + const { openFileModal } = await import("../modal"); await openFileModal(filePath, type); } @@ -432,23 +497,25 @@ async function showRecipeContent(filePath: string, type: 'recipe' | 'example'): * Update results count display */ function updateResultsCount(): void { - const resultsCount = document.getElementById('results-count'); + const resultsCount = document.getElementById("results-count"); if (!resultsCount || !samplesData) return; const filtered = getFilteredRecipes(); const total = samplesData.totalRecipes; - + if (filtered.length === total) { - resultsCount.textContent = `${total} recipe${total !== 1 ? 's' : ''}`; + resultsCount.textContent = `${total} recipe${total !== 1 ? "s" : ""}`; } else { - resultsCount.textContent = `${filtered.length} of ${total} recipe${total !== 1 ? 's' : ''}`; + resultsCount.textContent = `${filtered.length} of ${total} recipe${ + total !== 1 ? "s" : "" + }`; } } // Auto-initialize when DOM is ready -if (typeof document !== 'undefined') { - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => initSamplesPage()); +if (typeof document !== "undefined") { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => initSamplesPage()); } else { initSamplesPage(); } diff --git a/website/src/scripts/pages/skills.ts b/website/src/scripts/pages/skills.ts index ab0c8698..9689833f 100644 --- a/website/src/scripts/pages/skills.ts +++ b/website/src/scripts/pages/skills.ts @@ -1,11 +1,18 @@ /** * Skills page functionality */ -import { createChoices, getChoicesValues, type Choices } from '../choices'; -import { FuzzySearch, SearchItem } from '../search'; -import { fetchData, debounce, escapeHtml, getGitHubUrl, getRawGitHubUrl } from '../utils'; -import { setupModal, openFileModal } from '../modal'; -import JSZip from '../jszip'; +import { createChoices, getChoicesValues, type Choices } from "../choices"; +import { FuzzySearch, SearchItem } from "../search"; +import { + fetchData, + debounce, + escapeHtml, + getGitHubUrl, + getRawGitHubUrl, + showToast, +} from "../utils"; +import { setupModal, openFileModal } from "../modal"; +import JSZip from "../jszip"; interface SkillFile { name: string; @@ -29,86 +36,120 @@ interface SkillsData { }; } -const resourceType = 'skill'; +const resourceType = "skill"; let allItems: Skill[] = []; let search = new FuzzySearch(); let categorySelect: Choices; let currentFilters = { categories: [] as string[], - hasAssets: false + hasAssets: false, }; function applyFiltersAndRender(): void { - const searchInput = document.getElementById('search-input') as HTMLInputElement; - const countEl = document.getElementById('results-count'); - const query = searchInput?.value || ''; + const searchInput = document.getElementById( + "search-input" + ) as HTMLInputElement; + const countEl = document.getElementById("results-count"); + const query = searchInput?.value || ""; let results = query ? search.search(query) : [...allItems]; if (currentFilters.categories.length > 0) { - results = results.filter(item => currentFilters.categories.includes(item.category)); + results = results.filter((item) => + currentFilters.categories.includes(item.category) + ); } if (currentFilters.hasAssets) { - results = results.filter(item => item.hasAssets); + results = results.filter((item) => item.hasAssets); } renderItems(results, query); const activeFilters: string[] = []; - if (currentFilters.categories.length > 0) activeFilters.push(`${currentFilters.categories.length} categor${currentFilters.categories.length > 1 ? 'ies' : 'y'}`); - if (currentFilters.hasAssets) activeFilters.push('has assets'); + if (currentFilters.categories.length > 0) + activeFilters.push( + `${currentFilters.categories.length} categor${ + currentFilters.categories.length > 1 ? "ies" : "y" + }` + ); + if (currentFilters.hasAssets) activeFilters.push("has assets"); let countText = `${results.length} of ${allItems.length} skills`; if (activeFilters.length > 0) { - countText += ` (filtered by ${activeFilters.join(', ')})`; + countText += ` (filtered by ${activeFilters.join(", ")})`; } if (countEl) countEl.textContent = countText; } -function renderItems(items: Skill[], query = ''): void { - const list = document.getElementById('resource-list'); +function renderItems(items: Skill[], query = ""): void { + const list = document.getElementById("resource-list"); if (!list) return; if (items.length === 0) { - list.innerHTML = '

No skills found

Try a different search term or adjust filters

'; + list.innerHTML = + '

No skills found

Try a different search term or adjust filters

'; return; } - list.innerHTML = items.map(item => ` -
+ list.innerHTML = items + .map( + (item) => ` +
-
${query ? search.highlight(item.title, query) : escapeHtml(item.title)}
-
${escapeHtml(item.description || 'No description')}
+
${ + query ? search.highlight(item.title, query) : escapeHtml(item.title) + }
+
${escapeHtml( + item.description || "No description" + )}
- ${escapeHtml(item.category)} - ${item.hasAssets ? `${item.assetCount} asset${item.assetCount === 1 ? '' : 's'}` : ''} - ${item.files.length} file${item.files.length === 1 ? '' : 's'} + ${escapeHtml( + item.category + )} + ${ + item.hasAssets + ? `${ + item.assetCount + } asset${item.assetCount === 1 ? "" : "s"}` + : "" + } + ${item.files.length} file${ + item.files.length === 1 ? "" : "s" + }
- - GitHub + GitHub
- `).join(''); + ` + ) + .join(""); // Add click handlers for opening modal - list.querySelectorAll('.resource-item').forEach(el => { - el.addEventListener('click', (e) => { + list.querySelectorAll(".resource-item").forEach((el) => { + el.addEventListener("click", (e) => { // Don't trigger modal if clicking download button or github link - if ((e.target as HTMLElement).closest('.resource-actions')) return; + if ((e.target as HTMLElement).closest(".resource-actions")) return; const path = (el as HTMLElement).dataset.path; if (path) openFileModal(path, resourceType); }); }); // Add download handlers - list.querySelectorAll('.download-skill-btn').forEach(btn => { - btn.addEventListener('click', (e) => { + list.querySelectorAll(".download-skill-btn").forEach((btn) => { + btn.addEventListener("click", (e) => { e.stopPropagation(); const skillId = (btn as HTMLElement).dataset.skillId; if (skillId) downloadSkill(skillId, btn as HTMLButtonElement); @@ -116,16 +157,20 @@ function renderItems(items: Skill[], query = ''): void { }); } -async function downloadSkill(skillId: string, btn: HTMLButtonElement): Promise { - const skill = allItems.find(item => item.id === skillId); +async function downloadSkill( + skillId: string, + btn: HTMLButtonElement +): Promise { + const skill = allItems.find((item) => item.id === skillId); if (!skill || !skill.files || skill.files.length === 0) { - alert('No files found for this skill'); + showToast("No files found for this skill.", "error"); return; } const originalContent = btn.innerHTML; btn.disabled = true; - btn.innerHTML = ' Preparing...'; + btn.innerHTML = + ' Preparing...'; try { const zip = new JSZip(); @@ -152,11 +197,11 @@ async function downloadSkill(skillId: string, btn: HTMLButtonElement): Promise { btn.disabled = false; btn.innerHTML = originalContent; }, 2000); - } catch { - btn.innerHTML = ' Failed'; - setTimeout(() => { btn.disabled = false; btn.innerHTML = originalContent; }, 2000); + btn.innerHTML = + ' Downloaded!'; + setTimeout(() => { + btn.disabled = false; + btn.innerHTML = originalContent; + }, 2000); + } catch (error) { + const message = error instanceof Error ? error.message : "Download failed."; + showToast(message, "error"); + btn.innerHTML = + ' Failed'; + setTimeout(() => { + btn.disabled = false; + btn.innerHTML = originalContent; + }, 2000); } } export async function initSkillsPage(): Promise { - const list = document.getElementById('resource-list'); - const searchInput = document.getElementById('search-input') as HTMLInputElement; - const hasAssetsCheckbox = document.getElementById('filter-has-assets') as HTMLInputElement; - const clearFiltersBtn = document.getElementById('clear-filters'); + const list = document.getElementById("resource-list"); + const searchInput = document.getElementById( + "search-input" + ) as HTMLInputElement; + const hasAssetsCheckbox = document.getElementById( + "filter-has-assets" + ) as HTMLInputElement; + const clearFiltersBtn = document.getElementById("clear-filters"); - const data = await fetchData('skills.json'); + const data = await fetchData("skills.json"); if (!data || !data.items) { - if (list) list.innerHTML = '

Failed to load data

'; + if (list) + list.innerHTML = + '

Failed to load data

'; return; } allItems = data.items; search.setItems(allItems); - categorySelect = createChoices('#filter-category', { placeholderValue: 'All Categories' }); - categorySelect.setChoices(data.filters.categories.map(c => ({ value: c, label: c })), 'value', 'label', true); - document.getElementById('filter-category')?.addEventListener('change', () => { + categorySelect = createChoices("#filter-category", { + placeholderValue: "All Categories", + }); + categorySelect.setChoices( + data.filters.categories.map((c) => ({ value: c, label: c })), + "value", + "label", + true + ); + document.getElementById("filter-category")?.addEventListener("change", () => { currentFilters.categories = getChoicesValues(categorySelect); applyFiltersAndRender(); }); applyFiltersAndRender(); - searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200)); + searchInput?.addEventListener( + "input", + debounce(() => applyFiltersAndRender(), 200) + ); - hasAssetsCheckbox?.addEventListener('change', () => { + hasAssetsCheckbox?.addEventListener("change", () => { currentFilters.hasAssets = hasAssetsCheckbox.checked; applyFiltersAndRender(); }); - clearFiltersBtn?.addEventListener('click', () => { + clearFiltersBtn?.addEventListener("click", () => { currentFilters = { categories: [], hasAssets: false }; categorySelect.removeActiveItems(); if (hasAssetsCheckbox) hasAssetsCheckbox.checked = false; - if (searchInput) searchInput.value = ''; + if (searchInput) searchInput.value = ""; applyFiltersAndRender(); }); @@ -214,4 +285,4 @@ export async function initSkillsPage(): Promise { } // Auto-initialize when DOM is ready -document.addEventListener('DOMContentLoaded', initSkillsPage); +document.addEventListener("DOMContentLoaded", initSkillsPage); diff --git a/website/src/scripts/pages/tools.ts b/website/src/scripts/pages/tools.ts index 4b34ead9..ceb16525 100644 --- a/website/src/scripts/pages/tools.ts +++ b/website/src/scripts/pages/tools.ts @@ -1,8 +1,8 @@ /** * Tools page functionality */ -import { FuzzySearch, type SearchableItem } from '../search'; -import { fetchData, debounce, escapeHtml } from '../utils'; +import { FuzzySearch, type SearchableItem } from "../search"; +import { fetchData, debounce, escapeHtml } from "../utils"; export interface Tool extends SearchableItem { id: string; @@ -16,8 +16,8 @@ export interface Tool extends SearchableItem { links: { blog?: string; vscode?: string; - 'vscode-insiders'?: string; - 'visual-studio'?: string; + "vscode-insiders"?: string; + "visual-studio"?: string; github?: string; documentation?: string; marketplace?: string; @@ -43,19 +43,25 @@ let allItems: Tool[] = []; let search: FuzzySearch; let currentFilters = { categories: [] as string[], - query: '', + query: "", }; +function formatMultilineText(text: string): string { + return escapeHtml(text).replace(/\r?\n/g, "
"); +} + function applyFiltersAndRender(): void { - const searchInput = document.getElementById('search-input') as HTMLInputElement; - const countEl = document.getElementById('results-count'); - const query = searchInput?.value || ''; + const searchInput = document.getElementById( + "search-input" + ) as HTMLInputElement; + const countEl = document.getElementById("results-count"); + const query = searchInput?.value || ""; currentFilters.query = query; let results = query ? search.search(query) : [...allItems]; if (currentFilters.categories.length > 0) { - results = results.filter(item => + results = results.filter((item) => currentFilters.categories.includes(item.category) ); } @@ -69,8 +75,8 @@ function applyFiltersAndRender(): void { if (countEl) countEl.textContent = countText; } -function renderTools(tools: Tool[], query = ''): void { - const container = document.getElementById('tools-list'); +function renderTools(tools: Tool[], query = ""): void { + const container = document.getElementById("tools-list"); if (!container) return; if (tools.length === 0) { @@ -83,40 +89,54 @@ function renderTools(tools: Tool[], query = ''): void { return; } - container.innerHTML = tools.map(tool => { - const badges: string[] = []; - if (tool.featured) { - badges.push('Featured'); - } - badges.push(`${escapeHtml(tool.category)}`); + container.innerHTML = tools + .map((tool) => { + const badges: string[] = []; + if (tool.featured) { + badges.push('Featured'); + } + badges.push( + `${escapeHtml(tool.category)}` + ); - const features = tool.features && tool.features.length > 0 - ? `
+ const features = + tool.features && tool.features.length > 0 + ? `

Features

-
    ${tool.features.map(f => `
  • ${escapeHtml(f)}
  • `).join('')}
+
    ${tool.features + .map((f) => `
  • ${escapeHtml(f)}
  • `) + .join("")}
` - : ''; + : ""; - const requirements = tool.requirements && tool.requirements.length > 0 - ? `
+ const requirements = + tool.requirements && tool.requirements.length > 0 + ? `

Requirements

-
    ${tool.requirements.map(r => `
  • ${escapeHtml(r)}
  • `).join('')}
+
    ${tool.requirements + .map((r) => `
  • ${escapeHtml(r)}
  • `) + .join("")}
` - : ''; + : ""; - const tags = tool.tags && tool.tags.length > 0 - ? `
- ${tool.tags.map(t => `${escapeHtml(t)}`).join('')} + const tags = + tool.tags && tool.tags.length > 0 + ? `
+ ${tool.tags + .map((t) => `${escapeHtml(t)}`) + .join("")}
` - : ''; + : ""; - const config = tool.configuration - ? `
+ const config = tool.configuration + ? `

Configuration

${escapeHtml(tool.configuration.content)}
-
` - : ''; + : ""; - const actions: string[] = []; - if (tool.links.blog) { - actions.push(`📖 Blog`); - } - if (tool.links.marketplace) { - actions.push(`🏪 Marketplace`); - } - if (tool.links.npm) { - actions.push(`📦 npm`); - } - if (tool.links.pypi) { - actions.push(`🐍 PyPI`); - } - if (tool.links.documentation) { - actions.push(`📚 Docs`); - } - if (tool.links.github) { - actions.push(`GitHub`); - } - if (tool.links.vscode) { - actions.push(`Install in VS Code`); - } - if (tool.links['vscode-insiders']) { - actions.push(`VS Code Insiders`); - } - if (tool.links['visual-studio']) { - actions.push(`Visual Studio`); - } + const actions: string[] = []; + if (tool.links.blog) { + actions.push( + `📖 Blog` + ); + } + if (tool.links.marketplace) { + actions.push( + `🏪 Marketplace` + ); + } + if (tool.links.npm) { + actions.push( + `📦 npm` + ); + } + if (tool.links.pypi) { + actions.push( + `🐍 PyPI` + ); + } + if (tool.links.documentation) { + actions.push( + `📚 Docs` + ); + } + if (tool.links.github) { + actions.push( + `GitHub` + ); + } + if (tool.links.vscode) { + actions.push( + `Install in VS Code` + ); + } + if (tool.links["vscode-insiders"]) { + actions.push( + `VS Code Insiders` + ); + } + if (tool.links["visual-studio"]) { + actions.push( + `Visual Studio` + ); + } - const actionsHtml = actions.length > 0 - ? `
${actions.join('')}
` - : ''; + const actionsHtml = + actions.length > 0 + ? `
${actions.join("")}
` + : ""; - const titleHtml = query ? search.highlight(tool.name, query) : escapeHtml(tool.name); + const titleHtml = query + ? search.highlight(tool.name, query) + : escapeHtml(tool.name); + const descriptionHtml = formatMultilineText(tool.description); - return ` + return `

${titleHtml}

- ${badges.join('')} + ${badges.join("")}
-

${escapeHtml(tool.description)}

+

${descriptionHtml}

${features} ${requirements} ${config} @@ -177,20 +219,21 @@ function renderTools(tools: Tool[], query = ''): void { ${actionsHtml}
`; - }).join(''); + }) + .join(""); setupCopyConfigHandlers(); } function setupCopyConfigHandlers(): void { - document.querySelectorAll('.copy-config-btn').forEach(btn => { - btn.addEventListener('click', async (e) => { + document.querySelectorAll(".copy-config-btn").forEach((btn) => { + btn.addEventListener("click", async (e) => { e.stopPropagation(); const button = e.currentTarget as HTMLButtonElement; - const config = decodeURIComponent(button.dataset.config || ''); + const config = decodeURIComponent(button.dataset.config || ""); try { await navigator.clipboard.writeText(config); - button.classList.add('copied'); + button.classList.add("copied"); const originalHtml = button.innerHTML; button.innerHTML = ` @@ -199,35 +242,40 @@ function setupCopyConfigHandlers(): void { Copied! `; setTimeout(() => { - button.classList.remove('copied'); + button.classList.remove("copied"); button.innerHTML = originalHtml; }, 2000); } catch (err) { - console.error('Failed to copy:', err); + console.error("Failed to copy:", err); } }); }); } export async function initToolsPage(): Promise { - const container = document.getElementById('tools-list'); - const searchInput = document.getElementById('search-input') as HTMLInputElement; - const categoryFilter = document.getElementById('filter-category') as HTMLSelectElement; - const clearFiltersBtn = document.getElementById('clear-filters'); - const countEl = document.getElementById('results-count'); + const container = document.getElementById("tools-list"); + const searchInput = document.getElementById( + "search-input" + ) as HTMLInputElement; + const categoryFilter = document.getElementById( + "filter-category" + ) as HTMLSelectElement; + const clearFiltersBtn = document.getElementById("clear-filters"); if (container) { container.innerHTML = '
Loading tools...
'; } - const data = await fetchData('tools.json'); + const data = await fetchData("tools.json"); if (!data || !data.items) { - if (container) container.innerHTML = '

Failed to load tools

'; + if (container) + container.innerHTML = + '

Failed to load tools

'; return; } // Map items to include title for FuzzySearch - allItems = data.items.map(item => ({ + allItems = data.items.map((item) => ({ ...item, title: item.name, // FuzzySearch uses title })); @@ -237,23 +285,33 @@ export async function initToolsPage(): Promise { // Populate category filter if (categoryFilter && data.filters.categories) { - categoryFilter.innerHTML = '' + - data.filters.categories.map(c => ``).join(''); + categoryFilter.innerHTML = + '' + + data.filters.categories + .map( + (c) => `` + ) + .join(""); - categoryFilter.addEventListener('change', () => { - currentFilters.categories = categoryFilter.value ? [categoryFilter.value] : []; + categoryFilter.addEventListener("change", () => { + currentFilters.categories = categoryFilter.value + ? [categoryFilter.value] + : []; applyFiltersAndRender(); }); } // Search input handler - searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200)); + searchInput?.addEventListener( + "input", + debounce(() => applyFiltersAndRender(), 200) + ); // Clear filters - clearFiltersBtn?.addEventListener('click', () => { - currentFilters = { categories: [], query: '' }; - if (categoryFilter) categoryFilter.value = ''; - if (searchInput) searchInput.value = ''; + clearFiltersBtn?.addEventListener("click", () => { + currentFilters = { categories: [], query: "" }; + if (categoryFilter) categoryFilter.value = ""; + if (searchInput) searchInput.value = ""; applyFiltersAndRender(); }); @@ -261,4 +319,4 @@ export async function initToolsPage(): Promise { } // Auto-initialize when DOM is ready -document.addEventListener('DOMContentLoaded', initToolsPage); +document.addEventListener("DOMContentLoaded", initToolsPage); diff --git a/website/src/scripts/search.ts b/website/src/scripts/search.ts index 8856b216..28342dfb 100644 --- a/website/src/scripts/search.ts +++ b/website/src/scripts/search.ts @@ -3,7 +3,7 @@ * Simple substring matching on title and description with scoring */ -import { escapeHtml, fetchData } from './utils'; +import { escapeHtml, fetchData } from "./utils"; export interface SearchItem { title: string; @@ -45,7 +45,7 @@ export class FuzzySearch { */ search(query: string, options: SearchOptions = {}): T[] { const { - fields = ['title', 'description', 'searchText'], + fields = ["title", "description", "searchText"], limit = 50, minScore = 0, } = options; @@ -68,13 +68,17 @@ export class FuzzySearch { // Sort by score descending results.sort((a, b) => b.score - a.score); - return results.slice(0, limit).map(r => r.item); + return results.slice(0, limit).map((r) => r.item); } /** * Calculate match score for an item */ - private calculateScore(item: T, queryWords: string[], fields: string[]): number { + private calculateScore( + item: T, + queryWords: string[], + fields: string[] + ): number { let totalScore = 0; for (const word of queryWords) { @@ -87,23 +91,23 @@ export class FuzzySearch { const normalizedValue = String(value).toLowerCase(); // Exact match in title gets highest score - if (field === 'title' && normalizedValue === word) { + if (field === "title" && normalizedValue === word) { wordScore = Math.max(wordScore, 100); } // Title starts with word - else if (field === 'title' && normalizedValue.startsWith(word)) { + else if (field === "title" && normalizedValue.startsWith(word)) { wordScore = Math.max(wordScore, 80); } // Title contains word - else if (field === 'title' && normalizedValue.includes(word)) { + else if (field === "title" && normalizedValue.includes(word)) { wordScore = Math.max(wordScore, 60); } // Description contains word - else if (field === 'description' && normalizedValue.includes(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)) { + else if (field === "searchText" && normalizedValue.includes(word)) { wordScore = Math.max(wordScore, 20); } } @@ -112,8 +116,8 @@ export class FuzzySearch { } // Bonus for matching all words - const matchesAllWords = queryWords.every(word => - fields.some(field => { + const matchesAllWords = queryWords.every((word) => + fields.some((field) => { const value = (item as Record)[field]; return value && String(value).toLowerCase().includes(word); }) @@ -130,7 +134,7 @@ export class FuzzySearch { * Highlight matching text in a string */ highlight(text: string, query: string): string { - if (!query || !text) return escapeHtml(text || ''); + if (!query || !text) return escapeHtml(text || ""); const normalizedQuery = query.toLowerCase().trim(); const words = normalizedQuery.split(/\s+/); @@ -138,8 +142,27 @@ export class FuzzySearch { for (const word of words) { if (word.length < 2) continue; - const regex = new RegExp(`(${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); - result = result.replace(regex, '$1'); + const regex = new RegExp( + `(${word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, + "gi" + ); + const parts = result.split(/(<[^>]+>)/g); + let inMark = false; + result = parts + .map((part) => { + if (part.startsWith("<")) { + if (part.toLowerCase() === "") inMark = true; + if (part.toLowerCase() === "") inMark = false; + return part; + } + + if (inMark) { + return part; + } + + return part.replace(regex, "$1"); + }) + .join(""); } return result; @@ -153,7 +176,7 @@ export const globalSearch = new FuzzySearch(); * Initialize global search with search index */ export async function initGlobalSearch(): Promise> { - const searchIndex = await fetchData('search-index.json'); + const searchIndex = await fetchData("search-index.json"); if (searchIndex) { globalSearch.setItems(searchIndex); } diff --git a/website/src/scripts/theme.ts b/website/src/scripts/theme.ts index de1a8e2b..d0a9cf68 100644 --- a/website/src/scripts/theme.ts +++ b/website/src/scripts/theme.ts @@ -27,6 +27,9 @@ function applyTheme(theme: 'light' | 'dark'): void { document.documentElement.setAttribute('data-theme', theme); } +const initialTheme = getThemePreference(); +applyTheme(initialTheme); + /** * Toggle between light and dark theme */ diff --git a/website/src/scripts/utils.ts b/website/src/scripts/utils.ts index e3027136..479c7082 100644 --- a/website/src/scripts/utils.ts +++ b/website/src/scripts/utils.ts @@ -2,22 +2,26 @@ * 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'; +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 = { - instructions: { - baseUrl: 'https://aka.ms/awesome-copilot/install/instructions', - scheme: 'chat-instructions' +const VSCODE_INSTALL_CONFIG: Record< + string, + { baseUrl: string; scheme: string } +> = { + instructions: { + baseUrl: "https://aka.ms/awesome-copilot/install/instructions", + scheme: "chat-instructions", }, - prompt: { - baseUrl: 'https://aka.ms/awesome-copilot/install/prompt', - scheme: 'chat-prompt' + prompt: { + baseUrl: "https://aka.ms/awesome-copilot/install/prompt", + scheme: "chat-prompt", }, - agent: { - baseUrl: 'https://aka.ms/awesome-copilot/install/agent', - scheme: 'chat-agent' + agent: { + baseUrl: "https://aka.ms/awesome-copilot/install/agent", + scheme: "chat-agent", }, }; @@ -27,16 +31,18 @@ const VSCODE_INSTALL_CONFIG: Record 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 || '/'; + if (typeof document !== "undefined") { + return document.body.dataset.basePath || "/"; } - return '/'; + return "/"; } /** * Fetch JSON data from the data directory */ -export async function fetchData(filename: string): Promise { +export async function fetchData( + filename: string +): Promise { try { const basePath = getBasePath(); const response = await fetch(`${basePath}data/${filename}`); @@ -51,7 +57,9 @@ export async function fetchData(filename: string): Promise { +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}`); @@ -70,14 +78,14 @@ export async function copyToClipboard(text: string): Promise { await navigator.clipboard.writeText(text); return true; } catch { - // Fallback for older browsers - const textarea = document.createElement('textarea'); + // 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'; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; document.body.appendChild(textarea); textarea.select(); - const success = document.execCommand('copy'); + const success = document.execCommand("copy"); document.body.removeChild(textarea); return success; } @@ -89,14 +97,20 @@ export async function copyToClipboard(text: string): Promise { * @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 { +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)}`; - + const vscodeScheme = insiders ? "vscode-insiders" : "vscode"; + const innerUrl = `${vscodeScheme}:${ + config.scheme + }/install?url=${encodeURIComponent(rawUrl)}`; + return `${config.baseUrl}?url=${encodeURIComponent(innerUrl)}`; } @@ -120,25 +134,25 @@ export function getRawGitHubUrl(filePath: string): string { 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'); - + 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' }); + const filename = filePath.split("/").pop() || "file.md"; + + const blob = new Blob([content], { type: "text/markdown" }); const url = URL.createObjectURL(blob); - - const a = document.createElement('a'); + + const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); - + return true; } catch (error) { - console.error('Download failed:', error); + console.error("Download failed:", error); return false; } } @@ -147,22 +161,27 @@ export async function downloadFile(filePath: string): Promise { * 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)}`; + 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'); +export function showToast( + message: string, + type: "success" | "error" = "success" +): void { + const existing = document.querySelector(".toast"); if (existing) existing.remove(); - - const toast = document.createElement('div'); + + const toast = document.createElement("div"); toast.className = `toast ${type}`; toast.textContent = message; document.body.appendChild(toast); - + setTimeout(() => { toast.remove(); }, 3000); @@ -190,7 +209,7 @@ export function debounce void>( * Escape HTML to prevent XSS */ export function escapeHtml(text: string): string { - const div = document.createElement('div'); + const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } @@ -199,20 +218,21 @@ export function escapeHtml(text: string): string { * 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() + '...'; + 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('.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'; + 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"; } /** @@ -220,11 +240,11 @@ export function getResourceType(filePath: string): string { */ export function formatResourceType(type: string): string { const labels: Record = { - agent: '🤖 Agent', - prompt: '🎯 Prompt', - instruction: '📋 Instruction', - skill: '⚡ Skill', - collection: '📦 Collection', + agent: "🤖 Agent", + prompt: "🎯 Prompt", + instruction: "📋 Instruction", + skill: "⚡ Skill", + collection: "📦 Collection", }; return labels[type] || type; } @@ -234,42 +254,50 @@ export function formatResourceType(type: string): string { */ export function getResourceIcon(type: string): string { const icons: Record = { - agent: '🤖', - prompt: '🎯', - instruction: '📋', - skill: '⚡', - collection: '📦', + agent: "🤖", + prompt: "🎯", + instruction: "📋", + skill: "⚡", + collection: "📦", }; - return icons[type] || '📄'; + return icons[type] || "📄"; } /** * Generate HTML for install dropdown button */ -export function getInstallDropdownHtml(type: string, filePath: string, small = false): string { +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, '-')}`; - + + if (!vscodeUrl) return ""; + + const sizeClass = small ? "install-dropdown-small" : ""; + const uniqueId = `install-${filePath.replace(/[^a-zA-Z0-9]/g, "-")}`; + return ` -
- +
+ Install - @@ -281,33 +309,77 @@ export function getInstallDropdownHtml(type: string, filePath: string, small = f * Setup dropdown close handlers for dynamically created dropdowns */ export function setupDropdownCloseHandlers(): void { - document.addEventListener('click', (e) => { - const target = e.target as HTMLElement; - // Close all open dropdowns if clicking outside - if (!target.closest('.install-dropdown')) { - document.querySelectorAll('.install-dropdown.open').forEach(dropdown => { - dropdown.classList.remove('open'); - }); - } - }); + 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 btnClass = small ? "btn-small" : ""; const iconSize = small ? 14 : 16; - // Escape backslashes first, then single quotes to prevent breaking out of the JavaScript string literal in the onclick attribute - const escapedPath = filePath.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); - + return ` - -