Refactor code for consistency and readability

- Standardized string quotes to double quotes across multiple files.
- Improved formatting and indentation for better readability.
- Added a function to format multiline text in tools rendering.
- Enhanced dropdown and action button handlers for better event management.
- Updated the theme application logic to initialize on page load.
- Refactored utility functions for consistency and clarity.
- Improved error handling and user feedback in download and share functionalities.
This commit is contained in:
Aaron Powell
2026-02-02 16:42:22 +11:00
parent cdb056e44f
commit a1da290d10
12 changed files with 1225 additions and 651 deletions

View File

@@ -8,14 +8,15 @@ on:
push: push:
branches: ["main"] branches: ["main"]
paths: paths:
- 'website/**' - "website/**"
- 'agents/**' - "agents/**"
- 'prompts/**' - "prompts/**"
- 'instructions/**' - "instructions/**"
- 'skills/**' - "skills/**"
- 'collections/**' - "collections/**"
- 'eng/generate-website-data.mjs' - "cookbook/**"
- '.github/workflows/deploy-website.yml' - "eng/generate-website-data.mjs"
- ".github/workflows/deploy-website.yml"
# Allows you to run this workflow manually from the Actions tab # Allows you to run this workflow manually from the Actions tab
workflow_dispatch: workflow_dispatch:
@@ -43,8 +44,8 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: "20"
cache: 'npm' cache: "npm"
- name: Install root dependencies - name: Install root dependencies
run: npm ci run: npm ci
@@ -66,7 +67,7 @@ jobs:
- name: Upload artifact - name: Upload artifact
uses: actions/upload-pages-artifact@v3 uses: actions/upload-pages-artifact@v3
with: with:
path: './website/dist' path: "./website/dist"
# Deployment job # Deployment job
deploy: deploy:

View File

