From 99a48a40201a0c878be594124c6ce3bea4056d75 Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Mon, 9 Feb 2026 17:02:33 +1100 Subject: [PATCH] Adding hooks to the website --- eng/generate-website-data.mjs | 1 + website/src/layouts/BaseLayout.astro | 4 + website/src/pages/hooks.astro | 54 +++++ website/src/pages/index.astro | 8 + website/src/scripts/pages/hooks.ts | 348 +++++++++++++++++++++++++++ website/src/scripts/pages/index.ts | 3 +- website/src/scripts/utils.ts | 4 + 7 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 website/src/pages/hooks.astro create mode 100644 website/src/scripts/pages/hooks.ts diff --git a/eng/generate-website-data.mjs b/eng/generate-website-data.mjs index d36774f9..9b083091 100644 --- a/eng/generate-website-data.mjs +++ b/eng/generate-website-data.mjs @@ -923,6 +923,7 @@ async function main() { prompts: prompts.length, instructions: instructions.length, skills: skills.length, + hooks: hooks.length, collections: collections.length, tools: tools.length, samples: samplesData.totalRecipes, diff --git a/website/src/layouts/BaseLayout.astro b/website/src/layouts/BaseLayout.astro index 74ebf201..e54c7077 100644 --- a/website/src/layouts/BaseLayout.astro +++ b/website/src/layouts/BaseLayout.astro @@ -86,6 +86,10 @@ try { href={`${base}skills/`} class:list={[{ active: activeNav === "skills" }]}>Skills + Hooks +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+
Loading hooks...
+
+
+
+
+ + + + + diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index 64f1f0d7..d3d39050 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -57,6 +57,14 @@ const base = import.meta.env.BASE_URL;
-
+ + +
+

Hooks

+

Automated workflows triggered by agent events

+
+
-
+
diff --git a/website/src/scripts/pages/hooks.ts b/website/src/scripts/pages/hooks.ts new file mode 100644 index 00000000..2373c80c --- /dev/null +++ b/website/src/scripts/pages/hooks.ts @@ -0,0 +1,348 @@ +/** + * Hooks page functionality + */ +import { createChoices, getChoicesValues, type Choices } from "../choices"; +import { FuzzySearch, SearchItem } from "../search"; +import { + fetchData, + debounce, + escapeHtml, + getGitHubUrl, + getRawGitHubUrl, + showToast, + getLastUpdatedHtml, +} from "../utils"; +import { setupModal, openFileModal } from "../modal"; +import JSZip from "../jszip"; + +interface Hook extends SearchItem { + id: string; + path: string; + readmeFile: string; + hooks: string[]; + tags: string[]; + assets: string[]; + lastUpdated?: string | null; +} + +interface HooksData { + items: Hook[]; + filters: { + hooks: string[]; + tags: string[]; + }; +} + +type SortOption = "title" | "lastUpdated"; + +const resourceType = "hook"; +let allItems: Hook[] = []; +let search = new FuzzySearch(); +let hookSelect: Choices; +let tagSelect: Choices; +let currentFilters = { + hooks: [] as string[], + tags: [] as string[], +}; +let currentSort: SortOption = "title"; + +function sortItems(items: Hook[]): Hook[] { + return [...items].sort((a, b) => { + if (currentSort === "lastUpdated") { + const dateA = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0; + const dateB = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0; + return dateB - dateA; + } + return a.title.localeCompare(b.title); + }); +} + +function applyFiltersAndRender(): void { + const searchInput = document.getElementById( + "search-input" + ) as HTMLInputElement; + const countEl = document.getElementById("results-count"); + const query = searchInput?.value || ""; + + let results = query ? search.search(query) : [...allItems]; + + if (currentFilters.hooks.length > 0) { + results = results.filter((item) => + item.hooks.some((h) => currentFilters.hooks.includes(h)) + ); + } + if (currentFilters.tags.length > 0) { + results = results.filter((item) => + item.tags.some((t) => currentFilters.tags.includes(t)) + ); + } + + results = sortItems(results); + + renderItems(results, query); + const activeFilters: string[] = []; + if (currentFilters.hooks.length > 0) + activeFilters.push( + `${currentFilters.hooks.length} hook event${ + currentFilters.hooks.length > 1 ? "s" : "" + }` + ); + if (currentFilters.tags.length > 0) + activeFilters.push( + `${currentFilters.tags.length} tag${ + currentFilters.tags.length > 1 ? "s" : "" + }` + ); + let countText = `${results.length} of ${allItems.length} hooks`; + if (activeFilters.length > 0) { + countText += ` (filtered by ${activeFilters.join(", ")})`; + } + if (countEl) countEl.textContent = countText; +} + +function renderItems(items: Hook[], query = ""): void { + const list = document.getElementById("resource-list"); + if (!list) return; + + if (items.length === 0) { + list.innerHTML = + '

No hooks found

Try a different search term or adjust filters

'; + return; + } + + list.innerHTML = items + .map( + (item) => ` +
+ ` + ) + .join(""); + + // Add click handlers for opening modal + list.querySelectorAll(".resource-item").forEach((el) => { + el.addEventListener("click", (e) => { + if ((e.target as HTMLElement).closest(".resource-actions")) return; + const path = (el as HTMLElement).dataset.path; + if (path) openFileModal(path, resourceType); + }); + }); + + // Add download handlers + list.querySelectorAll(".download-hook-btn").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const hookId = (btn as HTMLElement).dataset.hookId; + if (hookId) downloadHook(hookId, btn as HTMLButtonElement); + }); + }); +} + +async function downloadHook( + hookId: string, + btn: HTMLButtonElement +): Promise { + const hook = allItems.find((item) => item.id === hookId); + if (!hook) { + showToast("Hook not found.", "error"); + return; + } + + // Build file list: README.md + all assets + const files = [ + { name: "README.md", path: hook.readmeFile }, + ...hook.assets.map((a) => ({ + name: a, + path: `${hook.path}/${a}`, + })), + ]; + + if (files.length === 0) { + showToast("No files found for this hook.", "error"); + return; + } + + const originalContent = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = + ' Preparing...'; + + try { + const zip = new JSZip(); + const folder = zip.folder(hook.id); + + const fetchPromises = files.map(async (file) => { + const url = getRawGitHubUrl(file.path); + try { + const response = await fetch(url); + if (!response.ok) return null; + const content = await response.text(); + return { name: file.name, content }; + } catch { + return null; + } + }); + + const results = await Promise.all(fetchPromises); + let addedFiles = 0; + for (const result of results) { + if (result && folder) { + folder.file(result.name, result.content); + addedFiles++; + } + } + + if (addedFiles === 0) throw new Error("Failed to fetch any files"); + + const blob = await zip.generateAsync({ type: "blob" }); + const downloadUrl = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = downloadUrl; + link.download = `${hook.id}.zip`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(downloadUrl); + + btn.innerHTML = + ' Downloaded!'; + setTimeout(() => { + btn.disabled = false; + btn.innerHTML = originalContent; + }, 2000); + } catch (error) { + const message = + error instanceof Error ? error.message : "Download failed."; + showToast(message, "error"); + btn.innerHTML = + ' Failed'; + setTimeout(() => { + btn.disabled = false; + btn.innerHTML = originalContent; + }, 2000); + } +} + +export async function initHooksPage(): Promise { + const list = document.getElementById("resource-list"); + const searchInput = document.getElementById( + "search-input" + ) as HTMLInputElement; + const clearFiltersBtn = document.getElementById("clear-filters"); + const sortSelect = document.getElementById( + "sort-select" + ) as HTMLSelectElement; + + const data = await fetchData("hooks.json"); + if (!data || !data.items) { + if (list) + list.innerHTML = + '

Failed to load data

'; + return; + } + + allItems = data.items; + search.setItems(allItems); + + // Setup hook event filter + hookSelect = createChoices("#filter-hook", { + placeholderValue: "All Events", + }); + hookSelect.setChoices( + data.filters.hooks.map((h) => ({ value: h, label: h })), + "value", + "label", + true + ); + document.getElementById("filter-hook")?.addEventListener("change", () => { + currentFilters.hooks = getChoicesValues(hookSelect); + applyFiltersAndRender(); + }); + + // Setup tag filter + tagSelect = createChoices("#filter-tag", { + placeholderValue: "All Tags", + }); + tagSelect.setChoices( + data.filters.tags.map((t) => ({ value: t, label: t })), + "value", + "label", + true + ); + document.getElementById("filter-tag")?.addEventListener("change", () => { + currentFilters.tags = getChoicesValues(tagSelect); + applyFiltersAndRender(); + }); + + sortSelect?.addEventListener("change", () => { + currentSort = sortSelect.value as SortOption; + applyFiltersAndRender(); + }); + + applyFiltersAndRender(); + searchInput?.addEventListener( + "input", + debounce(() => applyFiltersAndRender(), 200) + ); + + clearFiltersBtn?.addEventListener("click", () => { + currentFilters = { hooks: [], tags: [] }; + currentSort = "title"; + hookSelect.removeActiveItems(); + tagSelect.removeActiveItems(); + if (searchInput) searchInput.value = ""; + if (sortSelect) sortSelect.value = "title"; + applyFiltersAndRender(); + }); + + setupModal(); +} + +// Auto-initialize when DOM is ready +document.addEventListener("DOMContentLoaded", initHooksPage); diff --git a/website/src/scripts/pages/index.ts b/website/src/scripts/pages/index.ts index 6fa86746..106425d3 100644 --- a/website/src/scripts/pages/index.ts +++ b/website/src/scripts/pages/index.ts @@ -11,6 +11,7 @@ interface Manifest { prompts: number; instructions: number; skills: number; + hooks: number; collections: number; tools: number; }; @@ -35,7 +36,7 @@ export async function initHomepage(): Promise { const manifest = await fetchData('manifest.json'); if (manifest && manifest.counts) { // Populate counts in cards - const countKeys = ['agents', 'prompts', 'instructions', 'skills', 'collections', 'tools'] as const; + const countKeys = ['agents', 'prompts', 'instructions', 'skills', 'hooks', 'collections', 'tools'] as const; countKeys.forEach(key => { const countEl = document.querySelector(`.card-count[data-count="${key}"]`); if (countEl && manifest.counts[key] !== undefined) { diff --git a/website/src/scripts/utils.ts b/website/src/scripts/utils.ts index 4e381020..1728cd5e 100644 --- a/website/src/scripts/utils.ts +++ b/website/src/scripts/utils.ts @@ -231,6 +231,8 @@ export function getResourceType(filePath: string): string { if (filePath.endsWith(".instructions.md")) return "instruction"; if (filePath.includes("/skills/") && filePath.endsWith("SKILL.md")) return "skill"; + if (filePath.includes("/hooks/") && filePath.endsWith("README.md")) + return "hook"; if (filePath.endsWith(".collection.yml")) return "collection"; return "unknown"; } @@ -244,6 +246,7 @@ export function formatResourceType(type: string): string { prompt: "🎯 Prompt", instruction: "📋 Instruction", skill: "⚡ Skill", + hook: "🪝 Hook", collection: "📦 Collection", }; return labels[type] || type; @@ -258,6 +261,7 @@ export function getResourceIcon(type: string): string { prompt: "🎯", instruction: "📋", skill: "⚡", + hook: "🪝", collection: "📦", }; return icons[type] || "📄";