@@ -7,21 +7,20 @@
*/ */
import fs from "fs"; import fs from "fs";
import path from "path"; import path, { dirname } from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { dirname } from "path";
import { import {
AGENTS_DIR, AGENTS_DIR,
INSTRUCTIONS_DIR,
PROMPTS_DIR,
SKILLS_DIR,
COLLECTIONS_DIR, COLLECTIONS_DIR,
COOKBOOK_DIR, COOKBOOK_DIR,
INSTRUCTIONS_DIR,
PROMPTS_DIR,
ROOT_FOLDER, ROOT_FOLDER,
SKILLS_DIR,
} from "./constants.mjs"; } from "./constants.mjs";
import { import {
parseFrontmatter,
parseCollectionYaml, parseCollectionYaml,
parseFrontmatter,
parseSkillMetadata, parseSkillMetadata,
parseYamlFile, parseYamlFile,
} from "./yaml-parser.mjs"; } from "./yaml-parser.mjs";
@@ -63,17 +62,6 @@ function extractTitle(filePath, frontmatter) {
.join(" "); .join(" ");
} }
/**
* Get file content (for preview/full content)
*/
function getFileContent(filePath) {
try {
return fs.readFileSync(filePath, "utf8");
} catch (e) {
return null;
}
}
/** /**
* Generate agents metadata * Generate agents metadata
*/ */
@@ -90,7 +78,9 @@ function generateAgentsData() {
for (const file of files) { for (const file of files) {
const filePath = path.join(AGENTS_DIR, file); const filePath = path.join(AGENTS_DIR, file);
const frontmatter = parseFrontmatter(filePath); 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 model = frontmatter?.model || null;
const tools = frontmatter?.tools || []; const tools = frontmatter?.tools || [];
@@ -146,7 +136,9 @@ function generatePromptsData() {
for (const file of files) { for (const file of files) {
const filePath = path.join(PROMPTS_DIR, file); const filePath = path.join(PROMPTS_DIR, file);
const frontmatter = parseFrontmatter(filePath); 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 || []; const tools = frontmatter?.tools || [];
tools.forEach((t) => allTools.add(t)); tools.forEach((t) => allTools.add(t));
@@ -181,12 +173,15 @@ function parseApplyToPatterns(applyTo) {
// Handle array format // Handle array format
if (Array.isArray(applyTo)) { 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) // Handle string format (comma-separated)
if (typeof applyTo === 'string') { if (typeof applyTo === "string") {
return applyTo.split(',').map(p => p.trim()).filter(p => p.length > 0); return applyTo
.split(",")
.map((p) => p.trim())
.filter((p) => p.length > 0);
} }
return []; return [];
@@ -203,7 +198,7 @@ function extractExtensionFromPattern(pattern) {
// Match patterns like **/*.{ts,tsx} // Match patterns like **/*.{ts,tsx}
const braceMatch = pattern.match(/\*\.\{([^}]+)\}$/); const braceMatch = pattern.match(/\*\.\{([^}]+)\}$/);
if (braceMatch) { if (braceMatch) {
return braceMatch[1].split(',').map(ext => `.${ext.trim()}`); return braceMatch[1].split(",").map((ext) => `.${ext.trim()}`);
} }
return null; return null;
@@ -225,7 +220,9 @@ function generateInstructionsData() {
for (const file of files) { for (const file of files) {
const filePath = path.join(INSTRUCTIONS_DIR, file); const filePath = path.join(INSTRUCTIONS_DIR, file);
const frontmatter = parseFrontmatter(filePath); 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 applyToRaw = frontmatter?.applyTo || null;
const applyToPatterns = parseApplyToPatterns(applyToRaw); const applyToPatterns = parseApplyToPatterns(applyToRaw);
@@ -237,7 +234,7 @@ function generateInstructionsData() {
const ext = extractExtensionFromPattern(pattern); const ext = extractExtensionFromPattern(pattern);
if (ext) { if (ext) {
if (Array.isArray(ext)) { if (Array.isArray(ext)) {
ext.forEach(e => { ext.forEach((e) => {
extensions.push(e); extensions.push(e);
allExtensions.add(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 { return {
items: sortedInstructions, items: sortedInstructions,
@@ -277,16 +276,42 @@ function generateInstructionsData() {
function categorizeSkill(name, description) { function categorizeSkill(name, description) {
const text = `${name} ${description}`.toLowerCase(); const text = `${name} ${description}`.toLowerCase();
if (text.includes('azure') || text.includes('appinsights')) return 'Azure'; 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 (
if (text.includes('vscode') || text.includes('vs code')) return 'VS Code'; text.includes("github") ||
if (text.includes('test') || text.includes('qa') || text.includes('playwright')) return 'Testing'; text.includes("gh-cli") ||
if (text.includes('microsoft') || text.includes('m365') || text.includes('workiq')) return 'Microsoft'; text.includes("git-commit") ||
if (text.includes('cli') || text.includes('command')) return 'CLI Tools'; text.includes("git ")
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 "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 = []; const skills = [];
if (!fs.existsSync(SKILLS_DIR)) { if (!fs.existsSync(SKILLS_DIR)) {
return { items: [], filters: { categories: [], hasAssets: ['Yes', 'No'] } }; return { items: [], filters: { categories: [], hasAssets: ["Yes", "No"] } };
} }
const folders = fs const folders = fs
@@ -310,7 +335,9 @@ function generateSkillsData() {
const metadata = parseSkillMetadata(skillPath); const metadata = parseSkillMetadata(skillPath);
if (metadata) { 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); const category = categorizeSkill(metadata.name, metadata.description);
allCategories.add(category); allCategories.add(category);
@@ -342,7 +369,7 @@ function generateSkillsData() {
items: sortedSkills, items: sortedSkills,
filters: { filters: {
categories: Array.from(allCategories).sort(), 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; return files;
} }
@@ -397,7 +424,9 @@ function generateCollectionsData() {
for (const file of files) { for (const file of files) {
const filePath = path.join(COLLECTIONS_DIR, file); const filePath = path.join(COLLECTIONS_DIR, file);
const data = parseCollectionYaml(filePath); const data = parseCollectionYaml(filePath);
const relativePath = path.relative(ROOT_FOLDER, filePath).replace(/\\/g, "/"); const relativePath = path
.relative(ROOT_FOLDER, filePath)
.replace(/\\/g, "/");
if (data) { if (data) {
const tags = data.tags || []; const tags = data.tags || [];
@@ -498,7 +527,13 @@ function generateToolsData() {
/** /**
* Generate a combined index for search * Generate a combined index for search
*/ */
function generateSearchIndex(agents, prompts, instructions, skills, collections) { function generateSearchIndex(
agents,
prompts,
instructions,
skills,
collections
) {
const index = []; const index = [];
for (const agent of agents) { for (const agent of agents) {
@@ -508,7 +543,9 @@ function generateSearchIndex(agents, prompts, instructions, skills, collections)
title: agent.title, title: agent.title,
description: agent.description, description: agent.description,
path: agent.path, 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, title: instruction.title,
description: instruction.description, description: instruction.description,
path: instruction.path, 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, description: collection.description,
path: collection.path, path: collection.path,
tags: collection.tags, tags: collection.tags,
searchText: `${collection.name} ${collection.description} ${collection.tags.join(" ")}`.toLowerCase(), searchText: `${collection.name} ${
collection.description
} ${collection.tags.join(" ")}`.toLowerCase(),
}); });
} }
@@ -567,34 +608,46 @@ function generateSamplesData() {
const cookbookYamlPath = path.join(COOKBOOK_DIR, "cookbook.yml"); const cookbookYamlPath = path.join(COOKBOOK_DIR, "cookbook.yml");
if (!fs.existsSync(cookbookYamlPath)) { if (!fs.existsSync(cookbookYamlPath)) {
console.warn("Warning: cookbook/cookbook.yml not found, skipping samples generation"); console.warn(
return { cookbooks: [], totalRecipes: 0, totalCookbooks: 0, filters: { languages: [], tags: [] } }; "Warning: cookbook/cookbook.yml not found, skipping samples generation"
);
return {
cookbooks: [],
totalRecipes: 0,
totalCookbooks: 0,
filters: { languages: [], tags: [] },
};
} }
const cookbookManifest = parseYamlFile(cookbookYamlPath); const cookbookManifest = parseYamlFile(cookbookYamlPath);
if (!cookbookManifest || !cookbookManifest.cookbooks) { if (!cookbookManifest || !cookbookManifest.cookbooks) {
console.warn("Warning: Invalid cookbook.yml format"); 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 allLanguages = new Set();
const allTags = new Set(); const allTags = new Set();
let totalRecipes = 0; let totalRecipes = 0;
const cookbooks = cookbookManifest.cookbooks.map(cookbook => { const cookbooks = cookbookManifest.cookbooks.map((cookbook) => {
// Collect languages // Collect languages
cookbook.languages.forEach(lang => allLanguages.add(lang.id)); cookbook.languages.forEach((lang) => allLanguages.add(lang.id));
// Process recipes and add file paths // Process recipes and add file paths
const recipes = cookbook.recipes.map(recipe => { const recipes = cookbook.recipes.map((recipe) => {
// Collect tags // Collect tags
if (recipe.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 // Build variants with file paths for each language
const variants = {}; const variants = {};
cookbook.languages.forEach(lang => { cookbook.languages.forEach((lang) => {
const docPath = `${cookbook.path}/${lang.id}/${recipe.id}.md`; const docPath = `${cookbook.path}/${lang.id}/${recipe.id}.md`;
const examplePath = `${cookbook.path}/${lang.id}/recipe/${recipe.id}${lang.extension}`; const examplePath = `${cookbook.path}/${lang.id}/recipe/${recipe.id}${lang.extension}`;
@@ -605,7 +658,7 @@ function generateSamplesData() {
if (fs.existsSync(docFullPath)) { if (fs.existsSync(docFullPath)) {
variants[lang.id] = { variants[lang.id] = {
doc: docPath, doc: docPath,
example: fs.existsSync(exampleFullPath) ? examplePath : null example: fs.existsSync(exampleFullPath) ? examplePath : null,
}; };
} }
}); });
@@ -617,7 +670,7 @@ function generateSamplesData() {
name: recipe.name, name: recipe.name,
description: recipe.description, description: recipe.description,
tags: recipe.tags || [], tags: recipe.tags || [],
variants variants,
}; };
}); });
@@ -628,7 +681,7 @@ function generateSamplesData() {
path: cookbook.path, path: cookbook.path,
featured: cookbook.featured || false, featured: cookbook.featured || false,
languages: cookbook.languages, languages: cookbook.languages,
recipes recipes,
}; };
}); });
@@ -638,8 +691,8 @@ function generateSamplesData() {
totalCookbooks: cookbooks.length, totalCookbooks: cookbooks.length,
filters: { filters: {
languages: Array.from(allLanguages).sort(), 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 // Generate all data
const agentsData = generateAgentsData(); const agentsData = generateAgentsData();
const agents = agentsData.items; 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 promptsData = generatePromptsData();
const prompts = promptsData.items; 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 instructionsData = generateInstructionsData();
const instructions = instructionsData.items; 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 skillsData = generateSkillsData();
const skills = skillsData.items; 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 collectionsData = generateCollectionsData();
const collections = collectionsData.items; 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 toolsData = generateToolsData();
const tools = toolsData.items; 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(); 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`); console.log(`✓ Generated search index with ${searchIndex.length} items`);
// Write JSON files // Write JSON files

View File

@@ -3,8 +3,8 @@ import { defineConfig } from "astro/config";
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
site: "https://github.github.io", site: "https://github.github.io/awesome-copilot",
base: "/", base: "/awesome-copilot/",
output: "static", output: "static",
integrations: [sitemap()], integrations: [sitemap()],
build: { build: {

View File

@@ -24,6 +24,12 @@
--color-text: #e4e4ec; --color-text: #e4e4ec;
--color-text-muted: #9090a8; --color-text-muted: #9090a8;
--color-text-emphasis: #ffffff; --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: #B870FF;
--color-link-hover: #C898FD; --color-link-hover: #C898FD;
--color-accent: #8534F3; --color-accent: #8534F3;
@@ -46,12 +52,14 @@
--border-radius-lg: 16px; --border-radius-lg: 16px;
--border-radius-xl: 24px; --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: 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-lg: 0 20px 40px -12px rgba(0, 0, 0, 0.5);
--shadow-glow: 0 0 40px -10px rgba(133, 52, 243, 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: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1); --transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1);
--container-width: 1200px; --container-width: 1200px;
--header-height: 72px; --header-height: 72px;
--font-mono: 'Monaspace Argon NF', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
} }
/* Light theme */ /* Light theme */
@@ -735,6 +743,17 @@ a:hover {
border-color: var(--color-border); 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 { .btn-icon {
padding: 8px; padding: 8px;
background: transparent; background: transparent;
@@ -1360,6 +1379,7 @@ a:hover {
text-overflow: ellipsis; text-overflow: ellipsis;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }

View File

@@ -5,80 +5,161 @@ interface Props {
activeNav?: string; 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; const base = import.meta.env.BASE_URL;
--- ---
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title} - Awesome GitHub Copilot</title> <title>{title} - Awesome GitHub Copilot</title>
<meta name="description" content={description}> <meta name="description" content={description} />
<link rel="stylesheet" href={`${base}styles/global.css`}> <link rel="stylesheet" href={`${base}styles/global.css`} />
<link rel="icon" href={`${base}images/Copilot_Icon_Black.svg`} type="image/svg+xml"> <link
<script> rel="icon"
// Theme toggle - inline for immediate effect (prevent flash) href={`${base}images/Copilot_Icon_Black.svg`}
(function() { type="image/svg+xml"
const theme = localStorage.getItem('theme') || />
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); </head>
document.documentElement.setAttribute('data-theme', theme); <body data-base-path={base}>
})(); <a href="#main-content" class="skip-link">Skip to main content</a>
</script> <header class="site-header">
</head> <div class="container">
<body data-base-path={base}> <div class="header-content">
<a href="#main-content" class="skip-link">Skip to main content</a> <a href={base} class="logo">
<header class="site-header"> <img
<div class="container"> src={`${base}images/Copilot_Icon_White.svg`}
<div class="header-content"> alt=""
<a href={base} class="logo"> class="logo-icon logo-icon-dark"
<img src={`${base}images/Copilot_Icon_White.svg`} alt="" class="logo-icon logo-icon-dark" width="32" height="32" aria-hidden="true" /> width="32"
<img src={`${base}images/Copilot_Icon_Black.svg`} alt="" class="logo-icon logo-icon-light" width="32" height="32" aria-hidden="true" /> height="32"
<span class="logo-text">Awesome Copilot</span> aria-hidden="true"
</a> />
<nav class="main-nav" aria-label="Main navigation"> <img
<a href={`${base}agents/`} class:list={[{ active: activeNav === 'agents' }]}>Agents</a> src={`${base}images/Copilot_Icon_Black.svg`}
<a href={`${base}prompts/`} class:list={[{ active: activeNav === 'prompts' }]}>Prompts</a> alt=""
<a href={`${base}instructions/`} class:list={[{ active: activeNav === 'instructions' }]}>Instructions</a> class="logo-icon logo-icon-light"
<a href={`${base}skills/`} class:list={[{ active: activeNav === 'skills' }]}>Skills</a> width="32"
<a href={`${base}collections/`} class:list={[{ active: activeNav === 'collections' }]}>Collections</a> height="32"
<a href={`${base}tools/`} class:list={[{ active: activeNav === 'tools' }]}>Tools</a> aria-hidden="true"
<a href={`${base}samples/`} class:list={[{ active: activeNav === 'samples' }]}>Samples</a> />
</nav> <span class="logo-text">Awesome Copilot</span>
<div class="header-actions">
<button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">
<svg class="icon-sun" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0V.75A.75.75 0 0 1 8 0zm0 13a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 8 13zM2.343 2.343a.75.75 0 0 1 1.061 0l1.06 1.061a.75.75 0 0 1-1.06 1.06l-1.06-1.06a.75.75 0 0 1 0-1.06zm9.193 9.193a.75.75 0 0 1 1.06 0l1.061 1.06a.75.75 0 0 1-1.06 1.061l-1.061-1.06a.75.75 0 0 1 0-1.061zM0 8a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 8zm13 0a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5h-1.5A.75.75 0 0 1 13 8zM2.343 13.657a.75.75 0 0 1 0-1.061l1.06-1.06a.75.75 0 0 1 1.061 1.06l-1.06 1.06a.75.75 0 0 1-1.061 0zm9.193-9.193a.75.75 0 0 1 0-1.06l1.061-1.061a.75.75 0 0 1 1.06 1.06l-1.06 1.061a.75.75 0 0 1-1.061 0z"/>
</svg>
<svg class="icon-moon" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M9.598 1.591a.75.75 0 0 1 .785-.175 7 7 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786zm1.616 1.945a7 7 0 0 1-7.678 7.678 5.5 5.5 0 1 0 7.678-7.678z"/>
</svg>
</button>
<a href="https://github.com/github/awesome-copilot" class="github-link" target="_blank" rel="noopener" aria-label="View on GitHub">
<svg viewBox="0 0 16 16" width="24" height="24" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path>
</svg>
</a> </a>
<nav class="main-nav" aria-label="Main navigation">
<a
href={`${base}agents/`}
class:list={[{ active: activeNav === "agents" }]}>Agents</a
>
<a
href={`${base}prompts/`}
class:list={[{ active: activeNav === "prompts" }]}>Prompts</a
>
<a
href={`${base}instructions/`}
class:list={[{ active: activeNav === "instructions" }]}
>Instructions</a
>
<a
href={`${base}skills/`}
class:list={[{ active: activeNav === "skills" }]}>Skills</a
>
<a
href={`${base}collections/`}
class:list={[{ active: activeNav === "collections" }]}
>Collections</a
>
<a
href={`${base}tools/`}
class:list={[{ active: activeNav === "tools" }]}>Tools</a
>
<a
href={`${base}samples/`}
class:list={[{ active: activeNav === "samples" }]}>Samples</a
>
</nav>
<div class="header-actions">
<button
id="theme-toggle"
class="theme-toggle"
aria-label="Toggle theme"
>
<svg
class="icon-sun"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
>
<path
d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0V.75A.75.75 0 0 1 8 0zm0 13a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 8 13zM2.343 2.343a.75.75 0 0 1 1.061 0l1.06 1.061a.75.75 0 0 1-1.06 1.06l-1.06-1.06a.75.75 0 0 1 0-1.06zm9.193 9.193a.75.75 0 0 1 1.06 0l1.061 1.06a.75.75 0 0 1-1.06 1.061l-1.061-1.06a.75.75 0 0 1 0-1.061zM0 8a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 8zm13 0a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5h-1.5A.75.75 0 0 1 13 8zM2.343 13.657a.75.75 0 0 1 0-1.061l1.06-1.06a.75.75 0 0 1 1.061 1.06l-1.06 1.06a.75.75 0 0 1-1.061 0zm9.193-9.193a.75.75 0 0 1 0-1.06l1.061-1.061a.75.75 0 0 1 1.06 1.06l-1.06 1.061a.75.75 0 0 1-1.061 0z"
></path>
</svg>
<svg
class="icon-moon"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
>
<path
d="M9.598 1.591a.75.75 0 0 1 .785-.175 7 7 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786zm1.616 1.945a7 7 0 0 1-7.678 7.678 5.5 5.5 0 1 0 7.678-7.678z"
></path>
</svg>
</button>
<a
href="https://github.com/github/awesome-copilot"
class="github-link"
target="_blank"
rel="noopener"
aria-label="View on GitHub"
>
<svg
viewBox="0 0 16 16"
width="24"
height="24"
fill="currentColor"
aria-hidden="true"
>
<path
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"
></path>
</svg>
</a>
</div>
</div> </div>
</div> </div>
</div> </header>
</header>
<slot /> <slot />
<footer class="site-footer"> <footer class="site-footer">
<div class="container"> <div class="container">
<p> <p>
<a href="https://github.com/github/awesome-copilot" target="_blank" rel="noopener">GitHub</a> · <a
<a href="https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md" target="_blank" rel="noopener">Contribute</a> · href="https://github.com/github/awesome-copilot"
<a href="https://github.com/github/awesome-copilot/blob/main/LICENSE" target="_blank" rel="noopener">MIT License</a> target="_blank"
</p> rel="noopener">GitHub</a
</div> > ·
</footer> <a
href="https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md"
target="_blank"
rel="noopener">Contribute</a
> ·
<a
href="https://github.com/github/awesome-copilot/blob/main/LICENSE"
target="_blank"
rel="noopener">MIT License</a
>
</p>
</div>
</footer>
<script> <script>
import '../scripts/theme'; import "../scripts/theme";
</script> </script>
</body> </body>
</html> </html>

View File

@@ -2,7 +2,18 @@
* Modal functionality for file viewing * 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 // Modal state
let currentFilePath: string | null = null; let currentFilePath: string | null = null;
@@ -37,23 +48,24 @@ let collectionsCache: CollectionsData | null = null;
*/ */
function getFocusableElements(container: HTMLElement): HTMLElement[] { function getFocusableElements(container: HTMLElement): HTMLElement[] {
const focusableSelectors = [ const focusableSelectors = [
'button:not([disabled])', "button:not([disabled])",
'a[href]', "a[href]",
'input:not([disabled])', "input:not([disabled])",
'select:not([disabled])', "select:not([disabled])",
'textarea:not([disabled])', "textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])' '[tabindex]:not([tabindex="-1"])',
].join(', '); ].join(", ");
return Array.from(container.querySelectorAll<HTMLElement>(focusableSelectors)) return Array.from(
.filter(el => el.offsetParent !== null); // Filter out hidden elements container.querySelectorAll<HTMLElement>(focusableSelectors)
).filter((el) => el.offsetParent !== null); // Filter out hidden elements
} }
/** /**
* Handle keyboard navigation within modal (focus trap) * Handle keyboard navigation within modal (focus trap)
*/ */
function handleModalKeydown(e: KeyboardEvent, modal: HTMLElement): void { function handleModalKeydown(e: KeyboardEvent, modal: HTMLElement): void {
if (e.key === 'Tab') { if (e.key === "Tab") {
const focusableElements = getFocusableElements(modal); const focusableElements = getFocusableElements(modal);
if (focusableElements.length === 0) return; if (focusableElements.length === 0) return;
@@ -80,23 +92,23 @@ function handleModalKeydown(e: KeyboardEvent, modal: HTMLElement): void {
* Setup modal functionality * Setup modal functionality
*/ */
export function setupModal(): void { export function setupModal(): void {
const modal = document.getElementById('file-modal'); const modal = document.getElementById("file-modal");
const closeBtn = document.getElementById('close-modal'); const closeBtn = document.getElementById("close-modal");
const copyBtn = document.getElementById('copy-btn'); const copyBtn = document.getElementById("copy-btn");
const downloadBtn = document.getElementById('download-btn'); const downloadBtn = document.getElementById("download-btn");
const shareBtn = document.getElementById('share-btn'); const shareBtn = document.getElementById("share-btn");
if (!modal) return; if (!modal) return;
closeBtn?.addEventListener('click', closeModal); closeBtn?.addEventListener("click", closeModal);
modal.addEventListener('click', (e) => { modal.addEventListener("click", (e) => {
if (e.target === modal) closeModal(); if (e.target === modal) closeModal();
}); });
document.addEventListener('keydown', (e) => { document.addEventListener("keydown", (e) => {
if (!modal.classList.contains('hidden')) { if (!modal.classList.contains("hidden")) {
if (e.key === 'Escape') { if (e.key === "Escape") {
closeModal(); closeModal();
} else { } else {
handleModalKeydown(e, modal); handleModalKeydown(e, modal);
@@ -104,32 +116,41 @@ export function setupModal(): void {
} }
}); });
copyBtn?.addEventListener('click', async () => { copyBtn?.addEventListener("click", async () => {
if (currentFileContent) { if (currentFileContent) {
const success = await copyToClipboard(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) { if (currentFilePath) {
const success = await downloadFile(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) { if (currentFilePath) {
const success = await shareFile(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 // Setup install dropdown toggle
setupInstallDropdown('install-dropdown'); setupInstallDropdown("install-dropdown");
// Handle browser back/forward navigation // Handle browser back/forward navigation
window.addEventListener('hashchange', handleHashChange); window.addEventListener("hashchange", handleHashChange);
// Check for deep link on initial load // Check for deep link on initial load
handleHashChange(); handleHashChange();
@@ -141,13 +162,13 @@ export function setupModal(): void {
function handleHashChange(): void { function handleHashChange(): void {
const hash = window.location.hash; const hash = window.location.hash;
if (hash && hash.startsWith('#file=')) { if (hash && hash.startsWith("#file=")) {
const filePath = decodeURIComponent(hash.slice(6)); const filePath = decodeURIComponent(hash.slice(6));
if (filePath && filePath !== currentFilePath) { if (filePath && filePath !== currentFilePath) {
const type = getResourceType(filePath); const type = getResourceType(filePath);
openFileModal(filePath, type, false); // Don't update hash since we're responding to it 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 // No hash or empty hash - close modal if open
if (currentFilePath) { if (currentFilePath) {
closeModal(false); // Don't update hash since we're responding to it closeModal(false); // Don't update hash since we're responding to it
@@ -162,11 +183,15 @@ function updateHash(filePath: string | null): void {
if (filePath) { if (filePath) {
const newHash = `#file=${encodeURIComponent(filePath)}`; const newHash = `#file=${encodeURIComponent(filePath)}`;
if (window.location.hash !== newHash) { if (window.location.hash !== newHash) {
history.pushState(null, '', newHash); history.pushState(null, "", newHash);
} }
} else { } else {
if (window.location.hash) { if (window.location.hash) {
history.pushState(null, '', window.location.pathname + window.location.search); history.pushState(
null,
"",
window.location.pathname + window.location.search
);
} }
} }
} }
@@ -178,15 +203,18 @@ export function setupInstallDropdown(containerId: string): void {
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
if (!container) return; if (!container) return;
const toggle = container.querySelector<HTMLButtonElement>('.install-btn-toggle'); const toggle = container.querySelector<HTMLButtonElement>(
const menu = container.querySelector('.install-dropdown-menu'); ".install-btn-toggle"
const menuItems = container.querySelectorAll<HTMLAnchorElement>('.install-dropdown-menu a'); );
const menuItems = container.querySelectorAll<HTMLAnchorElement>(
".install-dropdown-menu a"
);
toggle?.addEventListener('click', (e) => { toggle?.addEventListener("click", (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const isOpen = container.classList.toggle('open'); const isOpen = container.classList.toggle("open");
toggle.setAttribute('aria-expanded', String(isOpen)); toggle.setAttribute("aria-expanded", String(isOpen));
// Focus first menu item when opening // Focus first menu item when opening
if (isOpen && menuItems.length > 0) { if (isOpen && menuItems.length > 0) {
@@ -195,11 +223,11 @@ export function setupInstallDropdown(containerId: string): void {
}); });
// Keyboard navigation for dropdown // Keyboard navigation for dropdown
toggle?.addEventListener('keydown', (e) => { toggle?.addEventListener("keydown", (e) => {
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
container.classList.add('open'); container.classList.add("open");
toggle.setAttribute('aria-expanded', 'true'); toggle.setAttribute("aria-expanded", "true");
if (menuItems.length > 0) { if (menuItems.length > 0) {
menuItems[0].focus(); menuItems[0].focus();
} }
@@ -208,15 +236,15 @@ export function setupInstallDropdown(containerId: string): void {
// Keyboard navigation within menu // Keyboard navigation within menu
menuItems.forEach((item, index) => { menuItems.forEach((item, index) => {
item.addEventListener('keydown', (e) => { item.addEventListener("keydown", (e) => {
switch (e.key) { switch (e.key) {
case 'ArrowDown': case "ArrowDown":
e.preventDefault(); e.preventDefault();
if (index < menuItems.length - 1) { if (index < menuItems.length - 1) {
menuItems[index + 1].focus(); menuItems[index + 1].focus();
} }
break; break;
case 'ArrowUp': case "ArrowUp":
e.preventDefault(); e.preventDefault();
if (index > 0) { if (index > 0) {
menuItems[index - 1].focus(); menuItems[index - 1].focus();
@@ -224,34 +252,34 @@ export function setupInstallDropdown(containerId: string): void {
toggle?.focus(); toggle?.focus();
} }
break; break;
case 'Escape': case "Escape":
e.preventDefault(); e.preventDefault();
container.classList.remove('open'); container.classList.remove("open");
toggle?.setAttribute('aria-expanded', 'false'); toggle?.setAttribute("aria-expanded", "false");
toggle?.focus(); toggle?.focus();
break; break;
case 'Tab': case "Tab":
// Close menu on tab out // Close menu on tab out
container.classList.remove('open'); container.classList.remove("open");
toggle?.setAttribute('aria-expanded', 'false'); toggle?.setAttribute("aria-expanded", "false");
break; break;
} }
}); });
}); });
// Close dropdown when clicking outside // Close dropdown when clicking outside
document.addEventListener('click', (e) => { document.addEventListener("click", (e) => {
if (!container.contains(e.target as Node)) { if (!container.contains(e.target as Node)) {
container.classList.remove('open'); container.classList.remove("open");
toggle?.setAttribute('aria-expanded', 'false'); toggle?.setAttribute("aria-expanded", "false");
} }
}); });
// Close dropdown when clicking a menu item // Close dropdown when clicking a menu item
container.querySelectorAll('.install-dropdown-menu a').forEach(link => { container.querySelectorAll(".install-dropdown-menu a").forEach((link) => {
link.addEventListener('click', () => { link.addEventListener("click", () => {
container.classList.remove('open'); container.classList.remove("open");
toggle?.setAttribute('aria-expanded', 'false'); toggle?.setAttribute("aria-expanded", "false");
}); });
}); });
} }
@@ -263,18 +291,29 @@ export function setupInstallDropdown(containerId: string): void {
* @param updateUrl - Whether to update the URL hash (default: true) * @param updateUrl - Whether to update the URL hash (default: true)
* @param trigger - The element that triggered the modal (for focus return) * @param trigger - The element that triggered the modal (for focus return)
*/ */
export async function openFileModal(filePath: string, type: string, updateUrl = true, trigger?: HTMLElement): Promise<void> { export async function openFileModal(
const modal = document.getElementById('file-modal'); filePath: string,
const title = document.getElementById('modal-title'); type: string,
const modalContent = document.getElementById('modal-content'); updateUrl = true,
const contentEl = modalContent?.querySelector('code'); trigger?: HTMLElement
const installDropdown = document.getElementById('install-dropdown'); ): Promise<void> {
const installBtnMain = document.getElementById('install-btn-main') as HTMLAnchorElement | null; const modal = document.getElementById("file-modal");
const installVscode = document.getElementById('install-vscode') as HTMLAnchorElement | null; const title = document.getElementById("modal-title");
const installInsiders = document.getElementById('install-insiders') as HTMLAnchorElement | null; const modalContent = document.getElementById("modal-content");
const copyBtn = document.getElementById('copy-btn'); const contentEl = modalContent?.querySelector("code");
const downloadBtn = document.getElementById('download-btn'); const installDropdown = document.getElementById("install-dropdown");
const closeBtn = document.getElementById('close-modal'); 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; if (!modal || !title || !modalContent) return;
@@ -282,7 +321,7 @@ export async function openFileModal(filePath: string, type: string, updateUrl =
currentFileType = type; currentFileType = type;
// Track trigger element for focus return // Track trigger element for focus return
triggerElement = trigger || document.activeElement as HTMLElement; triggerElement = trigger || (document.activeElement as HTMLElement);
// Update URL for deep linking // Update URL for deep linking
if (updateUrl) { if (updateUrl) {
@@ -290,8 +329,8 @@ export async function openFileModal(filePath: string, type: string, updateUrl =
} }
// Show modal with loading state // Show modal with loading state
title.textContent = filePath.split('/').pop() || filePath; title.textContent = filePath.split("/").pop() || filePath;
modal.classList.remove('hidden'); modal.classList.remove("hidden");
// Set focus to close button for accessibility // Set focus to close button for accessibility
setTimeout(() => { setTimeout(() => {
@@ -299,38 +338,45 @@ export async function openFileModal(filePath: string, type: string, updateUrl =
}, 0); }, 0);
// Handle collections differently - show as item list // Handle collections differently - show as item list
if (type === 'collection') { if (type === "collection") {
await openCollectionModal(filePath, title, modalContent, installDropdown, copyBtn, downloadBtn); await openCollectionModal(
filePath,
title,
modalContent,
installDropdown,
copyBtn,
downloadBtn
);
return; return;
} }
// Regular file modal // Regular file modal
if (contentEl) { if (contentEl) {
contentEl.textContent = 'Loading...'; contentEl.textContent = "Loading...";
} }
// Show copy/download buttons for regular files // Show copy/download buttons for regular files
if (copyBtn) copyBtn.style.display = 'inline-flex'; if (copyBtn) copyBtn.style.display = "inline-flex";
if (downloadBtn) downloadBtn.style.display = 'inline-flex'; if (downloadBtn) downloadBtn.style.display = "inline-flex";
// Restore pre/code structure if it was replaced by collection view // Restore pre/code structure if it was replaced by collection view
if (!modalContent.querySelector('pre')) { if (!modalContent.querySelector("pre")) {
modalContent.innerHTML = '<pre id="modal-content"><code></code></pre>'; modalContent.innerHTML = '<pre id="modal-content"><code></code></pre>';
} }
const codeEl = modalContent.querySelector('code'); const codeEl = modalContent.querySelector("code");
// Setup install dropdown // Setup install dropdown
const vscodeUrl = getVSCodeInstallUrl(type, filePath, false); const vscodeUrl = getVSCodeInstallUrl(type, filePath, false);
const insidersUrl = getVSCodeInstallUrl(type, filePath, true); const insidersUrl = getVSCodeInstallUrl(type, filePath, true);
if (vscodeUrl && installDropdown) { if (vscodeUrl && installDropdown) {
installDropdown.style.display = 'inline-flex'; installDropdown.style.display = "inline-flex";
installDropdown.classList.remove('open'); installDropdown.classList.remove("open");
if (installBtnMain) installBtnMain.href = vscodeUrl; if (installBtnMain) installBtnMain.href = vscodeUrl;
if (installVscode) installVscode.href = vscodeUrl; if (installVscode) installVscode.href = vscodeUrl;
if (installInsiders) installInsiders.href = insidersUrl || '#'; if (installInsiders) installInsiders.href = insidersUrl || "#";
} else if (installDropdown) { } else if (installDropdown) {
installDropdown.style.display = 'none'; installDropdown.style.display = "none";
} }
// Fetch and display content // Fetch and display content
@@ -340,7 +386,8 @@ export async function openFileModal(filePath: string, type: string, updateUrl =
if (fileContent && codeEl) { if (fileContent && codeEl) {
codeEl.textContent = fileContent; codeEl.textContent = fileContent;
} else if (codeEl) { } 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 downloadBtn: HTMLElement | null
): Promise<void> { ): Promise<void> {
// Hide install dropdown and copy/download for collections // Hide install dropdown and copy/download for collections
if (installDropdown) installDropdown.style.display = 'none'; if (installDropdown) installDropdown.style.display = "none";
if (copyBtn) copyBtn.style.display = 'none'; if (copyBtn) copyBtn.style.display = "none";
if (downloadBtn) downloadBtn.style.display = 'none'; if (downloadBtn) downloadBtn.style.display = "none";
// Show loading // Show loading
modalContent.innerHTML = '<div class="collection-loading">Loading collection...</div>'; modalContent.innerHTML =
'<div class="collection-loading">Loading collection...</div>';
// Load collections data if not cached // Load collections data if not cached
if (!collectionsCache) { if (!collectionsCache) {
collectionsCache = await fetchData<CollectionsData>('collections.json'); collectionsCache = await fetchData<CollectionsData>("collections.json");
} }
if (!collectionsCache) { if (!collectionsCache) {
modalContent.innerHTML = '<div class="collection-error">Failed to load collection data.</div>'; modalContent.innerHTML =
'<div class="collection-error">Failed to load collection data.</div>';
return; return;
} }
// Find the collection // Find the collection
const collection = collectionsCache.items.find(c => c.path === filePath); const collection = collectionsCache.items.find((c) => c.path === filePath);
if (!collection) { if (!collection) {
modalContent.innerHTML = '<div class="collection-error">Collection not found.</div>'; modalContent.innerHTML =
'<div class="collection-error">Collection not found.</div>';
return; return;
} }
@@ -386,33 +436,57 @@ async function openCollectionModal(
// Render collection view // Render collection view
modalContent.innerHTML = ` modalContent.innerHTML = `
<div class="collection-view"> <div class="collection-view">
<div class="collection-description">${escapeHtml(collection.description || '')}</div> <div class="collection-description">${escapeHtml(
${collection.tags && collection.tags.length > 0 ? ` collection.description || ""
)}</div>
${
collection.tags && collection.tags.length > 0
? `
<div class="collection-tags"> <div class="collection-tags">
${collection.tags.map(t => `<span class="resource-tag">${escapeHtml(t)}</span>`).join('')} ${collection.tags
.map((t) => `<span class="resource-tag">${escapeHtml(t)}</span>`)
.join("")}
</div> </div>
` : ''} `
: ""
}
<div class="collection-items-header"> <div class="collection-items-header">
<strong>${collection.items.length} items in this collection</strong> <strong>${collection.items.length} items in this collection</strong>
</div> </div>
<div class="collection-items-list"> <div class="collection-items-list">
${collection.items.map(item => ` ${collection.items
<div class="collection-item" data-path="${escapeHtml(item.path)}" data-type="${escapeHtml(item.kind)}"> .map(
<span class="collection-item-icon">${getResourceIcon(item.kind)}</span> (item) => `
<div class="collection-item" data-path="${escapeHtml(
item.path
)}" data-type="${escapeHtml(item.kind)}">
<span class="collection-item-icon">${getResourceIcon(
item.kind
)}</span>
<div class="collection-item-info"> <div class="collection-item-info">
<div class="collection-item-name">${escapeHtml(item.path.split('/').pop() || item.path)}</div> <div class="collection-item-name">${escapeHtml(
${item.usage ? `<div class="collection-item-usage">${escapeHtml(item.usage)}</div>` : ''} item.path.split("/").pop() || item.path
)}</div>
${
item.usage
? `<div class="collection-item-usage">${escapeHtml(
item.usage
)}</div>`
: ""
}
</div> </div>
<span class="collection-item-type">${escapeHtml(item.kind)}</span> <span class="collection-item-type">${escapeHtml(item.kind)}</span>
</div> </div>
`).join('')} `
)
.join("")}
</div> </div>
</div> </div>
`; `;
// Add click handlers to collection items // Add click handlers to collection items
modalContent.querySelectorAll('.collection-item').forEach(el => { modalContent.querySelectorAll(".collection-item").forEach((el) => {
el.addEventListener('click', () => { el.addEventListener("click", () => {
const path = (el as HTMLElement).dataset.path; const path = (el as HTMLElement).dataset.path;
const itemType = (el as HTMLElement).dataset.type; const itemType = (el as HTMLElement).dataset.type;
if (path && itemType) { if (path && itemType) {
@@ -427,14 +501,14 @@ async function openCollectionModal(
* @param updateUrl - Whether to update the URL hash (default: true) * @param updateUrl - Whether to update the URL hash (default: true)
*/ */
export function closeModal(updateUrl = true): void { export function closeModal(updateUrl = true): void {
const modal = document.getElementById('file-modal'); const modal = document.getElementById("file-modal");
const installDropdown = document.getElementById('install-dropdown'); const installDropdown = document.getElementById("install-dropdown");
if (modal) { if (modal) {
modal.classList.add('hidden'); modal.classList.add("hidden");
} }
if (installDropdown) { if (installDropdown) {
installDropdown.classList.remove('open'); installDropdown.classList.remove("open");
} }
// Update URL for deep linking // Update URL for deep linking
@@ -443,7 +517,11 @@ export function closeModal(updateUrl = true): void {
} }
// Return focus to trigger element // Return focus to trigger element
if (triggerElement && typeof triggerElement.focus === 'function') { if (
triggerElement &&
triggerElement.isConnected &&
typeof triggerElement.focus === "function"
) {
triggerElement.focus(); triggerElement.focus();
} }

View File

@@ -2,9 +2,9 @@
* Samples/Cookbook page functionality * Samples/Cookbook page functionality
*/ */
import { FuzzySearch, type SearchableItem } from '../search'; import { FuzzySearch, type SearchableItem } from "../search";
import { fetchData, escapeHtml } from '../utils'; import { fetchData, escapeHtml } from "../utils";
import { createChoices, getChoicesValues, type Choices } from '../choices'; import { createChoices, getChoicesValues, type Choices } from "../choices";
// Types // Types
interface Language { interface Language {
@@ -59,36 +59,44 @@ let tagChoices: Choices | null = null;
* Initialize the samples page * Initialize the samples page
*/ */
export async function initSamplesPage(): Promise<void> { export async function initSamplesPage(): Promise<void> {
// Load samples data try {
samplesData = await fetchData<SamplesData>('samples.json'); // Load samples data
samplesData = await fetchData<SamplesData>("samples.json");
if (!samplesData || samplesData.cookbooks.length === 0) { 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(); 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 * Show empty state when no cookbooks are available
*/ */
function showEmptyState(): void { function showEmptyState(): void {
const container = document.getElementById('samples-list'); const container = document.getElementById("samples-list");
if (container) { if (container) {
container.innerHTML = ` container.innerHTML = `
<div class="empty-state"> <div class="empty-state">
@@ -99,8 +107,8 @@ function showEmptyState(): void {
} }
// Hide filters // Hide filters
const filtersBar = document.getElementById('filters-bar'); const filtersBar = document.getElementById("filters-bar");
if (filtersBar) filtersBar.style.display = 'none'; if (filtersBar) filtersBar.style.display = "none";
} }
/** /**
@@ -110,12 +118,14 @@ function setupFilters(): void {
if (!samplesData) return; if (!samplesData) return;
// Language filter // Language filter
const languageSelect = document.getElementById('filter-language') as HTMLSelectElement; const languageSelect = document.getElementById(
"filter-language"
) as HTMLSelectElement;
if (languageSelect) { if (languageSelect) {
// Get unique languages across all cookbooks // Get unique languages across all cookbooks
const languages = new Map<string, Language>(); const languages = new Map<string, Language>();
samplesData.cookbooks.forEach(cookbook => { samplesData.cookbooks.forEach((cookbook) => {
cookbook.languages.forEach(lang => { cookbook.languages.forEach((lang) => {
if (!languages.has(lang.id)) { if (!languages.has(lang.id)) {
languages.set(lang.id, lang); languages.set(lang.id, lang);
} }
@@ -124,13 +134,13 @@ function setupFilters(): void {
languageSelect.innerHTML = '<option value="">All Languages</option>'; languageSelect.innerHTML = '<option value="">All Languages</option>';
languages.forEach((lang, id) => { languages.forEach((lang, id) => {
const option = document.createElement('option'); const option = document.createElement("option");
option.value = id; option.value = id;
option.textContent = `${lang.icon} ${lang.name}`; option.textContent = `${lang.icon} ${lang.name}`;
languageSelect.appendChild(option); languageSelect.appendChild(option);
}); });
languageSelect.addEventListener('change', () => { languageSelect.addEventListener("change", () => {
selectedLanguage = languageSelect.value || null; selectedLanguage = languageSelect.value || null;
renderCookbooks(); renderCookbooks();
updateResultsCount(); updateResultsCount();
@@ -138,18 +148,18 @@ function setupFilters(): void {
} }
// Tag filter (multi-select with Choices.js) // 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) { if (tagSelect && samplesData.filters.tags.length > 0) {
// Initialize Choices.js // Initialize Choices.js
tagChoices = createChoices('#filter-tag', { placeholderValue: 'All Tags' }); tagChoices = createChoices("#filter-tag", { placeholderValue: "All Tags" });
tagChoices.setChoices( tagChoices.setChoices(
samplesData.filters.tags.map(tag => ({ value: tag, label: tag })), samplesData.filters.tags.map((tag) => ({ value: tag, label: tag })),
'value', "value",
'label', "label",
true true
); );
tagSelect.addEventListener('change', () => { tagSelect.addEventListener("change", () => {
selectedTags = getChoicesValues(tagChoices!); selectedTags = getChoicesValues(tagChoices!);
renderCookbooks(); renderCookbooks();
updateResultsCount(); updateResultsCount();
@@ -157,19 +167,21 @@ function setupFilters(): void {
} }
// Clear filters button // Clear filters button
const clearBtn = document.getElementById('clear-filters'); const clearBtn = document.getElementById("clear-filters");
clearBtn?.addEventListener('click', clearFilters); clearBtn?.addEventListener("click", clearFilters);
} }
/** /**
* Setup search functionality * Setup search functionality
*/ */
function setupSearch(): void { function setupSearch(): void {
const searchInput = document.getElementById('search-input') as HTMLInputElement; const searchInput = document.getElementById(
"search-input"
) as HTMLInputElement;
if (!searchInput) return; if (!searchInput) return;
let debounceTimer: number; let debounceTimer: number;
searchInput.addEventListener('input', () => { searchInput.addEventListener("input", () => {
clearTimeout(debounceTimer); clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(() => { debounceTimer = window.setTimeout(() => {
renderCookbooks(); renderCookbooks();
@@ -185,16 +197,20 @@ function clearFilters(): void {
selectedLanguage = null; selectedLanguage = null;
selectedTags = []; selectedTags = [];
const languageSelect = document.getElementById('filter-language') as HTMLSelectElement; const languageSelect = document.getElementById(
if (languageSelect) languageSelect.value = ''; "filter-language"
) as HTMLSelectElement;
if (languageSelect) languageSelect.value = "";
// Clear Choices.js selection // Clear Choices.js selection
if (tagChoices) { if (tagChoices) {
tagChoices.removeActiveItems(); tagChoices.removeActiveItems();
} }
const searchInput = document.getElementById('search-input') as HTMLInputElement; const searchInput = document.getElementById(
if (searchInput) searchInput.value = ''; "search-input"
) as HTMLInputElement;
if (searchInput) searchInput.value = "";
renderCookbooks(); renderCookbooks();
updateResultsCount(); updateResultsCount();
@@ -203,44 +219,53 @@ function clearFilters(): void {
/** /**
* Get filtered recipes * Get filtered recipes
*/ */
function getFilteredRecipes(): { cookbook: Cookbook; recipe: Recipe; highlighted?: string }[] { function getFilteredRecipes(): {
cookbook: Cookbook;
recipe: Recipe;
highlighted?: string;
}[] {
if (!samplesData || !search) return []; if (!samplesData || !search) return [];
const searchInput = document.getElementById('search-input') as HTMLInputElement; const searchInput = document.getElementById(
const query = searchInput?.value.trim() || ''; "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) { if (query) {
// Use fuzzy search - returns SearchableItem[] directly // Use fuzzy search - returns SearchableItem[] directly
const searchResults = search.search(query); const searchResults = search.search(query);
results = searchResults.map(item => { results = searchResults.map((item) => {
const recipe = item as SearchableItem & { cookbookId: string }; 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 { return {
cookbook, cookbook,
recipe: recipe as unknown as Recipe, recipe: recipe as unknown as Recipe,
highlighted: search!.highlight(recipe.title, query) highlighted: search!.highlight(recipe.title, query),
}; };
}); });
} else { } else {
// No search query - return all recipes // No search query - return all recipes
results = samplesData.cookbooks.flatMap(cookbook => results = samplesData.cookbooks.flatMap((cookbook) =>
cookbook.recipes.map(recipe => ({ cookbook, recipe })) cookbook.recipes.map((recipe) => ({ cookbook, recipe }))
); );
} }
// Apply language filter // Apply language filter
if (selectedLanguage) { if (selectedLanguage) {
results = results.filter(({ recipe }) => results = results.filter(
recipe.variants[selectedLanguage!] ({ recipe }) => recipe.variants[selectedLanguage!]
); );
} }
// Apply tag filter // Apply tag filter
if (selectedTags.length > 0) { if (selectedTags.length > 0) {
results = results.filter(({ recipe }) => 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 * Render cookbooks and recipes
*/ */
function renderCookbooks(): void { function renderCookbooks(): void {
const container = document.getElementById('samples-list'); const container = document.getElementById("samples-list");
if (!container || !samplesData) return; if (!container || !samplesData) return;
const filteredResults = getFilteredRecipes(); const filteredResults = getFilteredRecipes();
@@ -267,7 +292,10 @@ function renderCookbooks(): void {
} }
// Group by cookbook // Group by cookbook
const byCookbook = new Map<string, { cookbook: Cookbook; recipes: { recipe: Recipe; highlighted?: string }[] }>(); const byCookbook = new Map<
string,
{ cookbook: Cookbook; recipes: { recipe: Recipe; highlighted?: string }[] }
>();
filteredResults.forEach(({ cookbook, recipe, highlighted }) => { filteredResults.forEach(({ cookbook, recipe, highlighted }) => {
if (!byCookbook.has(cookbook.id)) { if (!byCookbook.has(cookbook.id)) {
byCookbook.set(cookbook.id, { cookbook, recipes: [] }); byCookbook.set(cookbook.id, { cookbook, recipes: [] });
@@ -275,7 +303,7 @@ function renderCookbooks(): void {
byCookbook.get(cookbook.id)!.recipes.push({ recipe, highlighted }); byCookbook.get(cookbook.id)!.recipes.push({ recipe, highlighted });
}); });
let html = ''; let html = "";
byCookbook.forEach(({ cookbook, recipes }) => { byCookbook.forEach(({ cookbook, recipes }) => {
html += renderCookbookSection(cookbook, recipes); html += renderCookbookSection(cookbook, recipes);
}); });
@@ -289,18 +317,27 @@ function renderCookbooks(): void {
/** /**
* Render a cookbook section * Render a cookbook section
*/ */
function renderCookbookSection(cookbook: Cookbook, recipes: { recipe: Recipe; highlighted?: string }[]): string { function renderCookbookSection(
const languageTabs = cookbook.languages.map(lang => ` cookbook: Cookbook,
<button class="lang-tab${selectedLanguage === lang.id ? ' active' : ''}" recipes: { recipe: Recipe; highlighted?: string }[]
): string {
const languageTabs = cookbook.languages
.map(
(lang) => `
<button class="lang-tab${selectedLanguage === lang.id ? " active" : ""}"
data-lang="${lang.id}" data-lang="${lang.id}"
title="${lang.name}"> title="${lang.name}">
${lang.icon} ${lang.icon}
</button> </button>
`).join(''); `
)
.join("");
const recipeCards = recipes.map(({ recipe, highlighted }) => const recipeCards = recipes
renderRecipeCard(cookbook, recipe, highlighted) .map(({ recipe, highlighted }) =>
).join(''); renderRecipeCard(cookbook, recipe, highlighted)
)
.join("");
return ` return `
<div class="cookbook-section" data-cookbook="${cookbook.id}"> <div class="cookbook-section" data-cookbook="${cookbook.id}">
@@ -323,25 +360,36 @@ function renderCookbookSection(cookbook: Cookbook, recipes: { recipe: Recipe; hi
/** /**
* Render a recipe card * 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 recipeKey = `${cookbook.id}-${recipe.id}`;
const isExpanded = expandedRecipes.has(recipeKey); const isExpanded = expandedRecipes.has(recipeKey);
// Determine which language to show // 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 variant = recipe.variants[displayLang];
const tags = recipe.tags.map(tag => const tags = recipe.tags
`<span class="recipe-tag">${escapeHtml(tag)}</span>` .map((tag) => `<span class="recipe-tag">${escapeHtml(tag)}</span>`)
).join(''); .join("");
const langIndicators = cookbook.languages const langIndicators = cookbook.languages
.filter(lang => recipe.variants[lang.id]) .filter((lang) => recipe.variants[lang.id])
.map(lang => `<span class="lang-indicator" title="${lang.name}">${lang.icon}</span>`) .map(
.join(''); (lang) =>
`<span class="lang-indicator" title="${lang.name}">${lang.icon}</span>`
)
.join("");
return ` return `
<div class="recipe-card${isExpanded ? ' expanded' : ''}" data-recipe="${recipeKey}" data-cookbook="${cookbook.id}" data-recipe-id="${recipe.id}"> <div class="recipe-card${
isExpanded ? " expanded" : ""
}" data-recipe="${recipeKey}" data-cookbook="${
cookbook.id
}" data-recipe-id="${recipe.id}">
<div class="recipe-header"> <div class="recipe-header">
<h3>${highlightedName || escapeHtml(recipe.name)}</h3> <h3>${highlightedName || escapeHtml(recipe.name)}</h3>
<div class="recipe-langs">${langIndicators}</div> <div class="recipe-langs">${langIndicators}</div>
@@ -349,29 +397,41 @@ function renderRecipeCard(cookbook: Cookbook, recipe: Recipe, highlightedName?:
<p class="recipe-description">${escapeHtml(recipe.description)}</p> <p class="recipe-description">${escapeHtml(recipe.description)}</p>
<div class="recipe-tags">${tags}</div> <div class="recipe-tags">${tags}</div>
<div class="recipe-actions"> <div class="recipe-actions">
${variant ? ` ${
<button class="btn btn-secondary btn-small view-recipe-btn" data-doc="${variant.doc}"> variant
? `
<button class="btn btn-secondary btn-small view-recipe-btn" data-doc="${
variant.doc
}">
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true"> <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
<path d="M1 2.75A.75.75 0 0 1 1.75 2h12.5a.75.75 0 0 1 0 1.5H1.75A.75.75 0 0 1 1 2.75zm0 5A.75.75 0 0 1 1.75 7h12.5a.75.75 0 0 1 0 1.5H1.75A.75.75 0 0 1 1 7.75zM1.75 12h12.5a.75.75 0 0 1 0 1.5H1.75a.75.75 0 0 1 0-1.5z"/> <path d="M1 2.75A.75.75 0 0 1 1.75 2h12.5a.75.75 0 0 1 0 1.5H1.75A.75.75 0 0 1 1 2.75zm0 5A.75.75 0 0 1 1.75 7h12.5a.75.75 0 0 1 0 1.5H1.75A.75.75 0 0 1 1 7.75zM1.75 12h12.5a.75.75 0 0 1 0 1.5H1.75a.75.75 0 0 1 0-1.5z"/>
</svg> </svg>
View Recipe View Recipe
</button> </button>
${variant.example ? ` ${
variant.example
? `
<button class="btn btn-secondary btn-small view-example-btn" data-example="${variant.example}"> <button class="btn btn-secondary btn-small view-example-btn" data-example="${variant.example}">
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true"> <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
<path d="M4.72 3.22a.75.75 0 0 1 1.06 0l3.5 3.5a.75.75 0 0 1 0 1.06l-3.5 3.5a.75.75 0 0 1-1.06-1.06L7.69 7.5 4.72 4.28a.75.75 0 0 1 0-1.06zm6.25 1.06L10.22 5l.75.75-2.25 2.25 2.25 2.25-.75.75-.75-.72L11.97 7.5z"/> <path d="M4.72 3.22a.75.75 0 0 1 1.06 0l3.5 3.5a.75.75 0 0 1 0 1.06l-3.5 3.5a.75.75 0 0 1-1.06-1.06L7.69 7.5 4.72 4.28a.75.75 0 0 1 0-1.06zm6.25 1.06L10.22 5l.75.75-2.25 2.25 2.25 2.25-.75.75-.75-.72L11.97 7.5z"/>
</svg> </svg>
View Example View Example
</button> </button>
` : ''} `
<a href="https://github.com/github/awesome-copilot/blob/main/${variant.doc}" : ""
}
<a href="https://github.com/github/awesome-copilot/blob/main/${
variant.doc
}"
class="btn btn-secondary btn-small" target="_blank" rel="noopener"> class="btn btn-secondary btn-small" target="_blank" rel="noopener">
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true"> <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/> <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg> </svg>
GitHub GitHub
</a> </a>
` : '<span class="no-variant">Not available for selected language</span>'} `
: '<span class="no-variant">Not available for selected language</span>'
}
</div> </div>
</div> </div>
`; `;
@@ -382,35 +442,37 @@ function renderRecipeCard(cookbook: Cookbook, recipe: Recipe, highlightedName?:
*/ */
function setupRecipeListeners(): void { function setupRecipeListeners(): void {
// View recipe buttons // View recipe buttons
document.querySelectorAll('.view-recipe-btn').forEach(btn => { document.querySelectorAll(".view-recipe-btn").forEach((btn) => {
btn.addEventListener('click', async (e) => { btn.addEventListener("click", async (e) => {
e.stopPropagation(); e.stopPropagation();
const docPath = (btn as HTMLElement).dataset.doc; const docPath = (btn as HTMLElement).dataset.doc;
if (docPath) { if (docPath) {
await showRecipeContent(docPath, 'recipe'); await showRecipeContent(docPath, "recipe");
} }
}); });
}); });
// View example buttons // View example buttons
document.querySelectorAll('.view-example-btn').forEach(btn => { document.querySelectorAll(".view-example-btn").forEach((btn) => {
btn.addEventListener('click', async (e) => { btn.addEventListener("click", async (e) => {
e.stopPropagation(); e.stopPropagation();
const examplePath = (btn as HTMLElement).dataset.example; const examplePath = (btn as HTMLElement).dataset.example;
if (examplePath) { if (examplePath) {
await showRecipeContent(examplePath, 'example'); await showRecipeContent(examplePath, "example");
} }
}); });
}); });
// Language tab clicks // Language tab clicks
document.querySelectorAll('.lang-tab').forEach(tab => { document.querySelectorAll(".lang-tab").forEach((tab) => {
tab.addEventListener('click', (e) => { tab.addEventListener("click", (e) => {
const langId = (tab as HTMLElement).dataset.lang; const langId = (tab as HTMLElement).dataset.lang;
if (langId) { if (langId) {
selectedLanguage = langId; selectedLanguage = langId;
// Update language filter select // 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; if (languageSelect) languageSelect.value = langId;
renderCookbooks(); renderCookbooks();
updateResultsCount(); updateResultsCount();
@@ -422,9 +484,12 @@ function setupRecipeListeners(): void {
/** /**
* Show recipe/example content in modal * Show recipe/example content in modal
*/ */
async function showRecipeContent(filePath: string, type: 'recipe' | 'example'): Promise<void> { async function showRecipeContent(
filePath: string,
type: "recipe" | "example"
): Promise<void> {
// Use existing modal infrastructure // Use existing modal infrastructure
const { openFileModal } = await import('../modal'); const { openFileModal } = await import("../modal");
await openFileModal(filePath, type); await openFileModal(filePath, type);
} }
@@ -432,23 +497,25 @@ async function showRecipeContent(filePath: string, type: 'recipe' | 'example'):
* Update results count display * Update results count display
*/ */
function updateResultsCount(): void { function updateResultsCount(): void {
const resultsCount = document.getElementById('results-count'); const resultsCount = document.getElementById("results-count");
if (!resultsCount || !samplesData) return; if (!resultsCount || !samplesData) return;
const filtered = getFilteredRecipes(); const filtered = getFilteredRecipes();
const total = samplesData.totalRecipes; const total = samplesData.totalRecipes;
if (filtered.length === total) { if (filtered.length === total) {
resultsCount.textContent = `${total} recipe${total !== 1 ? 's' : ''}`; resultsCount.textContent = `${total} recipe${total !== 1 ? "s" : ""}`;
} else { } 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 // Auto-initialize when DOM is ready
if (typeof document !== 'undefined') { if (typeof document !== "undefined") {
if (document.readyState === 'loading') { if (document.readyState === "loading") {
document.addEventListener('DOMContentLoaded', () => initSamplesPage()); document.addEventListener("DOMContentLoaded", () => initSamplesPage());
} else { } else {
initSamplesPage(); initSamplesPage();
} }

View File

@@ -1,11 +1,18 @@
/** /**
* Skills page functionality * Skills page functionality
*/ */
import { createChoices, getChoicesValues, type Choices } from '../choices'; import { createChoices, getChoicesValues, type Choices } from "../choices";
import { FuzzySearch, SearchItem } from '../search'; import { FuzzySearch, SearchItem } from "../search";
import { fetchData, debounce, escapeHtml, getGitHubUrl, getRawGitHubUrl } from '../utils'; import {
import { setupModal, openFileModal } from '../modal'; fetchData,
import JSZip from '../jszip'; debounce,
escapeHtml,
getGitHubUrl,
getRawGitHubUrl,
showToast,
} from "../utils";
import { setupModal, openFileModal } from "../modal";
import JSZip from "../jszip";
interface SkillFile { interface SkillFile {
name: string; name: string;
@@ -29,86 +36,120 @@ interface SkillsData {
}; };
} }
const resourceType = 'skill'; const resourceType = "skill";
let allItems: Skill[] = []; let allItems: Skill[] = [];
let search = new FuzzySearch<Skill>(); let search = new FuzzySearch<Skill>();
let categorySelect: Choices; let categorySelect: Choices;
let currentFilters = { let currentFilters = {
categories: [] as string[], categories: [] as string[],
hasAssets: false hasAssets: false,
}; };
function applyFiltersAndRender(): void { function applyFiltersAndRender(): void {
const searchInput = document.getElementById('search-input') as HTMLInputElement; const searchInput = document.getElementById(
const countEl = document.getElementById('results-count'); "search-input"
const query = searchInput?.value || ''; ) as HTMLInputElement;
const countEl = document.getElementById("results-count");
const query = searchInput?.value || "";
let results = query ? search.search(query) : [...allItems]; let results = query ? search.search(query) : [...allItems];
if (currentFilters.categories.length > 0) { 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) { if (currentFilters.hasAssets) {
results = results.filter(item => item.hasAssets); results = results.filter((item) => item.hasAssets);
} }
renderItems(results, query); renderItems(results, query);
const activeFilters: string[] = []; const activeFilters: string[] = [];
if (currentFilters.categories.length > 0) activeFilters.push(`${currentFilters.categories.length} categor${currentFilters.categories.length > 1 ? 'ies' : 'y'}`); if (currentFilters.categories.length > 0)
if (currentFilters.hasAssets) activeFilters.push('has assets'); 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`; let countText = `${results.length} of ${allItems.length} skills`;
if (activeFilters.length > 0) { if (activeFilters.length > 0) {
countText += ` (filtered by ${activeFilters.join(', ')})`; countText += ` (filtered by ${activeFilters.join(", ")})`;
} }
if (countEl) countEl.textContent = countText; if (countEl) countEl.textContent = countText;
} }
function renderItems(items: Skill[], query = ''): void { function renderItems(items: Skill[], query = ""): void {
const list = document.getElementById('resource-list'); const list = document.getElementById("resource-list");
if (!list) return; if (!list) return;
if (items.length === 0) { if (items.length === 0) {
list.innerHTML = '<div class="empty-state"><h3>No skills found</h3><p>Try a different search term or adjust filters</p></div>'; list.innerHTML =
'<div class="empty-state"><h3>No skills found</h3><p>Try a different search term or adjust filters</p></div>';
return; return;
} }
list.innerHTML = items.map(item => ` list.innerHTML = items
<div class="resource-item" data-path="${escapeHtml(item.skillFile)}" data-skill-id="${escapeHtml(item.id)}"> .map(
(item) => `
<div class="resource-item" data-path="${escapeHtml(
item.skillFile
)}" data-skill-id="${escapeHtml(item.id)}">
<div class="resource-info"> <div class="resource-info">
<div class="resource-title">${query ? search.highlight(item.title, query) : escapeHtml(item.title)}</div> <div class="resource-title">${
<div class="resource-description">${escapeHtml(item.description || 'No description')}</div> query ? search.highlight(item.title, query) : escapeHtml(item.title)
}</div>
<div class="resource-description">${escapeHtml(
item.description || "No description"
)}</div>
<div class="resource-meta"> <div class="resource-meta">
<span class="resource-tag tag-category">${escapeHtml(item.category)}</span> <span class="resource-tag tag-category">${escapeHtml(
${item.hasAssets ? `<span class="resource-tag tag-assets">${item.assetCount} asset${item.assetCount === 1 ? '' : 's'}</span>` : ''} item.category
<span class="resource-tag">${item.files.length} file${item.files.length === 1 ? '' : 's'}</span> )}</span>
${
item.hasAssets
? `<span class="resource-tag tag-assets">${
item.assetCount
} asset${item.assetCount === 1 ? "" : "s"}</span>`
: ""
}
<span class="resource-tag">${item.files.length} file${
item.files.length === 1 ? "" : "s"
}</span>
</div> </div>
</div> </div>
<div class="resource-actions"> <div class="resource-actions">
<button class="btn btn-primary download-skill-btn" data-skill-id="${escapeHtml(item.id)}" title="Download as ZIP"> <button class="btn btn-primary download-skill-btn" data-skill-id="${escapeHtml(
item.id
)}" title="Download as ZIP">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"> <svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
<path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z"/> <path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z"/>
<path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969Z"/> <path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969Z"/>
</svg> </svg>
Download Download
</button> </button>
<a href="${getGitHubUrl(item.path)}" class="btn btn-secondary" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">GitHub</a> <a href="${getGitHubUrl(
item.path
)}" class="btn btn-secondary" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">GitHub</a>
</div> </div>
</div> </div>
`).join(''); `
)
.join("");
// Add click handlers for opening modal // Add click handlers for opening modal
list.querySelectorAll('.resource-item').forEach(el => { list.querySelectorAll(".resource-item").forEach((el) => {
el.addEventListener('click', (e) => { el.addEventListener("click", (e) => {
// Don't trigger modal if clicking download button or github link // 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; const path = (el as HTMLElement).dataset.path;
if (path) openFileModal(path, resourceType); if (path) openFileModal(path, resourceType);
}); });
}); });
// Add download handlers // Add download handlers
list.querySelectorAll('.download-skill-btn').forEach(btn => { list.querySelectorAll(".download-skill-btn").forEach((btn) => {
btn.addEventListener('click', (e) => { btn.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
const skillId = (btn as HTMLElement).dataset.skillId; const skillId = (btn as HTMLElement).dataset.skillId;
if (skillId) downloadSkill(skillId, btn as HTMLButtonElement); 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<void> { async function downloadSkill(
const skill = allItems.find(item => item.id === skillId); skillId: string,
btn: HTMLButtonElement
): Promise<void> {
const skill = allItems.find((item) => item.id === skillId);
if (!skill || !skill.files || skill.files.length === 0) { if (!skill || !skill.files || skill.files.length === 0) {
alert('No files found for this skill'); showToast("No files found for this skill.", "error");
return; return;
} }
const originalContent = btn.innerHTML; const originalContent = btn.innerHTML;
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<svg class="spinner" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M8 0a8 8 0 1 0 8 8h-1.5A6.5 6.5 0 1 1 8 1.5V0z"/></svg> Preparing...'; btn.innerHTML =
'<svg class="spinner" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M8 0a8 8 0 1 0 8 8h-1.5A6.5 6.5 0 1 1 8 1.5V0z"/></svg> Preparing...';
try { try {
const zip = new JSZip(); const zip = new JSZip();
@@ -152,11 +197,11 @@ async function downloadSkill(skillId: string, btn: HTMLButtonElement): Promise<v
} }
} }
if (addedFiles === 0) throw new Error('Failed to fetch any files'); if (addedFiles === 0) throw new Error("Failed to fetch any files");
const blob = await zip.generateAsync({ type: 'blob' }); const blob = await zip.generateAsync({ type: "blob" });
const downloadUrl = URL.createObjectURL(blob); const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement("a");
link.href = downloadUrl; link.href = downloadUrl;
link.download = `${skill.id}.zip`; link.download = `${skill.id}.zip`;
document.body.appendChild(link); document.body.appendChild(link);
@@ -164,49 +209,75 @@ async function downloadSkill(skillId: string, btn: HTMLButtonElement): Promise<v
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(downloadUrl); URL.revokeObjectURL(downloadUrl);
btn.innerHTML = '<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0z"/></svg> Downloaded!'; btn.innerHTML =
setTimeout(() => { btn.disabled = false; btn.innerHTML = originalContent; }, 2000); '<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0z"/></svg> Downloaded!';
} catch { setTimeout(() => {
btn.innerHTML = '<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 0 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06z"/></svg> Failed'; btn.disabled = false;
setTimeout(() => { btn.disabled = false; btn.innerHTML = originalContent; }, 2000); btn.innerHTML = originalContent;
}, 2000);
} catch (error) {
const message = error instanceof Error ? error.message : "Download failed.";
showToast(message, "error");
btn.innerHTML =
'<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 0 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06z"/></svg> Failed';
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = originalContent;
}, 2000);
} }
} }
export async function initSkillsPage(): Promise<void> { export async function initSkillsPage(): Promise<void> {
const list = document.getElementById('resource-list'); const list = document.getElementById("resource-list");
const searchInput = document.getElementById('search-input') as HTMLInputElement; const searchInput = document.getElementById(
const hasAssetsCheckbox = document.getElementById('filter-has-assets') as HTMLInputElement; "search-input"
const clearFiltersBtn = document.getElementById('clear-filters'); ) as HTMLInputElement;
const hasAssetsCheckbox = document.getElementById(
"filter-has-assets"
) as HTMLInputElement;
const clearFiltersBtn = document.getElementById("clear-filters");
const data = await fetchData<SkillsData>('skills.json'); const data = await fetchData<SkillsData>("skills.json");
if (!data || !data.items) { if (!data || !data.items) {
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>'; if (list)
list.innerHTML =
'<div class="empty-state"><h3>Failed to load data</h3></div>';
return; return;
} }
allItems = data.items; allItems = data.items;
search.setItems(allItems); search.setItems(allItems);
categorySelect = createChoices('#filter-category', { placeholderValue: 'All Categories' }); categorySelect = createChoices("#filter-category", {
categorySelect.setChoices(data.filters.categories.map(c => ({ value: c, label: c })), 'value', 'label', true); placeholderValue: "All Categories",
document.getElementById('filter-category')?.addEventListener('change', () => { });
categorySelect.setChoices(
data.filters.categories.map((c) => ({ value: c, label: c })),
"value",
"label",
true
);
document.getElementById("filter-category")?.addEventListener("change", () => {
currentFilters.categories = getChoicesValues(categorySelect); currentFilters.categories = getChoicesValues(categorySelect);
applyFiltersAndRender(); applyFiltersAndRender();
}); });
applyFiltersAndRender(); applyFiltersAndRender();
searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200)); searchInput?.addEventListener(
"input",
debounce(() => applyFiltersAndRender(), 200)
);
hasAssetsCheckbox?.addEventListener('change', () => { hasAssetsCheckbox?.addEventListener("change", () => {
currentFilters.hasAssets = hasAssetsCheckbox.checked; currentFilters.hasAssets = hasAssetsCheckbox.checked;
applyFiltersAndRender(); applyFiltersAndRender();
}); });
clearFiltersBtn?.addEventListener('click', () => { clearFiltersBtn?.addEventListener("click", () => {
currentFilters = { categories: [], hasAssets: false }; currentFilters = { categories: [], hasAssets: false };
categorySelect.removeActiveItems(); categorySelect.removeActiveItems();
if (hasAssetsCheckbox) hasAssetsCheckbox.checked = false; if (hasAssetsCheckbox) hasAssetsCheckbox.checked = false;
if (searchInput) searchInput.value = ''; if (searchInput) searchInput.value = "";
applyFiltersAndRender(); applyFiltersAndRender();
}); });
@@ -214,4 +285,4 @@ export async function initSkillsPage(): Promise<void> {
} }
// Auto-initialize when DOM is ready // Auto-initialize when DOM is ready
document.addEventListener('DOMContentLoaded', initSkillsPage); document.addEventListener("DOMContentLoaded", initSkillsPage);

View File

@@ -1,8 +1,8 @@
/** /**
* Tools page functionality * Tools page functionality
*/ */
import { FuzzySearch, type SearchableItem } from '../search'; import { FuzzySearch, type SearchableItem } from "../search";
import { fetchData, debounce, escapeHtml } from '../utils'; import { fetchData, debounce, escapeHtml } from "../utils";
export interface Tool extends SearchableItem { export interface Tool extends SearchableItem {
id: string; id: string;
@@ -16,8 +16,8 @@ export interface Tool extends SearchableItem {
links: { links: {
blog?: string; blog?: string;
vscode?: string; vscode?: string;
'vscode-insiders'?: string; "vscode-insiders"?: string;
'visual-studio'?: string; "visual-studio"?: string;
github?: string; github?: string;
documentation?: string; documentation?: string;
marketplace?: string; marketplace?: string;
@@ -43,19 +43,25 @@ let allItems: Tool[] = [];
let search: FuzzySearch<Tool>; let search: FuzzySearch<Tool>;
let currentFilters = { let currentFilters = {
categories: [] as string[], categories: [] as string[],
query: '', query: "",
}; };
function formatMultilineText(text: string): string {
return escapeHtml(text).replace(/\r?\n/g, "<br>");
}
function applyFiltersAndRender(): void { function applyFiltersAndRender(): void {
const searchInput = document.getElementById('search-input') as HTMLInputElement; const searchInput = document.getElementById(
const countEl = document.getElementById('results-count'); "search-input"
const query = searchInput?.value || ''; ) as HTMLInputElement;
const countEl = document.getElementById("results-count");
const query = searchInput?.value || "";
currentFilters.query = query; currentFilters.query = query;
let results = query ? search.search(query) : [...allItems]; let results = query ? search.search(query) : [...allItems];
if (currentFilters.categories.length > 0) { if (currentFilters.categories.length > 0) {
results = results.filter(item => results = results.filter((item) =>
currentFilters.categories.includes(item.category) currentFilters.categories.includes(item.category)
); );
} }
@@ -69,8 +75,8 @@ function applyFiltersAndRender(): void {
if (countEl) countEl.textContent = countText; if (countEl) countEl.textContent = countText;
} }
function renderTools(tools: Tool[], query = ''): void { function renderTools(tools: Tool[], query = ""): void {
const container = document.getElementById('tools-list'); const container = document.getElementById("tools-list");
if (!container) return; if (!container) return;
if (tools.length === 0) { if (tools.length === 0) {
@@ -83,40 +89,54 @@ function renderTools(tools: Tool[], query = ''): void {
return; return;
} }
container.innerHTML = tools.map(tool => { container.innerHTML = tools
const badges: string[] = []; .map((tool) => {
if (tool.featured) { const badges: string[] = [];
badges.push('<span class="tool-badge featured">Featured</span>'); if (tool.featured) {
} badges.push('<span class="tool-badge featured">Featured</span>');
badges.push(`<span class="tool-badge category">${escapeHtml(tool.category)}</span>`); }
badges.push(
`<span class="tool-badge category">${escapeHtml(tool.category)}</span>`
);
const features = tool.features && tool.features.length > 0 const features =
? `<div class="tool-section"> tool.features && tool.features.length > 0
? `<div class="tool-section">
<h3>Features</h3> <h3>Features</h3>
<ul>${tool.features.map(f => `<li>${escapeHtml(f)}</li>`).join('')}</ul> <ul>${tool.features
.map((f) => `<li>${escapeHtml(f)}</li>`)
.join("")}</ul>
</div>` </div>`
: ''; : "";
const requirements = tool.requirements && tool.requirements.length > 0 const requirements =
? `<div class="tool-section"> tool.requirements && tool.requirements.length > 0
? `<div class="tool-section">
<h3>Requirements</h3> <h3>Requirements</h3>
<ul>${tool.requirements.map(r => `<li>${escapeHtml(r)}</li>`).join('')}</ul> <ul>${tool.requirements
.map((r) => `<li>${escapeHtml(r)}</li>`)
.join("")}</ul>
</div>` </div>`
: ''; : "";
const tags = tool.tags && tool.tags.length > 0 const tags =
? `<div class="tool-tags"> tool.tags && tool.tags.length > 0
${tool.tags.map(t => `<span class="tool-tag">${escapeHtml(t)}</span>`).join('')} ? `<div class="tool-tags">
${tool.tags
.map((t) => `<span class="tool-tag">${escapeHtml(t)}</span>`)
.join("")}
</div>` </div>`
: ''; : "";
const config = tool.configuration const config = tool.configuration
? `<div class="tool-config"> ? `<div class="tool-config">
<h3>Configuration</h3> <h3>Configuration</h3>
<div class="tool-config-wrapper"> <div class="tool-config-wrapper">
<pre><code>${escapeHtml(tool.configuration.content)}</code></pre> <pre><code>${escapeHtml(tool.configuration.content)}</code></pre>
</div> </div>
<button class="copy-config-btn" data-config="${encodeURIComponent(tool.configuration.content)}"> <button class="copy-config-btn" data-config="${encodeURIComponent(
tool.configuration.content
)}">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"/> <path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"/>
<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/> <path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/>
@@ -124,52 +144,74 @@ function renderTools(tools: Tool[], query = ''): void {
Copy Configuration Copy Configuration
</button> </button>
</div>` </div>`
: ''; : "";
const actions: string[] = []; const actions: string[] = [];
if (tool.links.blog) { if (tool.links.blog) {
actions.push(`<a href="${tool.links.blog}" class="btn btn-secondary" target="_blank" rel="noopener">📖 Blog</a>`); actions.push(
} `<a href="${tool.links.blog}" class="btn btn-secondary" target="_blank" rel="noopener">📖 Blog</a>`
if (tool.links.marketplace) { );
actions.push(`<a href="${tool.links.marketplace}" class="btn btn-secondary" target="_blank" rel="noopener">🏪 Marketplace</a>`); }
} if (tool.links.marketplace) {
if (tool.links.npm) { actions.push(
actions.push(`<a href="${tool.links.npm}" class="btn btn-secondary" target="_blank" rel="noopener">📦 npm</a>`); `<a href="${tool.links.marketplace}" class="btn btn-secondary" target="_blank" rel="noopener">🏪 Marketplace</a>`
} );
if (tool.links.pypi) { }
actions.push(`<a href="${tool.links.pypi}" class="btn btn-secondary" target="_blank" rel="noopener">🐍 PyPI</a>`); if (tool.links.npm) {
} actions.push(
if (tool.links.documentation) { `<a href="${tool.links.npm}" class="btn btn-secondary" target="_blank" rel="noopener">📦 npm</a>`
actions.push(`<a href="${tool.links.documentation}" class="btn btn-secondary" target="_blank" rel="noopener">📚 Docs</a>`); );
} }
if (tool.links.github) { if (tool.links.pypi) {
actions.push(`<a href="${tool.links.github}" class="btn btn-secondary" target="_blank" rel="noopener">GitHub</a>`); actions.push(
} `<a href="${tool.links.pypi}" class="btn btn-secondary" target="_blank" rel="noopener">🐍 PyPI</a>`
if (tool.links.vscode) { );
actions.push(`<a href="${tool.links.vscode}" class="btn btn-primary" target="_blank" rel="noopener">Install in VS Code</a>`); }
} if (tool.links.documentation) {
if (tool.links['vscode-insiders']) { actions.push(
actions.push(`<a href="${tool.links['vscode-insiders']}" class="btn btn-outline" target="_blank" rel="noopener">VS Code Insiders</a>`); `<a href="${tool.links.documentation}" class="btn btn-secondary" target="_blank" rel="noopener">📚 Docs</a>`
} );
if (tool.links['visual-studio']) { }
actions.push(`<a href="${tool.links['visual-studio']}" class="btn btn-outline" target="_blank" rel="noopener">Visual Studio</a>`); if (tool.links.github) {
} actions.push(
`<a href="${tool.links.github}" class="btn btn-secondary" target="_blank" rel="noopener">GitHub</a>`
);
}
if (tool.links.vscode) {
actions.push(
`<a href="${tool.links.vscode}" class="btn btn-primary" target="_blank" rel="noopener">Install in VS Code</a>`
);
}
if (tool.links["vscode-insiders"]) {
actions.push(
`<a href="${tool.links["vscode-insiders"]}" class="btn btn-outline" target="_blank" rel="noopener">VS Code Insiders</a>`
);
}
if (tool.links["visual-studio"]) {
actions.push(
`<a href="${tool.links["visual-studio"]}" class="btn btn-outline" target="_blank" rel="noopener">Visual Studio</a>`
);
}
const actionsHtml = actions.length > 0 const actionsHtml =
? `<div class="tool-actions">${actions.join('')}</div>` actions.length > 0
: ''; ? `<div class="tool-actions">${actions.join("")}</div>`
: "";
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 `
<div class="tool-card"> <div class="tool-card">
<div class="tool-header"> <div class="tool-header">
<h2>${titleHtml}</h2> <h2>${titleHtml}</h2>
<div class="tool-badges"> <div class="tool-badges">
${badges.join('')} ${badges.join("")}
</div> </div>
</div> </div>
<p class="tool-description">${escapeHtml(tool.description)}</p> <p class="tool-description">${descriptionHtml}</p>
${features} ${features}
${requirements} ${requirements}
${config} ${config}
@@ -177,20 +219,21 @@ function renderTools(tools: Tool[], query = ''): void {
${actionsHtml} ${actionsHtml}
</div> </div>
`; `;
}).join(''); })
.join("");
setupCopyConfigHandlers(); setupCopyConfigHandlers();
} }
function setupCopyConfigHandlers(): void { function setupCopyConfigHandlers(): void {
document.querySelectorAll('.copy-config-btn').forEach(btn => { document.querySelectorAll(".copy-config-btn").forEach((btn) => {
btn.addEventListener('click', async (e) => { btn.addEventListener("click", async (e) => {
e.stopPropagation(); e.stopPropagation();
const button = e.currentTarget as HTMLButtonElement; const button = e.currentTarget as HTMLButtonElement;
const config = decodeURIComponent(button.dataset.config || ''); const config = decodeURIComponent(button.dataset.config || "");
try { try {
await navigator.clipboard.writeText(config); await navigator.clipboard.writeText(config);
button.classList.add('copied'); button.classList.add("copied");
const originalHtml = button.innerHTML; const originalHtml = button.innerHTML;
button.innerHTML = ` button.innerHTML = `
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
@@ -199,35 +242,40 @@ function setupCopyConfigHandlers(): void {
Copied! Copied!
`; `;
setTimeout(() => { setTimeout(() => {
button.classList.remove('copied'); button.classList.remove("copied");
button.innerHTML = originalHtml; button.innerHTML = originalHtml;
}, 2000); }, 2000);
} catch (err) { } catch (err) {
console.error('Failed to copy:', err); console.error("Failed to copy:", err);
} }
}); });
}); });
} }
export async function initToolsPage(): Promise<void> { export async function initToolsPage(): Promise<void> {
const container = document.getElementById('tools-list'); const container = document.getElementById("tools-list");
const searchInput = document.getElementById('search-input') as HTMLInputElement; const searchInput = document.getElementById(
const categoryFilter = document.getElementById('filter-category') as HTMLSelectElement; "search-input"
const clearFiltersBtn = document.getElementById('clear-filters'); ) as HTMLInputElement;
const countEl = document.getElementById('results-count'); const categoryFilter = document.getElementById(
"filter-category"
) as HTMLSelectElement;
const clearFiltersBtn = document.getElementById("clear-filters");
if (container) { if (container) {
container.innerHTML = '<div class="loading">Loading tools...</div>'; container.innerHTML = '<div class="loading">Loading tools...</div>';
} }
const data = await fetchData<ToolsData>('tools.json'); const data = await fetchData<ToolsData>("tools.json");
if (!data || !data.items) { if (!data || !data.items) {
if (container) container.innerHTML = '<div class="empty-state"><h3>Failed to load tools</h3></div>'; if (container)
container.innerHTML =
'<div class="empty-state"><h3>Failed to load tools</h3></div>';
return; return;
} }
// Map items to include title for FuzzySearch // Map items to include title for FuzzySearch
allItems = data.items.map(item => ({ allItems = data.items.map((item) => ({
...item, ...item,
title: item.name, // FuzzySearch uses title title: item.name, // FuzzySearch uses title
})); }));
@@ -237,23 +285,33 @@ export async function initToolsPage(): Promise<void> {
// Populate category filter // Populate category filter
if (categoryFilter && data.filters.categories) { if (categoryFilter && data.filters.categories) {
categoryFilter.innerHTML = '<option value="">All Categories</option>' + categoryFilter.innerHTML =
data.filters.categories.map(c => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join(''); '<option value="">All Categories</option>' +
data.filters.categories
.map(
(c) => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`
)
.join("");
categoryFilter.addEventListener('change', () => { categoryFilter.addEventListener("change", () => {
currentFilters.categories = categoryFilter.value ? [categoryFilter.value] : []; currentFilters.categories = categoryFilter.value
? [categoryFilter.value]
: [];
applyFiltersAndRender(); applyFiltersAndRender();
}); });
} }
// Search input handler // Search input handler
searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200)); searchInput?.addEventListener(
"input",
debounce(() => applyFiltersAndRender(), 200)
);
// Clear filters // Clear filters
clearFiltersBtn?.addEventListener('click', () => { clearFiltersBtn?.addEventListener("click", () => {
currentFilters = { categories: [], query: '' }; currentFilters = { categories: [], query: "" };
if (categoryFilter) categoryFilter.value = ''; if (categoryFilter) categoryFilter.value = "";
if (searchInput) searchInput.value = ''; if (searchInput) searchInput.value = "";
applyFiltersAndRender(); applyFiltersAndRender();
}); });
@@ -261,4 +319,4 @@ export async function initToolsPage(): Promise<void> {
} }
// Auto-initialize when DOM is ready // Auto-initialize when DOM is ready
document.addEventListener('DOMContentLoaded', initToolsPage); document.addEventListener("DOMContentLoaded", initToolsPage);

View File

@@ -3,7 +3,7 @@
* Simple substring matching on title and description with scoring * Simple substring matching on title and description with scoring
*/ */
import { escapeHtml, fetchData } from './utils'; import { escapeHtml, fetchData } from "./utils";
export interface SearchItem { export interface SearchItem {
title: string; title: string;
@@ -45,7 +45,7 @@ export class FuzzySearch<T extends SearchableItem = SearchItem> {
*/ */
search(query: string, options: SearchOptions = {}): T[] { search(query: string, options: SearchOptions = {}): T[] {
const { const {
fields = ['title', 'description', 'searchText'], fields = ["title", "description", "searchText"],
limit = 50, limit = 50,
minScore = 0, minScore = 0,
} = options; } = options;
@@ -68,13 +68,17 @@ export class FuzzySearch<T extends SearchableItem = SearchItem> {
// Sort by score descending // Sort by score descending
results.sort((a, b) => b.score - a.score); 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 * 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; let totalScore = 0;
for (const word of queryWords) { for (const word of queryWords) {
@@ -87,23 +91,23 @@ export class FuzzySearch<T extends SearchableItem = SearchItem> {
const normalizedValue = String(value).toLowerCase(); const normalizedValue = String(value).toLowerCase();
// Exact match in title gets highest score // Exact match in title gets highest score
if (field === 'title' && normalizedValue === word) { if (field === "title" && normalizedValue === word) {
wordScore = Math.max(wordScore, 100); wordScore = Math.max(wordScore, 100);
} }
// Title starts with word // Title starts with word
else if (field === 'title' && normalizedValue.startsWith(word)) { else if (field === "title" && normalizedValue.startsWith(word)) {
wordScore = Math.max(wordScore, 80); wordScore = Math.max(wordScore, 80);
} }
// Title contains word // Title contains word
else if (field === 'title' && normalizedValue.includes(word)) { else if (field === "title" && normalizedValue.includes(word)) {
wordScore = Math.max(wordScore, 60); wordScore = Math.max(wordScore, 60);
} }
// Description contains word // Description contains word
else if (field === 'description' && normalizedValue.includes(word)) { else if (field === "description" && normalizedValue.includes(word)) {
wordScore = Math.max(wordScore, 30); wordScore = Math.max(wordScore, 30);
} }
// searchText (includes tags, tools, etc) contains word // 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); wordScore = Math.max(wordScore, 20);
} }
} }
@@ -112,8 +116,8 @@ export class FuzzySearch<T extends SearchableItem = SearchItem> {
} }
// Bonus for matching all words // Bonus for matching all words
const matchesAllWords = queryWords.every(word => const matchesAllWords = queryWords.every((word) =>
fields.some(field => { fields.some((field) => {
const value = (item as Record<string, unknown>)[field]; const value = (item as Record<string, unknown>)[field];
return value && String(value).toLowerCase().includes(word); return value && String(value).toLowerCase().includes(word);
}) })
@@ -130,7 +134,7 @@ export class FuzzySearch<T extends SearchableItem = SearchItem> {
* Highlight matching text in a string * Highlight matching text in a string
*/ */
highlight(text: string, query: string): 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 normalizedQuery = query.toLowerCase().trim();
const words = normalizedQuery.split(/\s+/); const words = normalizedQuery.split(/\s+/);
@@ -138,8 +142,27 @@ export class FuzzySearch<T extends SearchableItem = SearchItem> {
for (const word of words) { for (const word of words) {
if (word.length < 2) continue; if (word.length < 2) continue;
const regex = new RegExp(`(${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); const regex = new RegExp(
result = result.replace(regex, '<mark>$1</mark>'); `(${word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`,
"gi"
);
const parts = result.split(/(<[^>]+>)/g);
let inMark = false;
result = parts
.map((part) => {
if (part.startsWith("<")) {
if (part.toLowerCase() === "<mark>") inMark = true;
if (part.toLowerCase() === "</mark>") inMark = false;
return part;
}
if (inMark) {
return part;
}
return part.replace(regex, "<mark>$1</mark>");
})
.join("");
} }
return result; return result;
@@ -153,7 +176,7 @@ export const globalSearch = new FuzzySearch<SearchItem>();
* Initialize global search with search index * Initialize global search with search index
*/ */
export async function initGlobalSearch(): Promise<FuzzySearch<SearchItem>> { export async function initGlobalSearch(): Promise<FuzzySearch<SearchItem>> {
const searchIndex = await fetchData<SearchItem[]>('search-index.json'); const searchIndex = await fetchData<SearchItem[]>("search-index.json");
if (searchIndex) { if (searchIndex) {
globalSearch.setItems(searchIndex); globalSearch.setItems(searchIndex);
} }

View File

@@ -27,6 +27,9 @@ function applyTheme(theme: 'light' | 'dark'): void {
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
} }
const initialTheme = getThemePreference();
applyTheme(initialTheme);
/** /**
* Toggle between light and dark theme * Toggle between light and dark theme
*/ */

View File

@@ -2,22 +2,26 @@
* Utility functions for the Awesome Copilot website * Utility functions for the Awesome Copilot website
*/ */
const REPO_BASE_URL = 'https://raw.githubusercontent.com/github/awesome-copilot/main'; const REPO_BASE_URL =
const REPO_GITHUB_URL = 'https://github.com/github/awesome-copilot/blob/main'; "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 // VS Code install URL configurations
const VSCODE_INSTALL_CONFIG: Record<string, { baseUrl: string; scheme: string }> = { const VSCODE_INSTALL_CONFIG: Record<
string,
{ baseUrl: string; scheme: string }
> = {
instructions: { instructions: {
baseUrl: 'https://aka.ms/awesome-copilot/install/instructions', baseUrl: "https://aka.ms/awesome-copilot/install/instructions",
scheme: 'chat-instructions' scheme: "chat-instructions",
}, },
prompt: { prompt: {
baseUrl: 'https://aka.ms/awesome-copilot/install/prompt', baseUrl: "https://aka.ms/awesome-copilot/install/prompt",
scheme: 'chat-prompt' scheme: "chat-prompt",
}, },
agent: { agent: {
baseUrl: 'https://aka.ms/awesome-copilot/install/agent', baseUrl: "https://aka.ms/awesome-copilot/install/agent",
scheme: 'chat-agent' scheme: "chat-agent",
}, },
}; };
@@ -27,16 +31,18 @@ const VSCODE_INSTALL_CONFIG: Record<string, { baseUrl: string; scheme: string }>
export function getBasePath(): string { export function getBasePath(): string {
// In Astro, import.meta.env.BASE_URL is available at build time // In Astro, import.meta.env.BASE_URL is available at build time
// At runtime, we use a data attribute on the body // At runtime, we use a data attribute on the body
if (typeof document !== 'undefined') { if (typeof document !== "undefined") {
return document.body.dataset.basePath || '/'; return document.body.dataset.basePath || "/";
} }
return '/'; return "/";
} }
/** /**
* Fetch JSON data from the data directory * Fetch JSON data from the data directory
*/ */
export async function fetchData<T = unknown>(filename: string): Promise<T | null> { export async function fetchData<T = unknown>(
filename: string
): Promise<T | null> {
try { try {
const basePath = getBasePath(); const basePath = getBasePath();
const response = await fetch(`${basePath}data/${filename}`); const response = await fetch(`${basePath}data/${filename}`);
@@ -51,7 +57,9 @@ export async function fetchData<T = unknown>(filename: string): Promise<T | null
/** /**
* Fetch raw file content from GitHub * Fetch raw file content from GitHub
*/ */
export async function fetchFileContent(filePath: string): Promise<string | null> { export async function fetchFileContent(
filePath: string
): Promise<string | null> {
try { try {
const response = await fetch(`${REPO_BASE_URL}/${filePath}`); const response = await fetch(`${REPO_BASE_URL}/${filePath}`);
if (!response.ok) throw new Error(`Failed to fetch ${filePath}`); if (!response.ok) throw new Error(`Failed to fetch ${filePath}`);
@@ -70,14 +78,14 @@ export async function copyToClipboard(text: string): Promise<boolean> {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
return true; return true;
} catch { } catch {
// Fallback for older browsers // Deprecated fallback for older browsers that lack the async clipboard API.
const textarea = document.createElement('textarea'); const textarea = document.createElement("textarea");
textarea.value = text; textarea.value = text;
textarea.style.position = 'fixed'; textarea.style.position = "fixed";
textarea.style.opacity = '0'; textarea.style.opacity = "0";
document.body.appendChild(textarea); document.body.appendChild(textarea);
textarea.select(); textarea.select();
const success = document.execCommand('copy'); const success = document.execCommand("copy");
document.body.removeChild(textarea); document.body.removeChild(textarea);
return success; return success;
} }
@@ -89,13 +97,19 @@ export async function copyToClipboard(text: string): Promise<boolean> {
* @param filePath - Path to the file * @param filePath - Path to the file
* @param insiders - Whether to use VS Code Insiders * @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]; const config = VSCODE_INSTALL_CONFIG[type];
if (!config) return null; if (!config) return null;
const rawUrl = `${REPO_BASE_URL}/${filePath}`; const rawUrl = `${REPO_BASE_URL}/${filePath}`;
const vscodeScheme = insiders ? 'vscode-insiders' : 'vscode'; const vscodeScheme = insiders ? "vscode-insiders" : "vscode";
const innerUrl = `${vscodeScheme}:${config.scheme}/install?url=${encodeURIComponent(rawUrl)}`; const innerUrl = `${vscodeScheme}:${
config.scheme
}/install?url=${encodeURIComponent(rawUrl)}`;
return `${config.baseUrl}?url=${encodeURIComponent(innerUrl)}`; return `${config.baseUrl}?url=${encodeURIComponent(innerUrl)}`;
} }
@@ -120,15 +134,15 @@ export function getRawGitHubUrl(filePath: string): string {
export async function downloadFile(filePath: string): Promise<boolean> { export async function downloadFile(filePath: string): Promise<boolean> {
try { try {
const response = await fetch(`${REPO_BASE_URL}/${filePath}`); 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 content = await response.text();
const filename = filePath.split('/').pop() || 'file.md'; const filename = filePath.split("/").pop() || "file.md";
const blob = new Blob([content], { type: 'text/markdown' }); const blob = new Blob([content], { type: "text/markdown" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = filename; a.download = filename;
document.body.appendChild(a); document.body.appendChild(a);
@@ -138,7 +152,7 @@ export async function downloadFile(filePath: string): Promise<boolean> {
return true; return true;
} catch (error) { } catch (error) {
console.error('Download failed:', error); console.error("Download failed:", error);
return false; return false;
} }
} }
@@ -147,18 +161,23 @@ export async function downloadFile(filePath: string): Promise<boolean> {
* Share/copy link to clipboard (deep link to current page with file hash) * Share/copy link to clipboard (deep link to current page with file hash)
*/ */
export async function shareFile(filePath: string): Promise<boolean> { export async function shareFile(filePath: string): Promise<boolean> {
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); return copyToClipboard(deepLinkUrl);
} }
/** /**
* Show a toast notification * Show a toast notification
*/ */
export function showToast(message: string, type: 'success' | 'error' = 'success'): void { export function showToast(
const existing = document.querySelector('.toast'); message: string,
type: "success" | "error" = "success"
): void {
const existing = document.querySelector(".toast");
if (existing) existing.remove(); if (existing) existing.remove();
const toast = document.createElement('div'); const toast = document.createElement("div");
toast.className = `toast ${type}`; toast.className = `toast ${type}`;
toast.textContent = message; toast.textContent = message;
document.body.appendChild(toast); document.body.appendChild(toast);
@@ -190,7 +209,7 @@ export function debounce<T extends (...args: unknown[]) => void>(
* Escape HTML to prevent XSS * Escape HTML to prevent XSS
*/ */
export function escapeHtml(text: string): string { export function escapeHtml(text: string): string {
const div = document.createElement('div'); const div = document.createElement("div");
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
} }
@@ -199,20 +218,21 @@ export function escapeHtml(text: string): string {
* Truncate text with ellipsis * Truncate text with ellipsis
*/ */
export function truncate(text: string | undefined, maxLength: number): string { export function truncate(text: string | undefined, maxLength: number): string {
if (!text || text.length <= maxLength) return text || ''; if (!text || text.length <= maxLength) return text || "";
return text.slice(0, maxLength).trim() + '...'; return text.slice(0, maxLength).trim() + "...";
} }
/** /**
* Get resource type from file path * Get resource type from file path
*/ */
export function getResourceType(filePath: string): string { export function getResourceType(filePath: string): string {
if (filePath.endsWith('.agent.md')) return 'agent'; if (filePath.endsWith(".agent.md")) return "agent";
if (filePath.endsWith('.prompt.md')) return 'prompt'; if (filePath.endsWith(".prompt.md")) return "prompt";
if (filePath.endsWith('.instructions.md')) return 'instruction'; if (filePath.endsWith(".instructions.md")) return "instruction";
if (filePath.includes('/skills/') && filePath.endsWith('SKILL.md')) return 'skill'; if (filePath.includes("/skills/") && filePath.endsWith("SKILL.md"))
if (filePath.endsWith('.collection.yml')) return 'collection'; return "skill";
return 'unknown'; 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 { export function formatResourceType(type: string): string {
const labels: Record<string, string> = { const labels: Record<string, string> = {
agent: '🤖 Agent', agent: "🤖 Agent",
prompt: '🎯 Prompt', prompt: "🎯 Prompt",
instruction: '📋 Instruction', instruction: "📋 Instruction",
skill: '⚡ Skill', skill: "⚡ Skill",
collection: '📦 Collection', collection: "📦 Collection",
}; };
return labels[type] || type; return labels[type] || type;
} }
@@ -234,42 +254,50 @@ export function formatResourceType(type: string): string {
*/ */
export function getResourceIcon(type: string): string { export function getResourceIcon(type: string): string {
const icons: Record<string, string> = { const icons: Record<string, string> = {
agent: '🤖', agent: "🤖",
prompt: '🎯', prompt: "🎯",
instruction: '📋', instruction: "📋",
skill: '⚡', skill: "⚡",
collection: '📦', collection: "📦",
}; };
return icons[type] || '📄'; return icons[type] || "📄";
} }
/** /**
* Generate HTML for install dropdown button * 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 vscodeUrl = getVSCodeInstallUrl(type, filePath, false);
const insidersUrl = getVSCodeInstallUrl(type, filePath, true); const insidersUrl = getVSCodeInstallUrl(type, filePath, true);
if (!vscodeUrl) return ''; if (!vscodeUrl) return "";
const sizeClass = small ? 'install-dropdown-small' : ''; const sizeClass = small ? "install-dropdown-small" : "";
const uniqueId = `install-${filePath.replace(/[^a-zA-Z0-9]/g, '-')}`; const uniqueId = `install-${filePath.replace(/[^a-zA-Z0-9]/g, "-")}`;
return ` return `
<div class="install-dropdown ${sizeClass}" id="${uniqueId}" onclick="event.stopPropagation()"> <div class="install-dropdown ${sizeClass}" id="${uniqueId}" data-install-scope="list">
<a href="${vscodeUrl}" class="btn btn-primary ${small ? 'btn-small' : ''} install-btn-main" target="_blank" rel="noopener"> <a href="${vscodeUrl}" class="btn btn-primary ${
small ? "btn-small" : ""
} install-btn-main" target="_blank" rel="noopener">
Install Install
</a> </a>
<button type="button" class="btn btn-primary ${small ? 'btn-small' : ''} install-btn-toggle" aria-label="Install options" onclick="event.preventDefault(); this.parentElement.classList.toggle('open');"> <button type="button" class="btn btn-primary ${
small ? "btn-small" : ""
} install-btn-toggle" aria-label="Install options" aria-expanded="false">
<svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor"> <svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor">
<path d="M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z"/> <path d="M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z"/>
</svg> </svg>
</button> </button>
<div class="install-dropdown-menu"> <div class="install-dropdown-menu">
<a href="${vscodeUrl}" target="_blank" rel="noopener" onclick="this.closest('.install-dropdown').classList.remove('open')"> <a href="${vscodeUrl}" target="_blank" rel="noopener">
VS Code VS Code
</a> </a>
<a href="${insidersUrl}" target="_blank" rel="noopener" onclick="this.closest('.install-dropdown').classList.remove('open')"> <a href="${insidersUrl}" target="_blank" rel="noopener">
VS Code Insiders VS Code Insiders
</a> </a>
</div> </div>
@@ -281,33 +309,77 @@ export function getInstallDropdownHtml(type: string, filePath: string, small = f
* Setup dropdown close handlers for dynamically created dropdowns * Setup dropdown close handlers for dynamically created dropdowns
*/ */
export function setupDropdownCloseHandlers(): void { export function setupDropdownCloseHandlers(): void {
document.addEventListener('click', (e) => { if (dropdownHandlersReady) return;
const target = e.target as HTMLElement; dropdownHandlersReady = true;
// Close all open dropdowns if clicking outside
if (!target.closest('.install-dropdown')) { document.addEventListener(
document.querySelectorAll('.install-dropdown.open').forEach(dropdown => { "click",
dropdown.classList.remove('open'); (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<HTMLButtonElement>(
".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<HTMLButtonElement>(
".install-btn-toggle"
);
toggleBtn?.setAttribute("aria-expanded", "false");
});
},
true
);
} }
/** /**
* Generate HTML for action buttons (download, share) in list view * Generate HTML for action buttons (download, share) in list view
*/ */
export function getActionButtonsHtml(filePath: string, small = false): string { export function getActionButtonsHtml(filePath: string, small = false): string {
const btnClass = small ? 'btn-small' : ''; const btnClass = small ? "btn-small" : "";
const iconSize = small ? 14 : 16; 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 ` return `
<button class="btn btn-secondary ${btnClass} action-download" data-path="${escapeHtml(filePath)}" onclick="event.stopPropagation(); window.__downloadFile && window.__downloadFile('${escapedPath}')" title="Download file"> <button class="btn btn-secondary ${btnClass} action-download" data-path="${escapeHtml(
filePath
)}" title="Download file">
<svg viewBox="0 0 16 16" width="${iconSize}" height="${iconSize}" fill="currentColor"> <svg viewBox="0 0 16 16" width="${iconSize}" height="${iconSize}" fill="currentColor">
<path d="M7.47 10.78a.75.75 0 0 0 1.06 0l3.75-3.75a.75.75 0 0 0-1.06-1.06L8.75 8.44V1.75a.75.75 0 0 0-1.5 0v6.69L4.78 5.97a.75.75 0 0 0-1.06 1.06l3.75 3.75ZM3.75 13a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-8.5Z"/> <path d="M7.47 10.78a.75.75 0 0 0 1.06 0l3.75-3.75a.75.75 0 0 0-1.06-1.06L8.75 8.44V1.75a.75.75 0 0 0-1.5 0v6.69L4.78 5.97a.75.75 0 0 0-1.06 1.06l3.75 3.75ZM3.75 13a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-8.5Z"/>
</svg> </svg>
</button> </button>
<button class="btn btn-secondary ${btnClass} action-share" data-path="${escapeHtml(filePath)}" onclick="event.stopPropagation(); window.__shareFile && window.__shareFile('${escapedPath}')" title="Copy link"> <button class="btn btn-secondary ${btnClass} action-share" data-path="${escapeHtml(
filePath
)}" title="Copy link">
<svg viewBox="0 0 16 16" width="${iconSize}" height="${iconSize}" fill="currentColor"> <svg viewBox="0 0 16 16" width="${iconSize}" height="${iconSize}" fill="currentColor">
<path d="M7.775 3.275a.75.75 0 0 0 1.06 1.06l1.25-1.25a2 2 0 1 1 2.83 2.83l-2.5 2.5a2 2 0 0 1-2.83 0 .75.75 0 0 0-1.06 1.06 3.5 3.5 0 0 0 4.95 0l2.5-2.5a3.5 3.5 0 0 0-4.95-4.95l-1.25 1.25zm-.025 5.45a.75.75 0 0 0-1.06-1.06l-1.25 1.25a2 2 0 1 1-2.83-2.83l2.5-2.5a2 2 0 0 1 2.83 0 .75.75 0 0 0 1.06-1.06 3.5 3.5 0 0 0-4.95 0l-2.5 2.5a3.5 3.5 0 0 0 4.95 4.95l1.25-1.25z"/> <path d="M7.775 3.275a.75.75 0 0 0 1.06 1.06l1.25-1.25a2 2 0 1 1 2.83 2.83l-2.5 2.5a2 2 0 0 1-2.83 0 .75.75 0 0 0-1.06 1.06 3.5 3.5 0 0 0 4.95 0l2.5-2.5a3.5 3.5 0 0 0-4.95-4.95l-1.25 1.25zm-.025 5.45a.75.75 0 0 0-1.06-1.06l-1.25 1.25a2 2 0 1 1-2.83-2.83l2.5-2.5a2 2 0 0 1 2.83 0 .75.75 0 0 0 1.06-1.06 3.5 3.5 0 0 0-4.95 0l-2.5 2.5a3.5 3.5 0 0 0 4.95 4.95l1.25-1.25z"/>
</svg> </svg>
@@ -319,14 +391,41 @@ export function getActionButtonsHtml(filePath: string, small = false): string {
* Setup global action handlers for download and share buttons * Setup global action handlers for download and share buttons
*/ */
export function setupActionHandlers(): void { export function setupActionHandlers(): void {
// Expose functions globally for inline onclick handlers if (actionHandlersReady) return;
(window as Window & { __downloadFile?: (path: string) => void; __shareFile?: (path: string) => void }).__downloadFile = async (path: string) => { actionHandlersReady = true;
const success = await downloadFile(path);
showToast(success ? 'Download started!' : 'Download failed', success ? 'success' : 'error');
};
(window as Window & { __downloadFile?: (path: string) => void; __shareFile?: (path: string) => void }).__shareFile = async (path: string) => { document.addEventListener(
const success = await shareFile(path); "click",
showToast(success ? 'Link copied!' : 'Failed to copy link', success ? 'success' : 'error'); async (e) => {
}; const target = (e.target as HTMLElement).closest(
".action-download, .action-share"
) as HTMLElement | null;
if (!target) return;
e.preventDefault();
e.stopPropagation();
const path = target.dataset.path;
if (!path) return;
if (target.classList.contains("action-download")) {
const success = await downloadFile(path);
showToast(
success ? "Download started!" : "Download failed",
success ? "success" : "error"
);
return;
}
const success = await shareFile(path);
showToast(
success ? "Link copied!" : "Failed to copy link",
success ? "success" : "error"
);
},
true
);
} }
let dropdownHandlersReady = false;
let actionHandlersReady = false;