More website tweaks (#977)

* Some layout tweaks

* SSR resource listing pages

Render resource listing pages in Astro for first paint and hydrate client filtering/search behavior on top.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fixing font path

* removing feature plugin reference as we don't track that anymore

* button alignment

* rendering markdown

* Improve skills modal file browsing

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Improving the layout of the search/filter section

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Aaron Powell
2026-03-12 11:48:54 +11:00
committed by GitHub
parent 494d6ac783
commit e65c8359b1
32 changed files with 2808 additions and 1245 deletions

View File

@@ -0,0 +1,19 @@
---
import {
getEmbeddedDataElementId,
serializeEmbeddedData,
} from "../scripts/embedded-data";
interface Props {
filename: string;
data: unknown;
}
const { filename, data } = Astro.props;
---
<script
id={getEmbeddedDataElementId(filename)}
type="application/json"
set:html={serializeEmbeddedData(data)}
></script>

View File

@@ -2,57 +2,220 @@
// Modal component for viewing file contents
---
<div id="file-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div
id="file-modal"
class="modal hidden"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div class="modal-content">
<div class="modal-header">
<h3 id="modal-title">File</h3>
<div class="modal-actions">
<button id="copy-btn" class="btn btn-secondary" aria-label="Copy to clipboard">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
<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"/>
</svg>
<span aria-hidden="true">Copy</span>
</button>
<button id="download-btn" class="btn btn-secondary" aria-label="Download file">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
<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>
<span aria-hidden="true">Download</span>
</button>
<button id="share-btn" class="btn btn-secondary" aria-label="Copy link to clipboard">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
<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>
<span aria-hidden="true">Share</span>
</button>
<div id="install-dropdown" class="install-dropdown" style="display: none;">
<a id="install-btn-main" class="btn btn-primary install-btn-main" target="_blank" rel="noopener">
Install
</a>
<button type="button" class="btn btn-primary install-btn-toggle" aria-label="Install options" aria-expanded="false" aria-haspopup="true">
<svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" aria-hidden="true">
<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"/>
<div class="modal-header-top">
<h3 id="modal-title">File</h3>
<div class="modal-actions">
<div id="modal-file-switcher" class="modal-file-switcher hidden">
<span class="modal-file-switcher-label">File</span>
<div id="modal-file-dropdown" class="modal-file-dropdown">
<button
type="button"
id="modal-file-button"
class="btn btn-secondary modal-file-button"
aria-label="Select file to preview"
aria-haspopup="menu"
aria-expanded="false"
>
<span id="modal-file-button-label">SKILL.md</span>
</button>
<button
type="button"
id="modal-file-toggle"
class="btn btn-secondary modal-file-toggle"
aria-label="Open file menu"
aria-haspopup="menu"
aria-expanded="false"
>
<svg
viewBox="0 0 16 16"
width="12"
height="12"
fill="currentColor"
aria-hidden="true"
>
<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>
</svg>
</button>
<div id="modal-file-menu" class="modal-file-menu" role="menu"></div>
</div>
</div>
<button
id="raw-btn"
class="btn btn-secondary hidden"
aria-label="View raw file"
>
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill="currentColor"
aria-hidden="true"
>
<path
d="M2.3 3.2a.75.75 0 0 1 1.06 0L8 7.94l4.64-4.72a.75.75 0 1 1 1.06 1.06L9.06 8l4.64 4.72a.75.75 0 1 1-1.06 1.06L8 8.06l-4.64 4.72a.75.75 0 1 1-1.06-1.06L6.94 8 2.3 3.28a.75.75 0 0 1 0-1.06z"
></path>
</svg>
<span aria-hidden="true">Raw</span>
</button>
<button
id="render-btn"
class="btn btn-secondary"
aria-label="Render file"
>
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill="currentColor"
aria-hidden="true"
>
<path
d="M2 2h12v1H2V2zm0 3h12v1H2V5zm0 3h12v1H2V8zm0 3h12v1H2v-1z"
></path>
</svg>
<span aria-hidden="true">Render</span>
</button>
<button
id="copy-btn"
class="btn btn-secondary"
aria-label="Copy to clipboard"
>
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill="currentColor"
aria-hidden="true"
>
<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>
<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>
</svg>
<span aria-hidden="true">Copy</span>
</button>
<button
id="download-btn"
class="btn btn-secondary"
aria-label="Download file"
>
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill="currentColor"
aria-hidden="true"
>
<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>
</svg>
<span aria-hidden="true">Download</span>
</button>
<button
id="share-btn"
class="btn btn-secondary"
aria-label="Copy link to clipboard"
>
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill="currentColor"
aria-hidden="true"
>
<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>
</svg>
<span aria-hidden="true">Share</span>
</button>
<div
id="install-dropdown"
class="install-dropdown"
style="display: none;"
>
<a
id="install-btn-main"
class="btn btn-primary install-btn-main"
target="_blank"
rel="noopener"
>
Install
</a>
<button
type="button"
class="btn btn-primary install-btn-toggle"
aria-label="Install options"
aria-expanded="false"
aria-haspopup="true"
>
<svg
viewBox="0 0 16 16"
width="12"
height="12"
fill="currentColor"
aria-hidden="true"
>
<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>
</svg>
</button>
<div class="install-dropdown-menu" role="menu">
<a
id="install-vscode"
target="_blank"
rel="noopener"
role="menuitem"
>
VS Code
</a>
<a
id="install-insiders"
target="_blank"
rel="noopener"
role="menuitem"
>
VS Code Insiders
</a>
</div>
</div>
<button
id="close-modal"
class="btn btn-icon"
aria-label="Close dialog"
>
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill="currentColor"
aria-hidden="true"
>
<path
d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"
></path>
</svg>
</button>
<div class="install-dropdown-menu" role="menu">
<a id="install-vscode" target="_blank" rel="noopener" role="menuitem">
VS Code
</a>
<a id="install-insiders" target="_blank" rel="noopener" role="menuitem">
VS Code Insiders
</a>
</div>
</div>
<button id="close-modal" class="btn btn-icon" aria-label="Close dialog">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
<path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"></path>
</svg>
</button>
</div>
</div>
<div class="modal-body">
<pre id="modal-content"><code></code></pre>
<pre id="modal-content"><code /></pre>
</div>
</div>
</div>

View File

@@ -1,8 +1,13 @@
---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import agentsData from '../../public/data/agents.json';
import Modal from '../components/Modal.astro';
import ContributeCTA from '../components/ContributeCTA.astro';
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
import PageHeader from '../components/PageHeader.astro';
import { renderAgentsHtml, sortAgents } from '../scripts/pages/agents-render';
const initialItems = sortAgents(agentsData.items, 'title');
---
<StarlightPage frontmatter={{ title: 'Custom Agents', description: 'Specialized agents that enhance GitHub Copilot for specific technologies, workflows, and domains', template: 'splash', prev: false, next: false, editUrl: false }}>
@@ -11,47 +16,48 @@ import PageHeader from '../components/PageHeader.astro';
<div class="page-content">
<div class="container">
<div class="search-bar">
<label for="search-input" class="sr-only">Search agents</label>
<input type="text" id="search-input" placeholder="Search agents..." autocomplete="off">
</div>
<!-- Filters -->
<div class="filters-bar" id="filters-bar">
<div class="filter-group">
<label for="filter-model">Model:</label>
<select id="filter-model" multiple aria-label="Filter by model"></select>
</div>
<div class="filter-group">
<label for="filter-tool">Tool:</label>
<select id="filter-tool" multiple aria-label="Filter by tool"></select>
</div>
<div class="filter-group">
<label class="checkbox-label">
<input type="checkbox" id="filter-handoffs">
Has Handoffs
</label>
</div>
<div class="filter-group">
<label for="sort-select">Sort:</label>
<select id="sort-select" aria-label="Sort by">
<option value="title">Name (A-Z)</option>
<option value="lastUpdated">Recently Updated</option>
</select>
</div>
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div>
<div class="results-count" id="results-count" aria-live="polite"></div>
<div class="resource-list" id="resource-list" role="list">
<div class="loading" aria-live="polite">Loading agents...</div>
<div class="listing-toolbar">
<div class="search-bar">
<label for="search-input" class="sr-only">Search agents</label>
<input type="text" id="search-input" placeholder="Search agents..." autocomplete="off">
</div>
<!-- Filters -->
<div class="filters-bar" id="filters-bar">
<div class="filter-group">
<label for="filter-model">Model:</label>
<select id="filter-model" multiple aria-label="Filter by model"></select>
</div>
<div class="filter-group">
<label for="filter-tool">Tool:</label>
<select id="filter-tool" multiple aria-label="Filter by tool"></select>
</div>
<div class="filter-group">
<label class="checkbox-label">
<input type="checkbox" id="filter-handoffs">
Has Handoffs
</label>
</div>
<div class="filter-group">
<label for="sort-select">Sort:</label>
<select id="sort-select" aria-label="Sort by">
<option value="title">Name (A-Z)</option>
<option value="lastUpdated">Recently Updated</option>
</select>
</div>
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div>
</div>
<div class="results-count" id="results-count" aria-live="polite">{initialItems.length} of {initialItems.length} agents</div>
<div class="resource-list" id="resource-list" role="list" set:html={renderAgentsHtml(initialItems)}></div>
<ContributeCTA resourceType="agents" />
</div>
</div>
</main>
<Modal />
<EmbeddedPageData filename="agents.json" data={agentsData} />
<script>
import '../scripts/pages/agents';

View File

@@ -3,6 +3,11 @@ import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import Modal from '../components/Modal.astro';
import ContributeCTA from '../components/ContributeCTA.astro';
import PageHeader from '../components/PageHeader.astro';
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
import hooksData from '../../public/data/hooks.json';
import { renderHooksHtml, sortHooks } from '../scripts/pages/hooks-render';
const initialItems = sortHooks(hooksData.items, 'title');
---
<StarlightPage frontmatter={{ title: 'Hooks', description: 'Automated workflows triggered by Copilot coding agent events', template: 'splash', prev: false, next: false, editUrl: false }}>
@@ -11,34 +16,34 @@ import PageHeader from '../components/PageHeader.astro';
<div class="page-content">
<div class="container">
<div class="search-bar">
<label for="search-input" class="sr-only">Search hooks</label>
<input type="text" id="search-input" placeholder="Search hooks..." autocomplete="off">
<div class="listing-toolbar">
<div class="search-bar">
<label for="search-input" class="sr-only">Search hooks</label>
<input type="text" id="search-input" placeholder="Search hooks..." autocomplete="off">
</div>
<div class="filters-bar" id="filters-bar">
<div class="filter-group">
<label for="filter-hook">Hook Event:</label>
<select id="filter-hook" multiple aria-label="Filter by hook event"></select>
</div>
<div class="filter-group">
<label for="filter-tag">Tag:</label>
<select id="filter-tag" multiple aria-label="Filter by tag"></select>
</div>
<div class="filter-group">
<label for="sort-select">Sort:</label>
<select id="sort-select" aria-label="Sort by">
<option value="title">Name (A-Z)</option>
<option value="lastUpdated">Recently Updated</option>
</select>
</div>
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div>
</div>
<div class="filters-bar" id="filters-bar">
<div class="filter-group">
<label for="filter-hook">Hook Event:</label>
<select id="filter-hook" multiple aria-label="Filter by hook event"></select>
</div>
<div class="filter-group">
<label for="filter-tag">Tag:</label>
<select id="filter-tag" multiple aria-label="Filter by tag"></select>
</div>
<div class="filter-group">
<label for="sort-select">Sort:</label>
<select id="sort-select" aria-label="Sort by">
<option value="title">Name (A-Z)</option>
<option value="lastUpdated">Recently Updated</option>
</select>
</div>
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div>
<div class="results-count" id="results-count" aria-live="polite"></div>
<div class="resource-list" id="resource-list" role="list">
<div class="loading" aria-live="polite">Loading hooks...</div>
</div>
<div class="results-count" id="results-count" aria-live="polite">{initialItems.length} of {initialItems.length} hooks</div>
<div class="resource-list" id="resource-list" role="list" set:html={renderHooksHtml(initialItems)}></div>
<ContributeCTA resourceType="hooks" />
</div>
</div>
@@ -46,6 +51,7 @@ import PageHeader from '../components/PageHeader.astro';
<Modal />
<EmbeddedPageData filename="hooks.json" data={hooksData} />
<script>
import '../scripts/pages/hooks';
</script>

View File

@@ -1,8 +1,13 @@
---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import instructionsData from '../../public/data/instructions.json';
import Modal from '../components/Modal.astro';
import ContributeCTA from '../components/ContributeCTA.astro';
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
import PageHeader from '../components/PageHeader.astro';
import { renderInstructionsHtml, sortInstructions } from '../scripts/pages/instructions-render';
const initialItems = sortInstructions(instructionsData.items, 'title');
---
<StarlightPage frontmatter={{ title: 'Instructions', description: 'Coding standards and best practices for GitHub Copilot', template: 'splash', prev: false, next: false, editUrl: false }}>
@@ -11,36 +16,37 @@ import PageHeader from '../components/PageHeader.astro';
<div class="page-content">
<div class="container">
<div class="search-bar">
<label for="search-input" class="sr-only">Search instructions</label>
<input type="text" id="search-input" placeholder="Search instructions..." autocomplete="off">
<div class="listing-toolbar">
<div class="search-bar">
<label for="search-input" class="sr-only">Search instructions</label>
<input type="text" id="search-input" placeholder="Search instructions..." autocomplete="off">
</div>
<div class="filters-bar" id="filters-bar">
<div class="filter-group">
<label for="filter-extension">File Extension:</label>
<select id="filter-extension" multiple aria-label="Filter by file extension"></select>
</div>
<div class="filter-group">
<label for="sort-select">Sort:</label>
<select id="sort-select" aria-label="Sort by">
<option value="title">Name (A-Z)</option>
<option value="lastUpdated">Recently Updated</option>
</select>
</div>
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div>
</div>
<div class="filters-bar" id="filters-bar">
<div class="filter-group">
<label for="filter-extension">File Extension:</label>
<select id="filter-extension" multiple aria-label="Filter by file extension"></select>
</div>
<div class="filter-group">
<label for="sort-select">Sort:</label>
<select id="sort-select" aria-label="Sort by">
<option value="title">Name (A-Z)</option>
<option value="lastUpdated">Recently Updated</option>
</select>
</div>
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div>
<div class="results-count" id="results-count" aria-live="polite"></div>
<div class="resource-list" id="resource-list" role="list">
<div class="loading" aria-live="polite">Loading instructions...</div>
</div>
<div class="results-count" id="results-count" aria-live="polite">{initialItems.length} of {initialItems.length} instructions</div>
<div class="resource-list" id="resource-list" role="list" set:html={renderInstructionsHtml(initialItems)}></div>
<ContributeCTA resourceType="instructions" />
</div>
</div>
</main>
<Modal />
<EmbeddedPageData filename="instructions.json" data={instructionsData} />
<script>
import '../scripts/pages/instructions';

View File

@@ -1,37 +1,63 @@
---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import samplesData from '../../../../public/data/samples.json';
import Modal from '../../../components/Modal.astro';
import EmbeddedPageData from '../../../components/EmbeddedPageData.astro';
import {
getRecipeResultsCountText,
renderCookbookSectionsHtml,
} from '../../../scripts/pages/samples-render';
const initialRecipeMatches = samplesData.cookbooks.flatMap((cookbook) =>
cookbook.recipes.map((recipe) => ({ cookbook, recipe }))
);
const languageOptions = Array.from(
new Map(
samplesData.cookbooks.flatMap((cookbook) =>
cookbook.languages.map((language) => [language.id, language])
)
).values()
);
---
<StarlightPage frontmatter={{ title: 'Cookbook', description: 'Code samples, recipes, and examples for building with GitHub Copilot' }}>
<div class="cookbook-page">
<div class="search-bar">
<label for="search-input" class="sr-only">Search recipes</label>
<input type="text" id="search-input" placeholder="Search recipes..." autocomplete="off">
</div>
<div class="filters-bar" id="filters-bar">
<div class="filter-group">
<label for="filter-language">Language:</label>
<select id="filter-language" aria-label="Filter by language">
<option value="">All Languages</option>
</select>
<div class="listing-toolbar">
<div class="search-bar">
<label for="search-input" class="sr-only">Search recipes</label>
<input type="text" id="search-input" placeholder="Search recipes..." autocomplete="off">
</div>
<div class="filter-group">
<label for="filter-tag">Tags:</label>
<select id="filter-tag" multiple aria-label="Filter by tags"></select>
<div class="filters-bar" id="filters-bar">
<div class="filter-group">
<label for="filter-language">Language:</label>
<select id="filter-language" aria-label="Filter by language">
<option value="">All Languages</option>
{languageOptions.map((language) => (
<option value={language.id}>{language.name}</option>
))}
</select>
</div>
<div class="filter-group">
<label for="filter-tag">Tags:</label>
<select id="filter-tag" multiple aria-label="Filter by tags">
{samplesData.filters.tags.map((tag) => (
<option value={tag}>{tag}</option>
))}
</select>
</div>
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div>
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div>
<div class="results-count" id="results-count" aria-live="polite"></div>
<div class="results-count" id="results-count" aria-live="polite">{getRecipeResultsCountText(samplesData.totalRecipes, samplesData.totalRecipes)}</div>
<div id="samples-list" role="list">
<div class="loading" aria-live="polite">Loading samples...</div>
</div>
<div id="samples-list" role="list" set:html={renderCookbookSectionsHtml(initialRecipeMatches)}></div>
</div>
<Modal />
<EmbeddedPageData filename="samples.json" data={samplesData} />
<style is:global>
/* Cookbook Section */

View File

@@ -1,8 +1,13 @@
---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import pluginsData from '../../public/data/plugins.json';
import Modal from '../components/Modal.astro';
import ContributeCTA from '../components/ContributeCTA.astro';
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
import PageHeader from '../components/PageHeader.astro';
import { renderPluginsHtml } from '../scripts/pages/plugins-render';
const initialItems = pluginsData.items;
---
<StarlightPage frontmatter={{ title: 'Plugins', description: 'Curated plugins of agents, hooks, and skills for specific workflows', template: 'splash', prev: false, next: false, editUrl: false }}>
@@ -20,35 +25,30 @@ import PageHeader from '../components/PageHeader.astro';
<p>Install any plugin with: <code>copilot plugin install &lt;plugin-name&gt;@awesome-copilot</code></p>
</div>
<div class="search-bar">
<label for="search-input" class="sr-only">Search plugins</label>
<input type="text" id="search-input" placeholder="Search plugins..." autocomplete="off">
<div class="listing-toolbar">
<div class="search-bar">
<label for="search-input" class="sr-only">Search plugins</label>
<input type="text" id="search-input" placeholder="Search plugins..." autocomplete="off">
</div>
<div class="filters-bar" id="filters-bar">
<div class="filter-group">
<label for="filter-tag">Tag:</label>
<select id="filter-tag" multiple aria-label="Filter by tag"></select>
</div>
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div>
</div>
<div class="filters-bar" id="filters-bar">
<div class="filter-group">
<label for="filter-tag">Tag:</label>
<select id="filter-tag" multiple aria-label="Filter by tag"></select>
</div>
<div class="filter-group">
<label class="checkbox-label">
<input type="checkbox" id="filter-featured">
Featured Only
</label>
</div>
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div>
<div class="results-count" id="results-count" aria-live="polite"></div>
<div class="resource-list" id="resource-list" role="list">
<div class="loading" aria-live="polite">Loading plugins...</div>
</div>
<div class="results-count" id="results-count" aria-live="polite">{initialItems.length} of {initialItems.length} plugins</div>
<div class="resource-list" id="resource-list" role="list" set:html={renderPluginsHtml(initialItems)}></div>
<ContributeCTA resourceType="plugins" />
</div>
</div>
</main>
<Modal />
<EmbeddedPageData filename="plugins.json" data={pluginsData} />
<script>
import '../scripts/pages/plugins';

View File

@@ -3,6 +3,11 @@ import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import Modal from '../components/Modal.astro';
import ContributeCTA from '../components/ContributeCTA.astro';
import PageHeader from '../components/PageHeader.astro';
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
import skillsData from '../../public/data/skills.json';
import { renderSkillsHtml, sortSkills } from '../scripts/pages/skills-render';
const initialItems = sortSkills(skillsData.items, 'title');
---
<StarlightPage frontmatter={{ title: 'Skills', description: 'Self-contained agent skills with instructions and bundled resources', template: 'splash', prev: false, next: false, editUrl: false }}>
@@ -11,36 +16,36 @@ import PageHeader from '../components/PageHeader.astro';
<div class="page-content">
<div class="container">
<div class="search-bar">
<label for="search-input" class="sr-only">Search skills</label>
<input type="text" id="search-input" placeholder="Search skills..." autocomplete="off">
<div class="listing-toolbar">
<div class="search-bar">
<label for="search-input" class="sr-only">Search skills</label>
<input type="text" id="search-input" placeholder="Search skills..." autocomplete="off">
</div>
<div class="filters-bar" id="filters-bar">
<div class="filter-group">
<label for="filter-category">Category:</label>
<select id="filter-category" multiple aria-label="Filter by category"></select>
</div>
<div class="filter-group">
<label class="checkbox-label">
<input type="checkbox" id="filter-has-assets">
Has Bundled Assets
</label>
</div>
<div class="filter-group">
<label for="sort-select">Sort:</label>
<select id="sort-select" aria-label="Sort by">
<option value="title">Name (A-Z)</option>
<option value="lastUpdated">Recently Updated</option>
</select>
</div>
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div>
</div>
<div class="filters-bar" id="filters-bar">
<div class="filter-group">
<label for="filter-category">Category:</label>
<select id="filter-category" multiple aria-label="Filter by category"></select>
</div>
<div class="filter-group">
<label class="checkbox-label">
<input type="checkbox" id="filter-has-assets">
Has Bundled Assets
</label>
</div>
<div class="filter-group">
<label for="sort-select">Sort:</label>
<select id="sort-select" aria-label="Sort by">
<option value="title">Name (A-Z)</option>
<option value="lastUpdated">Recently Updated</option>
</select>
</div>
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div>
<div class="results-count" id="results-count" aria-live="polite"></div>
<div class="resource-list" id="resource-list" role="list">
<div class="loading" aria-live="polite">Loading skills...</div>
</div>
<div class="results-count" id="results-count" aria-live="polite">{initialItems.length} of {initialItems.length} skills</div>
<div class="resource-list" id="resource-list" role="list" set:html={renderSkillsHtml(initialItems)}></div>
<ContributeCTA resourceType="skills" />
</div>
</div>
@@ -48,6 +53,7 @@ import PageHeader from '../components/PageHeader.astro';
<Modal />
<EmbeddedPageData filename="skills.json" data={skillsData} />
<script>
import '../scripts/pages/skills';
</script>

View File

@@ -1,7 +1,15 @@
---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import toolsData from '../../public/data/tools.json';
import ContributeCTA from "../components/ContributeCTA.astro";
import EmbeddedPageData from "../components/EmbeddedPageData.astro";
import PageHeader from "../components/PageHeader.astro";
import { renderToolsHtml } from "../scripts/pages/tools-render";
const initialItems = toolsData.items.map((item) => ({
...item,
title: item.name,
}));
---
<StarlightPage frontmatter={{ title: 'Tools', description: 'MCP servers and developer tools for GitHub Copilot', template: 'splash', prev: false, next: false, editUrl: false }}>
@@ -24,15 +32,18 @@ import PageHeader from "../components/PageHeader.astro";
<label for="filter-category" class="sr-only">Filter by category</label>
<select id="filter-category" class="filter-select" aria-label="Filter by category">
<option value="">All Categories</option>
{toolsData.filters.categories.map((category) => (
<option value={category}>{category}</option>
))}
</select>
<button id="clear-filters" class="btn btn-secondary btn-small"
>Clear</button
>
</div>
<div id="results-count" class="results-count" aria-live="polite"></div>
<div id="results-count" class="results-count" aria-live="polite">{initialItems.length} of {initialItems.length} tools</div>
</div>
<div id="tools-list" role="list"></div>
<div id="tools-list" role="list" set:html={renderToolsHtml(initialItems)}></div>
<div class="coming-soon">
<h2>More Tools Coming Soon</h2>
@@ -46,6 +57,8 @@ import PageHeader from "../components/PageHeader.astro";
</div>
</main>
<EmbeddedPageData filename="tools.json" data={toolsData} />
<style is:global>
.search-section {
margin-bottom: 24px;
@@ -94,12 +107,6 @@ import PageHeader from "../components/PageHeader.astro";
color: var(--color-text-muted);
}
.loading {
text-align: center;
padding: 48px;
color: var(--color-text-muted);
}
.empty-state {
text-align: center;
padding: 48px;
@@ -299,7 +306,6 @@ import PageHeader from "../components/PageHeader.astro";
</style>
<script>
import { initToolsPage } from "../scripts/pages/tools";
initToolsPage();
import '../scripts/pages/tools';
</script>
</StarlightPage>

View File

@@ -1,8 +1,13 @@
---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import workflowsData from '../../public/data/workflows.json';
import Modal from '../components/Modal.astro';
import ContributeCTA from '../components/ContributeCTA.astro';
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
import PageHeader from '../components/PageHeader.astro';
import { renderWorkflowsHtml, sortWorkflows } from '../scripts/pages/workflows-render';
const initialItems = sortWorkflows(workflowsData.items, 'title');
---
<StarlightPage frontmatter={{ title: 'Agentic Workflows', description: 'AI-powered repository automations that run coding agents in GitHub Actions', template: 'splash', prev: false, next: false, editUrl: false }}>
@@ -13,36 +18,37 @@ import PageHeader from '../components/PageHeader.astro';
<div class="page-content">
<div class="container">
<div class="search-bar">
<label for="search-input" class="sr-only">Search workflows</label>
<input type="text" id="search-input" placeholder="Search workflows..." autocomplete="off">
<div class="listing-toolbar">
<div class="search-bar">
<label for="search-input" class="sr-only">Search workflows</label>
<input type="text" id="search-input" placeholder="Search workflows..." autocomplete="off">
</div>
<div class="filters-bar" id="filters-bar">
<div class="filter-group">
<label for="filter-trigger">Trigger:</label>
<select id="filter-trigger" multiple aria-label="Filter by trigger"></select>
</div>
<div class="filter-group">
<label for="sort-select">Sort:</label>
<select id="sort-select" aria-label="Sort by">
<option value="title">Name (A-Z)</option>
<option value="lastUpdated">Recently Updated</option>
</select>
</div>
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div>
</div>
<div class="filters-bar" id="filters-bar">
<div class="filter-group">
<label for="filter-trigger">Trigger:</label>
<select id="filter-trigger" multiple aria-label="Filter by trigger"></select>
</div>
<div class="filter-group">
<label for="sort-select">Sort:</label>
<select id="sort-select" aria-label="Sort by">
<option value="title">Name (A-Z)</option>
<option value="lastUpdated">Recently Updated</option>
</select>
</div>
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div>
<div class="results-count" id="results-count" aria-live="polite"></div>
<div class="resource-list" id="resource-list" role="list">
<div class="loading" aria-live="polite">Loading workflows...</div>
</div>
<div class="results-count" id="results-count" aria-live="polite">{initialItems.length} of {initialItems.length} workflows</div>
<div class="resource-list" id="resource-list" role="list" set:html={renderWorkflowsHtml(initialItems)}></div>
<ContributeCTA resourceType="workflows" />
</div>
</div>
</main>
<Modal />
<EmbeddedPageData filename="workflows.json" data={workflowsData} />
<script>
import '../scripts/pages/workflows';

View File

@@ -0,0 +1,29 @@
const embeddedDataCache = new Map<string, unknown>();
export function getEmbeddedDataElementId(filename: string): string {
return `page-data-${filename.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`;
}
export function serializeEmbeddedData(data: unknown): string {
return JSON.stringify(data).replace(/</g, "\\u003c");
}
export function getEmbeddedData<T>(filename: string): T | null {
if (typeof document === "undefined") return null;
if (embeddedDataCache.has(filename)) {
return embeddedDataCache.get(filename) as T;
}
const element = document.getElementById(getEmbeddedDataElementId(filename));
if (!(element instanceof HTMLScriptElement)) return null;
try {
const data = JSON.parse(element.textContent || "null") as T;
embeddedDataCache.set(filename, data);
return data;
} catch (error) {
console.error(`Error parsing embedded data for ${filename}:`, error);
return null;
}
}

View File

@@ -2,6 +2,7 @@
* Modal functionality for file viewing
*/
import { marked } from "marked";
import {
fetchFileContent,
fetchData,
@@ -15,11 +16,15 @@ import {
getResourceIcon,
sanitizeUrl,
} from "./utils";
import fm from "front-matter";
type ModalViewMode = "rendered" | "raw";
// Modal state
let currentFilePath: string | null = null;
let currentFileContent: string | null = null;
let currentFileType: string | null = null;
let currentViewMode: ModalViewMode = "raw";
let triggerElement: HTMLElement | null = null;
let originalDocumentTitle: string | null = null;
@@ -35,6 +40,22 @@ interface ResourceData {
const resourceDataCache: Record<string, ResourceData | null> = {};
interface SkillFile {
name: string;
path: string;
}
interface SkillItem extends ResourceItem {
skillFile: string;
files: SkillFile[];
}
interface SkillsData {
items: SkillItem[];
}
let skillsCache: SkillsData | null | undefined;
const RESOURCE_TYPE_TO_JSON: Record<string, string> = {
agent: "agents.json",
instruction: "instructions.json",
@@ -66,17 +87,313 @@ async function resolveResourceTitle(
const item = data.items.find((i) => i.path === filePath);
if (item) return item.title;
// For skills/hooks, the modal receives the file path (e.g. skills/foo/SKILL.md)
// but JSON stores the folder path (e.g. skills/foo)
const parentPath = filePath.substring(0, filePath.lastIndexOf("/"));
if (parentPath) {
const parentItem = data.items.find((i) => i.path === parentPath);
// For skills/hooks, bundled files live under the resource folder while
// JSON stores the folder path itself (for example, skills/foo).
const collectionRootPath =
type === "skill"
? getCollectionRootPath(filePath, "skills")
: type === "hook"
? getCollectionRootPath(filePath, "hooks")
: filePath.substring(0, filePath.lastIndexOf("/"));
if (collectionRootPath) {
const parentItem = data.items.find((i) => i.path === collectionRootPath);
if (parentItem) return parentItem.title;
}
return fallback;
}
function getFileName(filePath: string): string {
return filePath.split("/").pop() || filePath;
}
function isMarkdownFile(filePath: string): boolean {
return /\.(md|markdown|mdx)$/i.test(filePath);
}
function getCollectionRootPath(filePath: string, collectionName: string): string | null {
const segments = filePath.split("/");
const collectionIndex = segments.indexOf(collectionName);
if (collectionIndex === -1 || segments.length <= collectionIndex + 1) {
return null;
}
return segments.slice(0, collectionIndex + 2).join("/");
}
function getSkillRootPath(filePath: string): string | null {
return getCollectionRootPath(filePath, "skills");
}
async function getSkillsData(): Promise<SkillsData | null> {
if (skillsCache === undefined) {
skillsCache = await fetchData<SkillsData>("skills.json");
}
return skillsCache;
}
async function getSkillItemByFilePath(filePath: string): Promise<SkillItem | null> {
if (getResourceType(filePath) !== "skill") return null;
const skillsData = await getSkillsData();
if (!skillsData) return null;
const rootPath = getSkillRootPath(filePath);
if (!rootPath) return null;
return (
skillsData.items.find(
(item) =>
item.path === rootPath ||
item.skillFile === filePath ||
item.files.some((file) => file.path === filePath)
) || null
);
}
function updateModalTitle(titleText: string, filePath: string): void {
const title = document.getElementById("modal-title");
if (title) {
title.textContent = titleText;
}
const fileName = getFileName(filePath);
document.title =
titleText === fileName
? `${titleText} | Awesome GitHub Copilot`
: `${titleText} · ${fileName} | Awesome GitHub Copilot`;
}
function getModalBody(): HTMLElement | null {
return document.querySelector<HTMLElement>(".modal-body");
}
function getModalContent(): HTMLElement | null {
return document.getElementById("modal-content");
}
function ensurePreContent(): HTMLPreElement | null {
let modalContent = getModalContent();
if (!modalContent) return null;
if (modalContent.tagName === "PRE") {
modalContent.className = "";
if (!modalContent.querySelector("code")) {
modalContent.innerHTML = "<code></code>";
}
return modalContent as HTMLPreElement;
}
const modalBody = getModalBody();
if (!modalBody) return null;
const pre = document.createElement("pre");
pre.id = "modal-content";
pre.innerHTML = "<code></code>";
modalBody.replaceChild(pre, modalContent);
return pre;
}
function ensureDivContent(className: string): HTMLDivElement | null {
let modalContent = getModalContent();
if (!modalContent) return null;
if (modalContent.tagName === "DIV") {
modalContent.className = className;
return modalContent as HTMLDivElement;
}
const modalBody = getModalBody();
if (!modalBody) return null;
const div = document.createElement("div");
div.id = "modal-content";
div.className = className;
modalBody.replaceChild(div, modalContent);
return div;
}
function renderPlainText(content: string): void {
const pre = ensurePreContent();
const codeEl = pre?.querySelector("code");
if (codeEl) {
codeEl.textContent = content;
}
}
const EXTENSION_LANGUAGE_MAP: Record<string, string> = {
bicep: "bicep",
cjs: "javascript",
css: "css",
cs: "csharp",
go: "go",
html: "html",
java: "java",
js: "javascript",
json: "json",
jsx: "jsx",
md: "md",
markdown: "md",
mdx: "mdx",
mjs: "javascript",
ps1: "powershell",
psm1: "powershell",
py: "python",
rb: "ruby",
rs: "rust",
scss: "scss",
sh: "bash",
sql: "sql",
toml: "toml",
ts: "typescript",
tsx: "tsx",
txt: "text",
xml: "xml",
yaml: "yaml",
yml: "yaml",
};
const FILE_NAME_LANGUAGE_MAP: Record<string, string> = {
dockerfile: "dockerfile",
makefile: "makefile",
};
function getLanguageForFile(filePath: string): string {
const fileName = getFileName(filePath);
const lowerFileName = fileName.toLowerCase();
if (FILE_NAME_LANGUAGE_MAP[lowerFileName]) {
return FILE_NAME_LANGUAGE_MAP[lowerFileName];
}
const extension = lowerFileName.includes(".")
? lowerFileName.split(".").pop()
: "";
if (extension && EXTENSION_LANGUAGE_MAP[extension]) {
return EXTENSION_LANGUAGE_MAP[extension];
}
return "text";
}
async function renderHighlightedCode(content: string, filePath: string): Promise<void> {
try {
const { codeToHtml } = await import("shiki");
const container = ensureDivContent("modal-code-content");
if (!container) return;
container.innerHTML = await codeToHtml(content, {
lang: getLanguageForFile(filePath),
themes: {
light: "github-light",
dark: "github-dark",
},
});
} catch {
renderPlainText(content);
}
}
function updateViewButtons(): void {
const renderBtn = document.getElementById("render-btn");
const rawBtn = document.getElementById("raw-btn");
const markdownFile = currentFilePath ? isMarkdownFile(currentFilePath) : false;
if (!renderBtn || !rawBtn) return;
if (!markdownFile) {
renderBtn.classList.add("hidden");
rawBtn.classList.add("hidden");
return;
}
if (currentViewMode === "rendered") {
renderBtn.classList.add("hidden");
rawBtn.classList.remove("hidden");
return;
}
rawBtn.classList.add("hidden");
renderBtn.classList.remove("hidden");
}
async function renderCurrentFileContent(): Promise<void> {
if (!currentFilePath) return;
updateViewButtons();
if (!currentFileContent) {
renderPlainText(
"Failed to load file content. Click the button below to view on GitHub."
);
return;
}
if (isMarkdownFile(currentFilePath) && currentViewMode === "rendered") {
const container = ensureDivContent("modal-rendered-content");
if (!container) return;
const { body: markdownBody } = fm(currentFileContent);
container.innerHTML = marked(markdownBody, { async: false });
} else {
await renderHighlightedCode(currentFileContent, currentFilePath);
}
const modalBody = getModalBody();
if (modalBody) {
modalBody.scrollTop = 0;
}
}
async function configureSkillFileSwitcher(filePath: string): Promise<void> {
const switcher = document.getElementById("modal-file-switcher");
const fileButtonLabel = document.getElementById("modal-file-button-label");
const menu = document.getElementById("modal-file-menu");
if (!switcher || !fileButtonLabel || !menu) return;
const skillItem = await getSkillItemByFilePath(filePath);
if (currentFilePath !== filePath) return;
if (!skillItem || skillItem.files.length <= 1) {
switcher.classList.add("hidden");
fileButtonLabel.textContent = "";
menu.innerHTML = "";
return;
}
fileButtonLabel.textContent = getFileName(filePath);
menu.innerHTML = skillItem.files
.map(
(file) =>
`<button type="button" class="modal-file-menu-item${
file.path === filePath ? " active" : ""
}" data-path="${escapeHtml(file.path)}" role="menuitemradio" aria-checked="${
file.path === filePath ? "true" : "false"
}">${escapeHtml(file.name)}</button>`
)
.join("");
switcher.classList.remove("hidden");
}
function hideSkillFileSwitcher(): void {
const switcher = document.getElementById("modal-file-switcher");
const fileButtonLabel = document.getElementById("modal-file-button-label");
const menu = document.getElementById("modal-file-menu");
const dropdown = document.getElementById("modal-file-dropdown");
const fileButton = document.getElementById("modal-file-button");
const fileToggle = document.getElementById("modal-file-toggle");
switcher?.classList.add("hidden");
dropdown?.classList.remove("open");
fileButton?.setAttribute("aria-expanded", "false");
fileToggle?.setAttribute("aria-expanded", "false");
if (fileButtonLabel) fileButtonLabel.textContent = "";
if (menu) menu.innerHTML = "";
}
// Plugin data cache
interface PluginItem {
path: string;
@@ -170,10 +487,16 @@ export function setupModal(): void {
const copyBtn = document.getElementById("copy-btn");
const downloadBtn = document.getElementById("download-btn");
const shareBtn = document.getElementById("share-btn");
const renderBtn = document.getElementById("render-btn");
const rawBtn = document.getElementById("raw-btn");
const fileDropdown = document.getElementById("modal-file-dropdown");
const fileButton = document.getElementById("modal-file-button");
const fileToggle = document.getElementById("modal-file-toggle");
const fileMenu = document.getElementById("modal-file-menu");
if (!modal) return;
closeBtn?.addEventListener("click", closeModal);
closeBtn?.addEventListener("click", () => closeModal());
modal.addEventListener("click", (e) => {
if (e.target === modal) closeModal();
@@ -219,12 +542,124 @@ export function setupModal(): void {
}
});
renderBtn?.addEventListener("click", async () => {
currentViewMode = "rendered";
await renderCurrentFileContent();
});
rawBtn?.addEventListener("click", async () => {
currentViewMode = "raw";
await renderCurrentFileContent();
});
const setFileMenuOpen = (isOpen: boolean): void => {
if (!fileDropdown) return;
fileDropdown.classList.toggle("open", isOpen);
fileButton?.setAttribute("aria-expanded", String(isOpen));
fileToggle?.setAttribute("aria-expanded", String(isOpen));
};
const toggleFileMenu = (event: Event): void => {
event.preventDefault();
event.stopPropagation();
const isOpen = !fileDropdown?.classList.contains("open");
setFileMenuOpen(Boolean(isOpen));
if (isOpen) {
fileMenu
?.querySelector<HTMLElement>(".modal-file-menu-item.active, .modal-file-menu-item")
?.focus();
}
};
fileButton?.addEventListener("click", toggleFileMenu);
fileToggle?.addEventListener("click", toggleFileMenu);
fileButton?.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
toggleFileMenu(e);
}
});
fileToggle?.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
toggleFileMenu(e);
}
});
fileMenu?.addEventListener("click", async (event) => {
const target = (event.target as HTMLElement).closest<HTMLButtonElement>(
".modal-file-menu-item"
);
const targetPath = target?.dataset.path;
if (!target || !targetPath || !currentFileType) return;
setFileMenuOpen(false);
await openFileModal(
targetPath,
currentFileType,
true,
triggerElement || undefined
);
});
fileMenu?.addEventListener("keydown", async (event) => {
const items = Array.from(
fileMenu.querySelectorAll<HTMLButtonElement>(".modal-file-menu-item")
);
const currentIndex = items.findIndex((item) => item === event.target);
switch (event.key) {
case "ArrowDown":
event.preventDefault();
if (currentIndex >= 0 && currentIndex < items.length - 1) {
items[currentIndex + 1].focus();
}
break;
case "ArrowUp":
event.preventDefault();
if (currentIndex > 0) {
items[currentIndex - 1].focus();
} else {
fileButton?.focus();
}
break;
case "Escape":
event.preventDefault();
setFileMenuOpen(false);
fileButton?.focus();
break;
case "Tab":
setFileMenuOpen(false);
break;
case "Enter":
case " ":
if (currentIndex >= 0 && currentFileType) {
const targetPath = items[currentIndex].dataset.path;
if (!targetPath) return;
event.preventDefault();
setFileMenuOpen(false);
await openFileModal(
targetPath,
currentFileType,
true,
triggerElement || undefined
);
}
break;
}
});
// Setup install dropdown toggle
setupInstallDropdown("install-dropdown");
// Handle browser back/forward navigation
window.addEventListener("hashchange", handleHashChange);
document.addEventListener("click", (e) => {
if (fileDropdown && !fileDropdown.contains(e.target as Node)) {
setFileMenuOpen(false);
}
});
// Check for deep link on initial load
handleHashChange();
}
@@ -372,8 +807,6 @@ export async function openFileModal(
): Promise<void> {
const modal = document.getElementById("file-modal");
const title = document.getElementById("modal-title");
let modalContent = document.getElementById("modal-content");
const contentEl = modalContent?.querySelector("code");
const installDropdown = document.getElementById("install-dropdown");
const installBtnMain = document.getElementById(
"install-btn-main"
@@ -387,38 +820,29 @@ export async function openFileModal(
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) return;
currentFilePath = filePath;
currentFileType = type;
currentViewMode = "raw";
// Track trigger element for focus return
triggerElement = trigger || (document.activeElement as HTMLElement);
triggerElement =
trigger || triggerElement || (document.activeElement as HTMLElement);
// Update URL for deep linking
if (updateUrl) {
updateHash(filePath);
}
// Show modal with loading state
const fallbackName = filePath.split("/").pop() || filePath;
title.textContent = fallbackName;
modal.classList.remove("hidden");
// Update document title to reflect the open file
if (!originalDocumentTitle) {
originalDocumentTitle = document.title;
}
document.title = `${fallbackName} | Awesome GitHub Copilot`;
// Resolve the proper title from JSON data asynchronously
resolveResourceTitle(filePath, type).then((resolvedTitle) => {
if (currentFilePath === filePath) {
title.textContent = resolvedTitle;
document.title = `${resolvedTitle} | Awesome GitHub Copilot`;
}
});
// Show modal with loading state
const fallbackName = getFileName(filePath);
updateModalTitle(fallbackName, filePath);
modal.classList.remove("hidden");
// Set focus to close button for accessibility
setTimeout(() => {
@@ -427,6 +851,9 @@ export async function openFileModal(
// Handle plugins differently - show as item list
if (type === "plugin") {
const modalContent = getModalContent();
if (!modalContent) return;
hideSkillFileSwitcher();
await openPluginModal(
filePath,
title,
@@ -438,27 +865,12 @@ export async function openFileModal(
return;
}
// Regular file modal
if (contentEl) {
contentEl.textContent = "Loading...";
}
// Show copy/download buttons for regular files
if (copyBtn) copyBtn.style.display = "inline-flex";
if (downloadBtn) downloadBtn.style.display = "inline-flex";
// Restore pre/code structure if it was replaced by plugin view
if (modalContent.tagName !== 'PRE') {
const modalBody = modalContent.parentElement;
if (modalBody) {
const pre = document.createElement("pre");
pre.id = "modal-content";
pre.innerHTML = "<code></code>";
modalBody.replaceChild(pre, modalContent);
modalContent = pre;
}
}
const codeEl = modalContent.querySelector("code");
renderPlainText("Loading...");
hideSkillFileSwitcher();
updateViewButtons();
// Setup install dropdown
const vscodeUrl = getVSCodeInstallUrl(type, filePath, false);
@@ -474,16 +886,19 @@ export async function openFileModal(
installDropdown.style.display = "none";
}
// Fetch and display content
const fileContent = await fetchFileContent(filePath);
currentFileContent = fileContent;
const [resolvedTitle, fileContent] = await Promise.all([
resolveResourceTitle(filePath, type),
fetchFileContent(filePath),
type === "skill" ? configureSkillFileSwitcher(filePath) : Promise.resolve(),
]);
if (fileContent && codeEl) {
codeEl.textContent = fileContent;
} else if (codeEl) {
codeEl.textContent =
"Failed to load file content. Click the button below to view on GitHub.";
if (currentFilePath !== filePath) {
return;
}
updateModalTitle(resolvedTitle, filePath);
currentFileContent = fileContent;
await renderCurrentFileContent();
}
/**
@@ -511,7 +926,8 @@ async function openPluginModal(
modalBody.replaceChild(div, modalContent);
modalContent = div;
} else {
modalContent.innerHTML = '<div class="collection-loading">Loading plugin...</div>';
modalContent.innerHTML =
'<div class="collection-loading">Loading plugin...</div>';
}
// Load plugins data if not cached
@@ -551,7 +967,9 @@ async function openPluginModal(
function getExternalPluginUrl(plugin: Plugin): string {
if (plugin.source?.source === "github" && plugin.source.repo) {
const base = `https://github.com/${plugin.source.repo}`;
return plugin.source.path ? `${base}/tree/main/${plugin.source.path}` : base;
return plugin.source.path
? `${base}/tree/main/${plugin.source.path}`
: base;
}
// Sanitize URLs from JSON to prevent XSS via javascript:/data: schemes
return sanitizeUrl(plugin.repository || plugin.homepage);
@@ -569,7 +987,11 @@ function renderExternalPluginModal(
<span class="external-plugin-meta-label">Author</span>
<span class="external-plugin-meta-value">${
plugin.author.url
? `<a href="${sanitizeUrl(plugin.author.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(plugin.author.name)}</a>`
? `<a href="${sanitizeUrl(
plugin.author.url
)}" target="_blank" rel="noopener noreferrer">${escapeHtml(
plugin.author.name
)}</a>`
: escapeHtml(plugin.author.name)
}</span>
</div>`
@@ -578,7 +1000,11 @@ function renderExternalPluginModal(
const repoHtml = plugin.repository
? `<div class="external-plugin-meta-row">
<span class="external-plugin-meta-label">Repository</span>
<span class="external-plugin-meta-value"><a href="${sanitizeUrl(plugin.repository)}" target="_blank" rel="noopener noreferrer">${escapeHtml(plugin.repository)}</a></span>
<span class="external-plugin-meta-value"><a href="${sanitizeUrl(
plugin.repository
)}" target="_blank" rel="noopener noreferrer">${escapeHtml(
plugin.repository
)}</a></span>
</div>`
: "";
@@ -586,21 +1012,31 @@ function renderExternalPluginModal(
plugin.homepage && plugin.homepage !== plugin.repository
? `<div class="external-plugin-meta-row">
<span class="external-plugin-meta-label">Homepage</span>
<span class="external-plugin-meta-value"><a href="${sanitizeUrl(plugin.homepage)}" target="_blank" rel="noopener noreferrer">${escapeHtml(plugin.homepage)}</a></span>
<span class="external-plugin-meta-value"><a href="${sanitizeUrl(
plugin.homepage
)}" target="_blank" rel="noopener noreferrer">${escapeHtml(
plugin.homepage
)}</a></span>
</div>`
: "";
const licenseHtml = plugin.license
? `<div class="external-plugin-meta-row">
<span class="external-plugin-meta-label">License</span>
<span class="external-plugin-meta-value">${escapeHtml(plugin.license)}</span>
<span class="external-plugin-meta-value">${escapeHtml(
plugin.license
)}</span>
</div>`
: "";
const sourceHtml = plugin.source?.repo
? `<div class="external-plugin-meta-row">
<span class="external-plugin-meta-label">Source</span>
<span class="external-plugin-meta-value">GitHub: ${escapeHtml(plugin.source.repo)}${plugin.source.path ? ` (${escapeHtml(plugin.source.path)})` : ""}</span>
<span class="external-plugin-meta-value">GitHub: ${escapeHtml(
plugin.source.repo
)}${
plugin.source.path ? ` (${escapeHtml(plugin.source.path)})` : ""
}</span>
</div>`
: "";
@@ -608,12 +1044,18 @@ function renderExternalPluginModal(
modalContent.innerHTML = `
<div class="collection-view">
<div class="collection-description">${escapeHtml(plugin.description || "")}</div>
<div class="collection-description">${escapeHtml(
plugin.description || ""
)}</div>
${
plugin.tags && plugin.tags.length > 0
? `<div class="collection-tags">
<span class="resource-tag resource-tag-external">🔗 External Plugin</span>
${plugin.tags.map((t) => `<span class="resource-tag">${escapeHtml(t)}</span>`).join("")}
${plugin.tags
.map(
(t) => `<span class="resource-tag">${escapeHtml(t)}</span>`
)
.join("")}
</div>`
: `<div class="collection-tags">
<span class="resource-tag resource-tag-external">🔗 External Plugin</span>
@@ -627,7 +1069,9 @@ function renderExternalPluginModal(
${sourceHtml}
</div>
<div class="external-plugin-cta">
<a href="${sanitizeUrl(repoUrl)}" class="btn btn-primary external-plugin-repo-btn" target="_blank" rel="noopener noreferrer">
<a href="${sanitizeUrl(
repoUrl
)}" class="btn btn-primary external-plugin-repo-btn" target="_blank" rel="noopener noreferrer">
View Repository →
</a>
</div>
@@ -745,7 +1189,9 @@ export function closeModal(updateUrl = true): void {
currentFilePath = null;
currentFileContent = null;
currentFileType = null;
currentViewMode = "raw";
triggerElement = null;
hideSkillFileSwitcher();
}
/**

View File

@@ -0,0 +1,115 @@
import {
escapeHtml,
getActionButtonsHtml,
getGitHubUrl,
getInstallDropdownHtml,
getLastUpdatedHtml,
} from "../utils";
export interface RenderableAgent {
title: string;
description?: string;
path: string;
model?: string;
tools?: string[];
hasHandoffs?: boolean;
lastUpdated?: string | null;
}
export type AgentSortOption = "title" | "lastUpdated";
const resourceType = "agent";
export function sortAgents<T extends RenderableAgent>(
items: T[],
sort: AgentSortOption
): T[] {
return [...items].sort((a, b) => {
if (sort === "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);
});
}
export function renderAgentsHtml(
items: RenderableAgent[],
options: {
query?: string;
highlightTitle?: (title: string, query: string) => string;
} = {}
): string {
const { query = "", highlightTitle } = options;
if (items.length === 0) {
return `
<div class="empty-state">
<h3>No agents found</h3>
<p>Try a different search term or adjust filters</p>
</div>
`;
}
return items
.map((item) => {
const titleHtml =
query && highlightTitle
? highlightTitle(item.title, query)
: escapeHtml(item.title);
return `
<div class="resource-item" data-path="${escapeHtml(item.path)}">
<div class="resource-info">
<div class="resource-title">${titleHtml}</div>
<div class="resource-description">${escapeHtml(
item.description || "No description"
)}</div>
<div class="resource-meta">
${
item.model
? `<span class="resource-tag tag-model">${escapeHtml(
item.model
)}</span>`
: ""
}
${
item.tools
?.slice(0, 3)
.map(
(tool) =>
`<span class="resource-tag">${escapeHtml(tool)}</span>`
)
.join("") || ""
}
${
item.tools && item.tools.length > 3
? `<span class="resource-tag">+${
item.tools.length - 3
} more</span>`
: ""
}
${
item.hasHandoffs
? `<span class="resource-tag tag-handoffs">handoffs</span>`
: ""
}
${getLastUpdatedHtml(item.lastUpdated)}
</div>
</div>
<div class="resource-actions">
${getInstallDropdownHtml(resourceType, item.path, true)}
${getActionButtonsHtml(item.path, true)}
<a href="${getGitHubUrl(
item.path
)}" class="btn btn-secondary btn-small" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">
GitHub
</a>
</div>
</div>
`;
})
.join("");
}

View File

@@ -3,11 +3,11 @@
*/
import { createChoices, getChoicesValues, type Choices } from '../choices';
import { FuzzySearch, type SearchItem } from '../search';
import { fetchData, debounce, escapeHtml, getGitHubUrl, getInstallDropdownHtml, setupDropdownCloseHandlers, getActionButtonsHtml, setupActionHandlers, getLastUpdatedHtml } from '../utils';
import { fetchData, debounce, setupDropdownCloseHandlers, setupActionHandlers } from '../utils';
import { setupModal, openFileModal } from '../modal';
import { renderAgentsHtml, sortAgents, type AgentSortOption, type RenderableAgent } from './agents-render';
interface Agent extends SearchItem {
path: string;
interface Agent extends SearchItem, RenderableAgent {
model?: string;
tools?: string[];
hasHandoffs?: boolean;
@@ -22,14 +22,12 @@ interface AgentsData {
};
}
type SortOption = 'title' | 'lastUpdated';
const resourceType = 'agent';
let allItems: Agent[] = [];
let search = new FuzzySearch<Agent>();
let modelSelect: Choices;
let toolSelect: Choices;
let currentSort: SortOption = 'title';
let currentSort: AgentSortOption = 'title';
let resourceListHandlersReady = false;
let currentFilters = {
models: [] as string[],
@@ -38,16 +36,7 @@ let currentFilters = {
};
function sortItems(items: Agent[]): Agent[] {
return [...items].sort((a, b) => {
if (currentSort === 'lastUpdated') {
// Sort by last updated (newest first), with null/undefined at end
const dateA = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0;
const dateB = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0;
return dateB - dateA;
}
// Default: sort by title
return a.title.localeCompare(b.title);
});
return sortAgents(items, currentSort);
}
function applyFiltersAndRender(): void {
@@ -97,48 +86,31 @@ function renderItems(items: Agent[], query = ''): void {
const list = document.getElementById('resource-list');
if (!list) return;
if (items.length === 0) {
list.innerHTML = `
<div class="empty-state">
<h3>No agents found</h3>
<p>Try a different search term or adjust filters</p>
</div>
`;
return;
}
list.innerHTML = items.map(item => `
<div class="resource-item" data-path="${escapeHtml(item.path)}">
<div class="resource-info">
<div class="resource-title">${query ? search.highlight(item.title, query) : escapeHtml(item.title)}</div>
<div class="resource-description">${escapeHtml(item.description || 'No description')}</div>
<div class="resource-meta">
${item.model ? `<span class="resource-tag tag-model">${escapeHtml(item.model)}</span>` : ''}
${item.tools?.slice(0, 3).map(t => `<span class="resource-tag">${escapeHtml(t)}</span>`).join('') || ''}
${item.tools && item.tools.length > 3 ? `<span class="resource-tag">+${item.tools.length - 3} more</span>` : ''}
${item.hasHandoffs ? `<span class="resource-tag tag-handoffs">handoffs</span>` : ''}
${getLastUpdatedHtml(item.lastUpdated)}
</div>
</div>
<div class="resource-actions">
${getInstallDropdownHtml(resourceType, item.path, true)}
${getActionButtonsHtml(item.path, true)}
<a href="${getGitHubUrl(item.path)}" class="btn btn-secondary btn-small" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">
GitHub
</a>
</div>
</div>
`).join('');
// Add click handlers
list.querySelectorAll('.resource-item').forEach(el => {
el.addEventListener('click', () => {
const path = (el as HTMLElement).dataset.path;
if (path) openFileModal(path, resourceType);
});
list.innerHTML = renderAgentsHtml(items, {
query,
highlightTitle: (title, highlightQuery) => search.highlight(title, highlightQuery),
});
}
function setupResourceListHandlers(list: HTMLElement | null): void {
if (!list || resourceListHandlersReady) return;
list.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
if (target.closest('.resource-actions')) {
return;
}
const item = target.closest('.resource-item') as HTMLElement | null;
const path = item?.dataset.path;
if (path) {
openFileModal(path, 'agent');
}
});
resourceListHandlersReady = true;
}
export async function initAgentsPage(): Promise<void> {
const list = document.getElementById('resource-list');
const searchInput = document.getElementById('search-input') as HTMLInputElement;
@@ -146,6 +118,8 @@ export async function initAgentsPage(): Promise<void> {
const clearFiltersBtn = document.getElementById('clear-filters');
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
setupResourceListHandlers(list as HTMLElement | null);
const data = await fetchData<AgentsData>('agents.json');
if (!data || !data.items) {
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
@@ -173,11 +147,14 @@ export async function initAgentsPage(): Promise<void> {
// Initialize sort select
sortSelect?.addEventListener('change', () => {
currentSort = sortSelect.value as SortOption;
currentSort = sortSelect.value as AgentSortOption;
applyFiltersAndRender();
});
applyFiltersAndRender();
const countEl = document.getElementById('results-count');
if (countEl) {
countEl.textContent = `${allItems.length} of ${allItems.length} agents`;
}
searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200));

View File

@@ -0,0 +1,115 @@
import {
escapeHtml,
getGitHubUrl,
getLastUpdatedHtml,
} from "../utils";
export interface RenderableHook {
id: string;
title: string;
description?: string;
path: string;
readmeFile: string;
hooks: string[];
tags: string[];
assets: string[];
lastUpdated?: string | null;
}
export type HookSortOption = "title" | "lastUpdated";
export function sortHooks<T extends RenderableHook>(
items: T[],
sort: HookSortOption
): T[] {
return [...items].sort((a, b) => {
if (sort === "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);
});
}
export function renderHooksHtml(
items: RenderableHook[],
options: {
query?: string;
highlightTitle?: (title: string, query: string) => string;
} = {}
): string {
const { query = "", highlightTitle } = options;
if (items.length === 0) {
return `
<div class="empty-state">
<h3>No hooks found</h3>
<p>Try a different search term or adjust filters</p>
</div>
`;
}
return items
.map((item) => {
const titleHtml =
query && highlightTitle
? highlightTitle(item.title, query)
: escapeHtml(item.title);
return `
<div class="resource-item" data-path="${escapeHtml(
item.readmeFile
)}" data-hook-id="${escapeHtml(item.id)}">
<div class="resource-info">
<div class="resource-title">${titleHtml}</div>
<div class="resource-description">${escapeHtml(
item.description || "No description"
)}</div>
<div class="resource-meta">
${item.hooks
.map(
(hook) =>
`<span class="resource-tag tag-hook">${escapeHtml(
hook
)}</span>`
)
.join("")}
${item.tags
.map(
(tag) =>
`<span class="resource-tag tag-tag">${escapeHtml(
tag
)}</span>`
)
.join("")}
${
item.assets.length > 0
? `<span class="resource-tag tag-assets">${
item.assets.length
} asset${item.assets.length === 1 ? "" : "s"}</span>`
: ""
}
${getLastUpdatedHtml(item.lastUpdated)}
</div>
</div>
<div class="resource-actions">
<button class="btn btn-primary download-hook-btn" data-hook-id="${escapeHtml(
item.id
)}" title="Download as ZIP">
<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="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>
Download
</button>
<a href="${getGitHubUrl(
item.path
)}" class="btn btn-secondary" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">GitHub</a>
</div>
</div>
`;
})
.join("");
}

View File

@@ -6,24 +6,19 @@ import { FuzzySearch, type SearchItem } from "../search";
import {
fetchData,
debounce,
escapeHtml,
getGitHubUrl,
getRawGitHubUrl,
showToast,
getLastUpdatedHtml,
loadJSZip,
} from "../utils";
import { setupModal, openFileModal } from "../modal";
import JSZip from "../jszip";
import {
renderHooksHtml,
sortHooks,
type HookSortOption,
type RenderableHook,
} from "./hooks-render";
interface Hook extends SearchItem {
id: string;
path: string;
readmeFile: string;
hooks: string[];
tags: string[];
assets: string[];
lastUpdated?: string | null;
}
interface Hook extends SearchItem, RenderableHook {}
interface HooksData {
items: Hook[];
@@ -33,8 +28,6 @@ interface HooksData {
};
}
type SortOption = "title" | "lastUpdated";
const resourceType = "hook";
let allItems: Hook[] = [];
let search = new FuzzySearch<Hook>();
@@ -44,17 +37,11 @@ let currentFilters = {
hooks: [] as string[],
tags: [] as string[],
};
let currentSort: SortOption = "title";
let currentSort: HookSortOption = "title";
let resourceListHandlersReady = false;
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);
});
return sortHooks(items, currentSort);
}
function applyFiltersAndRender(): void {
@@ -104,84 +91,40 @@ function renderItems(items: Hook[], query = ""): void {
const list = document.getElementById("resource-list");
if (!list) return;
if (items.length === 0) {
list.innerHTML =
'<div class="empty-state"><h3>No hooks found</h3><p>Try a different search term or adjust filters</p></div>';
return;
}
list.innerHTML = renderHooksHtml(items, {
query,
highlightTitle: (title, highlightQuery) =>
search.highlight(title, highlightQuery),
});
}
list.innerHTML = items
.map(
(item) => `
<div class="resource-item" data-path="${escapeHtml(
item.readmeFile
)}" data-hook-id="${escapeHtml(item.id)}">
<div class="resource-info">
<div class="resource-title">${
query ? search.highlight(item.title, query) : escapeHtml(item.title)
}</div>
<div class="resource-description">${escapeHtml(
item.description || "No description"
)}</div>
<div class="resource-meta">
${item.hooks
.map(
(h) =>
`<span class="resource-tag tag-hook">${escapeHtml(h)}</span>`
)
.join("")}
${item.tags
.map(
(t) =>
`<span class="resource-tag tag-tag">${escapeHtml(t)}</span>`
)
.join("")}
${
item.assets.length > 0
? `<span class="resource-tag tag-assets">${
item.assets.length
} asset${item.assets.length === 1 ? "" : "s"}</span>`
: ""
}
${getLastUpdatedHtml(item.lastUpdated)}
</div>
</div>
<div class="resource-actions">
<button class="btn btn-primary download-hook-btn" data-hook-id="${escapeHtml(
item.id
)}" title="Download as ZIP">
<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="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>
Download
</button>
<a href="${getGitHubUrl(
item.path
)}" class="btn btn-secondary" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">GitHub</a>
</div>
</div>
`
)
.join("");
function setupResourceListHandlers(list: HTMLElement | null): void {
if (!list || resourceListHandlersReady) return;
// 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);
});
list.addEventListener("click", (event) => {
const target = event.target as HTMLElement;
const downloadButton = target.closest(
".download-hook-btn"
) as HTMLButtonElement | null;
if (downloadButton) {
event.stopPropagation();
const hookId = downloadButton.dataset.hookId;
if (hookId) downloadHook(hookId, downloadButton);
return;
}
if (target.closest(".resource-actions")) {
return;
}
const item = target.closest(".resource-item") as HTMLElement | null;
const path = item?.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);
});
});
resourceListHandlersReady = true;
}
async function downloadHook(
@@ -214,6 +157,7 @@ async function downloadHook(
'<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 {
const JSZip = await loadJSZip();
const zip = new JSZip();
const folder = zip.folder(hook.id);
@@ -279,6 +223,8 @@ export async function initHooksPage(): Promise<void> {
"sort-select"
) as HTMLSelectElement;
setupResourceListHandlers(list as HTMLElement | null);
const data = await fetchData<HooksData>("hooks.json");
if (!data || !data.items) {
if (list)
@@ -321,7 +267,7 @@ export async function initHooksPage(): Promise<void> {
});
sortSelect?.addEventListener("change", () => {
currentSort = sortSelect.value as SortOption;
currentSort = sortSelect.value as HookSortOption;
applyFiltersAndRender();
});

View File

@@ -0,0 +1,86 @@
import {
escapeHtml,
getActionButtonsHtml,
getGitHubUrl,
getInstallDropdownHtml,
getLastUpdatedHtml,
} from '../utils';
export interface RenderableInstruction {
title: string;
description?: string;
path: string;
applyTo?: string | string[] | null;
extensions?: string[];
lastUpdated?: string | null;
}
export type InstructionSortOption = 'title' | 'lastUpdated';
export function sortInstructions<T extends RenderableInstruction>(
items: T[],
sort: InstructionSortOption
): T[] {
return [...items].sort((a, b) => {
if (sort === '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);
});
}
export function renderInstructionsHtml(
items: RenderableInstruction[],
options: {
query?: string;
highlightTitle?: (title: string, query: string) => string;
} = {}
): string {
const { query = '', highlightTitle } = options;
if (items.length === 0) {
return `
<div class="empty-state">
<h3>No instructions found</h3>
<p>Try a different search term or adjust filters</p>
</div>
`;
}
return items
.map((item) => {
const applyToText = Array.isArray(item.applyTo)
? item.applyTo.join(', ')
: item.applyTo;
const titleHtml =
query && highlightTitle
? highlightTitle(item.title, query)
: escapeHtml(item.title);
return `
<div class="resource-item" data-path="${escapeHtml(item.path)}">
<div class="resource-info">
<div class="resource-title">${titleHtml}</div>
<div class="resource-description">${escapeHtml(item.description || 'No description')}</div>
<div class="resource-meta">
${applyToText ? `<span class="resource-tag">applies to: ${escapeHtml(applyToText)}</span>` : ''}
${item.extensions?.slice(0, 4).map((extension) => `<span class="resource-tag tag-extension">${escapeHtml(extension)}</span>`).join('') || ''}
${item.extensions && item.extensions.length > 4 ? `<span class="resource-tag">+${item.extensions.length - 4} more</span>` : ''}
${getLastUpdatedHtml(item.lastUpdated)}
</div>
</div>
<div class="resource-actions">
${getInstallDropdownHtml('instructions', item.path, true)}
${getActionButtonsHtml(item.path, true)}
<a href="${getGitHubUrl(item.path)}" class="btn btn-secondary btn-small" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">
GitHub
</a>
</div>
</div>
`;
})
.join('');
}

View File

@@ -3,12 +3,18 @@
*/
import { createChoices, getChoicesValues, type Choices } from '../choices';
import { FuzzySearch, type SearchItem } from '../search';
import { fetchData, debounce, escapeHtml, getGitHubUrl, getInstallDropdownHtml, setupDropdownCloseHandlers, getActionButtonsHtml, setupActionHandlers, getLastUpdatedHtml } from '../utils';
import { fetchData, debounce, setupDropdownCloseHandlers, setupActionHandlers } from '../utils';
import { setupModal, openFileModal } from '../modal';
import {
renderInstructionsHtml,
sortInstructions,
type InstructionSortOption,
type RenderableInstruction,
} from './instructions-render';
interface Instruction extends SearchItem {
interface Instruction extends SearchItem, RenderableInstruction {
path: string;
applyTo?: string;
applyTo?: string | string[];
extensions?: string[];
lastUpdated?: string | null;
}
@@ -20,24 +26,16 @@ interface InstructionsData {
};
}
type SortOption = 'title' | 'lastUpdated';
const resourceType = 'instruction';
let allItems: Instruction[] = [];
let search = new FuzzySearch<Instruction>();
let extensionSelect: Choices;
let currentFilters = { extensions: [] as string[] };
let currentSort: SortOption = 'title';
let currentSort: InstructionSortOption = 'title';
let resourceListHandlersReady = false;
function sortItems(items: Instruction[]): Instruction[] {
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);
});
return sortInstructions(items, currentSort);
}
function applyFiltersAndRender(): void {
@@ -70,48 +68,39 @@ function renderItems(items: Instruction[], query = ''): void {
const list = document.getElementById('resource-list');
if (!list) return;
if (items.length === 0) {
list.innerHTML = '<div class="empty-state"><h3>No instructions found</h3><p>Try a different search term or adjust filters</p></div>';
return;
}
list.innerHTML = items.map(item => `
<div class="resource-item" data-path="${escapeHtml(item.path)}">
<div class="resource-info">
<div class="resource-title">${query ? search.highlight(item.title, query) : escapeHtml(item.title)}</div>
<div class="resource-description">${escapeHtml(item.description || 'No description')}</div>
<div class="resource-meta">
${item.applyTo ? `<span class="resource-tag">applies to: ${escapeHtml(item.applyTo)}</span>` : ''}
${item.extensions?.slice(0, 4).map(e => `<span class="resource-tag tag-extension">${escapeHtml(e)}</span>`).join('') || ''}
${item.extensions && item.extensions.length > 4 ? `<span class="resource-tag">+${item.extensions.length - 4} more</span>` : ''}
${getLastUpdatedHtml(item.lastUpdated)}
</div>
</div>
<div class="resource-actions">
${getInstallDropdownHtml('instructions', item.path, true)}
${getActionButtonsHtml(item.path, true)}
<a href="${getGitHubUrl(item.path)}" class="btn btn-secondary btn-small" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">
GitHub
</a>
</div>
</div>
`).join('');
// Add click handlers
list.querySelectorAll('.resource-item').forEach(el => {
el.addEventListener('click', () => {
const path = (el as HTMLElement).dataset.path;
if (path) openFileModal(path, resourceType);
});
list.innerHTML = renderInstructionsHtml(items, {
query,
highlightTitle: (title, highlightQuery) => search.highlight(title, highlightQuery),
});
}
function setupResourceListHandlers(list: HTMLElement | null): void {
if (!list || resourceListHandlersReady) return;
list.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
if (target.closest('.resource-actions')) {
return;
}
const item = target.closest('.resource-item') as HTMLElement | null;
const path = item?.dataset.path;
if (path) {
openFileModal(path, resourceType);
}
});
resourceListHandlersReady = true;
}
export async function initInstructionsPage(): Promise<void> {
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;
setupResourceListHandlers(list as HTMLElement | null);
const data = await fetchData<InstructionsData>('instructions.json');
if (!data || !data.items) {
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
@@ -129,11 +118,15 @@ export async function initInstructionsPage(): Promise<void> {
});
sortSelect?.addEventListener('change', () => {
currentSort = sortSelect.value as SortOption;
currentSort = sortSelect.value as InstructionSortOption;
applyFiltersAndRender();
});
applyFiltersAndRender();
const countEl = document.getElementById('results-count');
if (countEl) {
countEl.textContent = `${allItems.length} of ${allItems.length} instructions`;
}
searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200));
clearFiltersBtn?.addEventListener('click', () => {

View File

@@ -0,0 +1,91 @@
import { escapeHtml, getGitHubUrl, sanitizeUrl } from '../utils';
interface PluginAuthor {
name: string;
url?: string;
}
interface PluginSource {
source: string;
repo?: string;
path?: string;
}
export interface RenderablePlugin {
name: string;
description?: string;
path: string;
tags?: string[];
itemCount: number;
external?: boolean;
repository?: string | null;
homepage?: string | null;
author?: PluginAuthor | null;
source?: PluginSource | null;
}
function getExternalPluginUrl(plugin: RenderablePlugin): string {
if (plugin.source?.source === 'github' && plugin.source.repo) {
const base = `https://github.com/${plugin.source.repo}`;
return plugin.source.path ? `${base}/tree/main/${plugin.source.path}` : base;
}
return sanitizeUrl(plugin.repository || plugin.homepage);
}
export function renderPluginsHtml(
items: RenderablePlugin[],
options: {
query?: string;
highlightTitle?: (title: string, query: string) => string;
} = {}
): string {
const { query = '', highlightTitle } = options;
if (items.length === 0) {
return `
<div class="empty-state">
<h3>No plugins found</h3>
<p>Try a different search term or adjust filters</p>
</div>
`;
}
return items
.map((item) => {
const isExternal = item.external === true;
const metaTag = isExternal
? '<span class="resource-tag resource-tag-external">🔗 External</span>'
: `<span class="resource-tag">${item.itemCount} items</span>`;
const authorTag =
isExternal && item.author?.name
? `<span class="resource-tag">by ${escapeHtml(item.author.name)}</span>`
: '';
const githubHref = isExternal
? escapeHtml(getExternalPluginUrl(item))
: getGitHubUrl(item.path);
const titleHtml =
query && highlightTitle
? highlightTitle(item.name, query)
: escapeHtml(item.name);
return `
<div class="resource-item${isExternal ? ' resource-item-external' : ''}" data-path="${escapeHtml(item.path)}">
<div class="resource-info">
<div class="resource-title">${titleHtml}</div>
<div class="resource-description">${escapeHtml(item.description || 'No description')}</div>
<div class="resource-meta">
${metaTag}
${authorTag}
${item.tags?.slice(0, 4).map((tag) => `<span class="resource-tag">${escapeHtml(tag)}</span>`).join('') || ''}
${item.tags && item.tags.length > 4 ? `<span class="resource-tag">+${item.tags.length - 4} more</span>` : ''}
</div>
</div>
<div class="resource-actions">
<a href="${githubHref}" class="btn btn-secondary" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()" title="${isExternal ? 'View repository' : 'View on GitHub'}">${isExternal ? 'Repository' : 'GitHub'}</a>
</div>
</div>
`;
})
.join('');
}

View File

@@ -3,8 +3,9 @@
*/
import { createChoices, getChoicesValues, type Choices } from '../choices';
import { FuzzySearch, type SearchItem } from '../search';
import { fetchData, debounce, escapeHtml, getGitHubUrl, sanitizeUrl } from '../utils';
import { fetchData, debounce } from '../utils';
import { setupModal, openFileModal } from '../modal';
import { renderPluginsHtml, type RenderablePlugin } from './plugins-render';
interface PluginAuthor {
name: string;
@@ -17,12 +18,11 @@ interface PluginSource {
path?: string;
}
interface Plugin extends SearchItem {
interface Plugin extends SearchItem, RenderablePlugin {
id: string;
name: string;
path: string;
tags?: string[];
featured?: boolean;
itemCount: number;
external?: boolean;
repository?: string | null;
@@ -45,8 +45,8 @@ let search = new FuzzySearch<Plugin>();
let tagSelect: Choices;
let currentFilters = {
tags: [] as string[],
featured: false
};
let resourceListHandlersReady = false;
function applyFiltersAndRender(): void {
const searchInput = document.getElementById('search-input') as HTMLInputElement;
@@ -58,14 +58,10 @@ function applyFiltersAndRender(): void {
if (currentFilters.tags.length > 0) {
results = results.filter(item => item.tags?.some(tag => currentFilters.tags.includes(tag)));
}
if (currentFilters.featured) {
results = results.filter(item => item.featured);
}
renderItems(results, query);
const activeFilters: string[] = [];
if (currentFilters.tags.length > 0) activeFilters.push(`${currentFilters.tags.length} tag${currentFilters.tags.length > 1 ? 's' : ''}`);
if (currentFilters.featured) activeFilters.push('featured');
let countText = `${results.length} of ${allItems.length} plugins`;
if (activeFilters.length > 0) {
countText += ` (filtered by ${activeFilters.join(', ')})`;
@@ -73,69 +69,42 @@ function applyFiltersAndRender(): void {
if (countEl) countEl.textContent = countText;
}
function getExternalPluginUrl(plugin: Plugin): string {
if (plugin.source?.source === 'github' && plugin.source.repo) {
const base = `https://github.com/${plugin.source.repo}`;
return plugin.source.path ? `${base}/tree/main/${plugin.source.path}` : base;
}
// Sanitize URLs from JSON to prevent XSS via javascript:/data: schemes
return sanitizeUrl(plugin.repository || plugin.homepage);
}
function renderItems(items: Plugin[], query = ''): void {
const list = document.getElementById('resource-list');
if (!list) return;
if (items.length === 0) {
list.innerHTML = '<div class="empty-state"><h3>No plugins found</h3><p>Try a different search term or adjust filters</p></div>';
return;
}
list.innerHTML = items.map(item => {
const isExternal = item.external === true;
const metaTag = isExternal
? `<span class="resource-tag resource-tag-external">🔗 External</span>`
: `<span class="resource-tag">${item.itemCount} items</span>`;
const authorTag = isExternal && item.author?.name
? `<span class="resource-tag">by ${escapeHtml(item.author.name)}</span>`
: '';
const githubHref = isExternal
? escapeHtml(getExternalPluginUrl(item))
: getGitHubUrl(item.path);
return `
<div class="resource-item${isExternal ? ' resource-item-external' : ''}" data-path="${escapeHtml(item.path)}">
<div class="resource-info">
<div class="resource-title">${item.featured ? '⭐ ' : ''}${query ? search.highlight(item.name, query) : escapeHtml(item.name)}</div>
<div class="resource-description">${escapeHtml(item.description || 'No description')}</div>
<div class="resource-meta">
${metaTag}
${authorTag}
${item.tags?.slice(0, 4).map(t => `<span class="resource-tag">${escapeHtml(t)}</span>`).join('') || ''}
${item.tags && item.tags.length > 4 ? `<span class="resource-tag">+${item.tags.length - 4} more</span>` : ''}
</div>
</div>
<div class="resource-actions">
<a href="${githubHref}" class="btn btn-secondary" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()" title="${isExternal ? 'View repository' : 'View on GitHub'}">${isExternal ? 'Repository' : 'GitHub'}</a>
</div>
</div>`;
}).join('');
// Add click handlers
list.querySelectorAll('.resource-item').forEach(el => {
el.addEventListener('click', () => {
const path = (el as HTMLElement).dataset.path;
if (path) openFileModal(path, resourceType);
});
list.innerHTML = renderPluginsHtml(items, {
query,
highlightTitle: (title, highlightQuery) => search.highlight(title, highlightQuery),
});
}
function setupResourceListHandlers(list: HTMLElement | null): void {
if (!list || resourceListHandlersReady) return;
list.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
if (target.closest('.resource-actions')) {
return;
}
const item = target.closest('.resource-item') as HTMLElement | null;
const path = item?.dataset.path;
if (path) {
openFileModal(path, resourceType);
}
});
resourceListHandlersReady = true;
}
export async function initPluginsPage(): Promise<void> {
const list = document.getElementById('resource-list');
const searchInput = document.getElementById('search-input') as HTMLInputElement;
const featuredCheckbox = document.getElementById('filter-featured') as HTMLInputElement;
const clearFiltersBtn = document.getElementById('clear-filters');
setupResourceListHandlers(list as HTMLElement | null);
const data = await fetchData<PluginsData>('plugins.json');
if (!data || !data.items) {
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
@@ -159,18 +128,16 @@ export async function initPluginsPage(): Promise<void> {
applyFiltersAndRender();
});
applyFiltersAndRender();
const countEl = document.getElementById('results-count');
if (countEl) {
countEl.textContent = `${allItems.length} of ${allItems.length} plugins`;
}
searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200));
featuredCheckbox?.addEventListener('change', () => {
currentFilters.featured = featuredCheckbox.checked;
applyFiltersAndRender();
});
clearFiltersBtn?.addEventListener('click', () => {
currentFilters = { tags: [], featured: false };
currentFilters = { tags: [] };
tagSelect.removeActiveItems();
if (featuredCheckbox) featuredCheckbox.checked = false;
if (searchInput) searchInput.value = '';
applyFiltersAndRender();
});

View File

@@ -0,0 +1,251 @@
import { escapeHtml, sanitizeUrl } from "../utils";
export interface Language {
id: string;
name: string;
icon: string;
extension: string;
}
export interface RecipeVariant {
doc: string;
example: string | null;
}
export interface Recipe {
id: string;
name: string;
description: string;
tags: string[];
languages: string[];
variants: Record<string, RecipeVariant>;
external?: boolean;
url?: string | null;
author?: { name: string; url?: string } | null;
}
export interface Cookbook {
id: string;
name: string;
description: string;
path: string;
featured: boolean;
languages: Language[];
recipes: Recipe[];
}
export interface CookbookRecipeMatch {
cookbook: Cookbook;
recipe: Recipe;
highlightedName?: string;
}
export function getRecipeResultsCountText(
filteredCount: number,
totalCount: number
): string {
if (filteredCount === totalCount) {
return `${totalCount} recipe${totalCount !== 1 ? "s" : ""}`;
}
return `${filteredCount} of ${totalCount} recipe${
totalCount !== 1 ? "s" : ""
}`;
}
export function renderCookbookSectionsHtml(
matches: CookbookRecipeMatch[],
options: {
selectedLanguage?: string | null;
} = {}
): string {
if (matches.length === 0) {
return `
<div class="empty-state">
<h3>No Results Found</h3>
<p>Try adjusting your search or filters.</p>
</div>
`;
}
const { selectedLanguage = null } = options;
const byCookbook = new Map<
string,
{ cookbook: Cookbook; recipes: { recipe: Recipe; highlightedName?: string }[] }
>();
matches.forEach(({ cookbook, recipe, highlightedName }) => {
if (!byCookbook.has(cookbook.id)) {
byCookbook.set(cookbook.id, { cookbook, recipes: [] });
}
byCookbook.get(cookbook.id)?.recipes.push({ recipe, highlightedName });
});
let html = "";
byCookbook.forEach(({ cookbook, recipes }) => {
html += renderCookbookSection(cookbook, recipes, selectedLanguage);
});
return html;
}
function renderCookbookSection(
cookbook: Cookbook,
recipes: { recipe: Recipe; highlightedName?: string }[],
selectedLanguage: string | null
): string {
const languageTabs = cookbook.languages
.map(
(language) => `
<button class="lang-tab${selectedLanguage === language.id ? " active" : ""}"
data-lang="${escapeHtml(language.id)}"
title="${escapeHtml(language.name)}">
${escapeHtml(language.icon)}
</button>
`
)
.join("");
const recipeCards = recipes
.map(({ recipe, highlightedName }) =>
renderRecipeCard(cookbook, recipe, selectedLanguage, highlightedName)
)
.join("");
return `
<div class="cookbook-section" data-cookbook="${escapeHtml(cookbook.id)}">
<div class="cookbook-header">
<div class="cookbook-info">
<h2>${escapeHtml(cookbook.name)}</h2>
<p>${escapeHtml(cookbook.description)}</p>
</div>
<div class="cookbook-languages">
${languageTabs}
</div>
</div>
<div class="recipes-grid">
${recipeCards}
</div>
</div>
`;
}
function renderRecipeCard(
cookbook: Cookbook,
recipe: Recipe,
selectedLanguage: string | null,
highlightedName?: string
): string {
const recipeKey = `${cookbook.id}-${recipe.id}`;
const tags = recipe.tags
.map((tag) => `<span class="recipe-tag">${escapeHtml(tag)}</span>`)
.join("");
const titleHtml = highlightedName || escapeHtml(recipe.name);
if (recipe.external && recipe.url) {
const authorHtml = recipe.author
? `<span class="recipe-author">by ${
recipe.author.url
? `<a href="${sanitizeUrl(
recipe.author.url
)}" target="_blank" rel="noopener">${escapeHtml(
recipe.author.name
)}</a>`
: escapeHtml(recipe.author.name)
}</span>`
: "";
return `
<div class="recipe-card external" data-recipe="${escapeHtml(recipeKey)}">
<div class="recipe-header">
<h3>${titleHtml}</h3>
<span class="recipe-badge external-badge" title="External project">
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
<path d="M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z"/>
</svg>
Community
</span>
</div>
<p class="recipe-description">${escapeHtml(recipe.description)}</p>
${authorHtml ? `<div class="recipe-author-line">${authorHtml}</div>` : ""}
<div class="recipe-tags">${tags}</div>
<div class="recipe-actions">
<a href="${sanitizeUrl(
recipe.url
)}" class="btn btn-primary btn-small" target="_blank" rel="noopener">
<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"/>
</svg>
View on GitHub
</a>
</div>
</div>
`;
}
const displayLanguage = selectedLanguage || cookbook.languages?.[0]?.id || "nodejs";
const variant = recipe.variants[displayLanguage];
const langIndicators = (cookbook.languages ?? [])
.filter((language) => recipe.variants[language.id])
.map(
(language) =>
`<span class="lang-indicator" title="${escapeHtml(language.name)}">${escapeHtml(
language.icon
)}</span>`
)
.join("");
return `
<div class="recipe-card" data-recipe="${escapeHtml(
recipeKey
)}" data-cookbook="${escapeHtml(cookbook.id)}" data-recipe-id="${escapeHtml(
recipe.id
)}">
<div class="recipe-header">
<h3>${titleHtml}</h3>
<div class="recipe-langs">${langIndicators}</div>
</div>
<p class="recipe-description">${escapeHtml(recipe.description)}</p>
<div class="recipe-tags">${tags}</div>
<div class="recipe-actions">
${
variant
? `
<button class="btn btn-secondary btn-small view-recipe-btn" data-doc="${escapeHtml(
variant.doc
)}">
<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"/>
</svg>
View Recipe
</button>
${
variant.example
? `
<button class="btn btn-secondary btn-small view-example-btn" data-example="${escapeHtml(
variant.example
)}">
<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"/>
</svg>
View Example
</button>
`
: ""
}
<a href="https://github.com/github/awesome-copilot/blob/main/${escapeHtml(
variant.doc
)}"
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">
<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>
GitHub
</a>
`
: '<span class="no-variant">Not available for selected language</span>'
}
</div>
</div>
`;
}

View File

@@ -3,44 +3,16 @@
*/
import { FuzzySearch, type SearchableItem } from "../search";
import { fetchData, escapeHtml } from "../utils";
import { fetchData, debounce } from "../utils";
import { createChoices, getChoicesValues, type Choices } from "../choices";
import { setupModal } from "../modal";
// Types
interface Language {
id: string;
name: string;
icon: string;
extension: string;
}
interface RecipeVariant {
doc: string;
example: string | null;
}
interface Recipe {
id: string;
name: string;
description: string;
tags: string[];
languages: string[];
variants: Record<string, RecipeVariant>;
external?: boolean;
url?: string | null;
author?: { name: string; url?: string } | null;
}
interface Cookbook {
id: string;
name: string;
description: string;
path: string;
featured: boolean;
languages: Language[];
recipes: Recipe[];
}
import {
getRecipeResultsCountText,
renderCookbookSectionsHtml,
type Cookbook,
type CookbookRecipeMatch,
type Language,
} from "./samples-render";
interface SamplesData {
cookbooks: Cookbook[];
@@ -57,13 +29,16 @@ let samplesData: SamplesData | null = null;
let search: FuzzySearch<SearchableItem> | null = null;
let selectedLanguage: string | null = null;
let selectedTags: string[] = [];
let expandedRecipes: Set<string> = new Set();
let tagChoices: Choices | null = null;
let initialized = false;
/**
* Initialize the samples page
*/
export async function initSamplesPage(): Promise<void> {
if (initialized) return;
initialized = true;
try {
// Load samples data
samplesData = await fetchData<SamplesData>("samples.json");
@@ -90,7 +65,7 @@ export async function initSamplesPage(): Promise<void> {
setupModal();
setupFilters();
setupSearch();
renderCookbooks();
setupRecipeListeners();
updateResultsCount();
} catch (error) {
console.error("Failed to initialize samples page:", error);
@@ -186,14 +161,13 @@ function setupSearch(): void {
) as HTMLInputElement;
if (!searchInput) return;
let debounceTimer: number;
searchInput.addEventListener("input", () => {
clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(() => {
searchInput.addEventListener(
"input",
debounce(() => {
renderCookbooks();
updateResultsCount();
}, 200);
});
}, 200)
);
}
/**
@@ -225,11 +199,7 @@ function clearFilters(): void {
/**
* Get filtered recipes
*/
function getFilteredRecipes(): {
cookbook: Cookbook;
recipe: Recipe;
highlighted?: string;
}[] {
function getFilteredRecipes(): CookbookRecipeMatch[] {
if (!samplesData || !search) return [];
const searchInput = document.getElementById(
@@ -237,8 +207,7 @@ function getFilteredRecipes(): {
) as HTMLInputElement;
const query = searchInput?.value.trim() || "";
let results: { cookbook: Cookbook; recipe: Recipe; highlighted?: string }[] =
[];
let results: CookbookRecipeMatch[] = [];
if (query) {
// Use fuzzy search - returns SearchableItem[] directly
@@ -250,8 +219,8 @@ function getFilteredRecipes(): {
)!;
return {
cookbook,
recipe: recipe as unknown as Recipe,
highlighted: search!.highlight(recipe.title, query),
recipe: recipe as unknown as CookbookRecipeMatch["recipe"],
highlightedName: search!.highlight(recipe.title, query),
};
});
} else {
@@ -285,204 +254,14 @@ function renderCookbooks(): void {
const container = document.getElementById("samples-list");
if (!container || !samplesData) return;
const filteredResults = getFilteredRecipes();
if (filteredResults.length === 0) {
container.innerHTML = `
<div class="empty-state">
<h3>No Results Found</h3>
<p>Try adjusting your search or filters.</p>
</div>
`;
return;
}
// Group by cookbook
const byCookbook = new Map<
string,
{ cookbook: Cookbook; recipes: { recipe: Recipe; highlighted?: string }[] }
>();
filteredResults.forEach(({ cookbook, recipe, highlighted }) => {
if (!byCookbook.has(cookbook.id)) {
byCookbook.set(cookbook.id, { cookbook, recipes: [] });
}
byCookbook.get(cookbook.id)!.recipes.push({ recipe, highlighted });
container.innerHTML = renderCookbookSectionsHtml(getFilteredRecipes(), {
selectedLanguage,
});
let html = "";
byCookbook.forEach(({ cookbook, recipes }) => {
html += renderCookbookSection(cookbook, recipes);
});
container.innerHTML = html;
// Setup event listeners
setupRecipeListeners();
}
/**
* Render a cookbook section
*/
function renderCookbookSection(
cookbook: Cookbook,
recipes: { recipe: Recipe; highlighted?: string }[]
): string {
const languageTabs = cookbook.languages
.map(
(lang) => `
<button class="lang-tab${selectedLanguage === lang.id ? " active" : ""}"
data-lang="${lang.id}"
title="${lang.name}">
${lang.icon}
</button>
`
)
.join("");
const recipeCards = recipes
.map(({ recipe, highlighted }) =>
renderRecipeCard(cookbook, recipe, highlighted)
)
.join("");
return `
<div class="cookbook-section" data-cookbook="${cookbook.id}">
<div class="cookbook-header">
<div class="cookbook-info">
<h2>${escapeHtml(cookbook.name)}</h2>
<p>${escapeHtml(cookbook.description)}</p>
</div>
<div class="cookbook-languages">
${languageTabs}
</div>
</div>
<div class="recipes-grid">
${recipeCards}
</div>
</div>
`;
}
/**
* Render a recipe card
*/
function renderRecipeCard(
cookbook: Cookbook,
recipe: Recipe,
highlightedName?: string
): string {
const recipeKey = `${cookbook.id}-${recipe.id}`;
const isExpanded = expandedRecipes.has(recipeKey);
const tags = recipe.tags
.map((tag) => `<span class="recipe-tag">${escapeHtml(tag)}</span>`)
.join("");
// External recipe — link to external URL
if (recipe.external && recipe.url) {
const authorHtml = recipe.author
? `<span class="recipe-author">by ${
recipe.author.url
? `<a href="${escapeHtml(recipe.author.url)}" target="_blank" rel="noopener">${escapeHtml(recipe.author.name)}</a>`
: escapeHtml(recipe.author.name)
}</span>`
: "";
return `
<div class="recipe-card external${
isExpanded ? " expanded" : ""
}" data-recipe="${escapeHtml(recipeKey)}">
<div class="recipe-header">
<h3>${highlightedName || escapeHtml(recipe.name)}</h3>
<span class="recipe-badge external-badge" title="External project">
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
<path d="M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z"/>
</svg>
Community
</span>
</div>
<p class="recipe-description">${escapeHtml(recipe.description)}</p>
${authorHtml ? `<div class="recipe-author-line">${authorHtml}</div>` : ""}
<div class="recipe-tags">${tags}</div>
<div class="recipe-actions">
<a href="${escapeHtml(recipe.url)}"
class="btn btn-primary btn-small" target="_blank" rel="noopener">
<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"/>
</svg>
View on GitHub
</a>
</div>
</div>
`;
}
// Local recipe — existing behavior
// Determine which language to show
const displayLang = selectedLanguage || cookbook.languages?.[0]?.id || "nodejs";
const variant = recipe.variants[displayLang];
const langIndicators = (cookbook.languages ?? [])
.filter((lang) => recipe.variants[lang.id])
.map(
(lang) =>
`<span class="lang-indicator" title="${lang.name}">${lang.icon}</span>`
)
.join("");
return `
<div class="recipe-card${
isExpanded ? " expanded" : ""
}" data-recipe="${recipeKey}" data-cookbook="${
cookbook.id
}" data-recipe-id="${recipe.id}">
<div class="recipe-header">
<h3>${highlightedName || escapeHtml(recipe.name)}</h3>
<div class="recipe-langs">${langIndicators}</div>
</div>
<p class="recipe-description">${escapeHtml(recipe.description)}</p>
<div class="recipe-tags">${tags}</div>
<div class="recipe-actions">
${
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">
<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>
View Recipe
</button>
${
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">
<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>
View Example
</button>
`
: ""
}
<a href="https://github.com/github/awesome-copilot/blob/main/${
variant.doc
}"
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">
<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>
GitHub
</a>
`
: '<span class="no-variant">Not available for selected language</span>'
}
</div>
</div>
`;
}
/**
* Setup event listeners for recipe interactions
*/
@@ -548,14 +327,7 @@ function updateResultsCount(): void {
const filtered = getFilteredRecipes();
const total = samplesData.totalRecipes;
if (filtered.length === total) {
resultsCount.textContent = `${total} recipe${total !== 1 ? "s" : ""}`;
} else {
resultsCount.textContent = `${filtered.length} of ${total} recipe${
total !== 1 ? "s" : ""
}`;
}
resultsCount.textContent = getRecipeResultsCountText(filtered.length, total);
}
// Auto-initialize when DOM is ready

View File

@@ -0,0 +1,111 @@
import {
escapeHtml,
getGitHubUrl,
getLastUpdatedHtml,
} from "../utils";
export interface RenderableSkillFile {
name: string;
path: string;
}
export interface RenderableSkill {
id: string;
title: string;
description?: string;
path: string;
skillFile: string;
category: string;
hasAssets: boolean;
assetCount: number;
files: RenderableSkillFile[];
lastUpdated?: string | null;
}
export type SkillSortOption = "title" | "lastUpdated";
export function sortSkills<T extends RenderableSkill>(
items: T[],
sort: SkillSortOption
): T[] {
return [...items].sort((a, b) => {
if (sort === "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);
});
}
export function renderSkillsHtml(
items: RenderableSkill[],
options: {
query?: string;
highlightTitle?: (title: string, query: string) => string;
} = {}
): string {
const { query = "", highlightTitle } = options;
if (items.length === 0) {
return `
<div class="empty-state">
<h3>No skills found</h3>
<p>Try a different search term or adjust filters</p>
</div>
`;
}
return items
.map((item) => {
const titleHtml =
query && highlightTitle
? highlightTitle(item.title, query)
: escapeHtml(item.title);
return `
<div class="resource-item" data-path="${escapeHtml(
item.skillFile
)}" data-skill-id="${escapeHtml(item.id)}">
<div class="resource-info">
<div class="resource-title">${titleHtml}</div>
<div class="resource-description">${escapeHtml(
item.description || "No description"
)}</div>
<div class="resource-meta">
<span class="resource-tag tag-category">${escapeHtml(
item.category
)}</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>
${getLastUpdatedHtml(item.lastUpdated)}
</div>
</div>
<div class="resource-actions">
<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">
<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"/>
</svg>
Download
</button>
<a href="${getGitHubUrl(
item.path
)}" class="btn btn-secondary" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">GitHub</a>
</div>
</div>
`;
})
.join("");
}

View File

@@ -6,29 +6,25 @@ import { FuzzySearch, type SearchItem } from "../search";
import {
fetchData,
debounce,
escapeHtml,
getGitHubUrl,
getRawGitHubUrl,
showToast,
getLastUpdatedHtml,
loadJSZip,
} from "../utils";
import { setupModal, openFileModal } from "../modal";
import JSZip from "../jszip";
import {
renderSkillsHtml,
sortSkills,
type RenderableSkill,
type SkillSortOption,
} from "./skills-render";
interface SkillFile {
name: string;
path: string;
}
interface Skill extends SearchItem {
id: string;
path: string;
skillFile: string;
category: string;
hasAssets: boolean;
assetCount: number;
interface Skill extends SearchItem, Omit<RenderableSkill, "files"> {
files: SkillFile[];
lastUpdated?: string | null;
}
interface SkillsData {
@@ -38,8 +34,6 @@ interface SkillsData {
};
}
type SortOption = 'title' | 'lastUpdated';
const resourceType = "skill";
let allItems: Skill[] = [];
let search = new FuzzySearch<Skill>();
@@ -48,17 +42,11 @@ let currentFilters = {
categories: [] as string[],
hasAssets: false,
};
let currentSort: SortOption = 'title';
let currentSort: SkillSortOption = 'title';
let resourceListHandlersReady = false;
function sortItems(items: Skill[]): Skill[] {
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);
});
return sortSkills(items, currentSort);
}
function applyFiltersAndRender(): void {
@@ -101,79 +89,36 @@ function renderItems(items: Skill[], query = ""): void {
const list = document.getElementById("resource-list");
if (!list) return;
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>';
return;
}
list.innerHTML = renderSkillsHtml(items, {
query,
highlightTitle: (title, highlightQuery) =>
search.highlight(title, highlightQuery),
});
}
list.innerHTML = items
.map(
(item) => `
<div class="resource-item" data-path="${escapeHtml(
item.skillFile
)}" data-skill-id="${escapeHtml(item.id)}">
<div class="resource-info">
<div class="resource-title">${
query ? search.highlight(item.title, query) : escapeHtml(item.title)
}</div>
<div class="resource-description">${escapeHtml(
item.description || "No description"
)}</div>
<div class="resource-meta">
<span class="resource-tag tag-category">${escapeHtml(
item.category
)}</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>
${getLastUpdatedHtml(item.lastUpdated)}
</div>
</div>
<div class="resource-actions">
<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">
<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"/>
</svg>
Download
</button>
<a href="${getGitHubUrl(
item.path
)}" class="btn btn-secondary" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">GitHub</a>
</div>
</div>
`
)
.join("");
function setupResourceListHandlers(list: HTMLElement | null): void {
if (!list || resourceListHandlersReady) return;
// Add click handlers for opening modal
list.querySelectorAll(".resource-item").forEach((el) => {
el.addEventListener("click", (e) => {
// Don't trigger modal if clicking download button or github link
if ((e.target as HTMLElement).closest(".resource-actions")) return;
const path = (el as HTMLElement).dataset.path;
if (path) openFileModal(path, resourceType);
});
list.addEventListener("click", (event) => {
const target = event.target as HTMLElement;
const downloadButton = target.closest(
".download-skill-btn"
) as HTMLButtonElement | null;
if (downloadButton) {
event.stopPropagation();
const skillId = downloadButton.dataset.skillId;
if (skillId) downloadSkill(skillId, downloadButton);
return;
}
if (target.closest(".resource-actions")) return;
const item = target.closest(".resource-item") as HTMLElement | null;
const path = item?.dataset.path;
if (path) openFileModal(path, resourceType);
});
// Add download handlers
list.querySelectorAll(".download-skill-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const skillId = (btn as HTMLElement).dataset.skillId;
if (skillId) downloadSkill(skillId, btn as HTMLButtonElement);
});
});
resourceListHandlersReady = true;
}
async function downloadSkill(
@@ -192,6 +137,7 @@ async function downloadSkill(
'<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 {
const JSZip = await loadJSZip();
const zip = new JSZip();
const folder = zip.folder(skill.id);
@@ -257,6 +203,8 @@ export async function initSkillsPage(): Promise<void> {
const clearFiltersBtn = document.getElementById("clear-filters");
const sortSelect = document.getElementById("sort-select") as HTMLSelectElement;
setupResourceListHandlers(list as HTMLElement | null);
const data = await fetchData<SkillsData>("skills.json");
if (!data || !data.items) {
if (list)
@@ -283,7 +231,7 @@ export async function initSkillsPage(): Promise<void> {
});
sortSelect?.addEventListener("change", () => {
currentSort = sortSelect.value as SortOption;
currentSort = sortSelect.value as SkillSortOption;
applyFiltersAndRender();
});

View File

@@ -0,0 +1,198 @@
import { escapeHtml } from "../utils";
export interface RenderableTool {
id: string;
name: string;
title: string;
description: string;
category: string;
featured: boolean;
requirements: string[];
features: string[];
links: {
blog?: string;
vscode?: string;
"vscode-insiders"?: string;
"visual-studio"?: string;
github?: string;
documentation?: string;
marketplace?: string;
npm?: string;
pypi?: string;
};
configuration?: {
type: string;
content: string;
} | null;
tags: string[];
}
function formatMultilineText(text: string): string {
return escapeHtml(text).replace(/\r?\n/g, "<br>");
}
function sanitizeToolUrl(url: string): string {
try {
const protocol = new URL(url).protocol;
if (
protocol === "http:" ||
protocol === "https:" ||
protocol === "vscode:" ||
protocol === "vscode-insiders:"
) {
return escapeHtml(url);
}
} catch {
return "#";
}
return "#";
}
function getToolActionLink(
href: string | undefined,
label: string,
className: string
): string {
if (!href) return "";
return `<a href="${sanitizeToolUrl(
href
)}" class="${className}" target="_blank" rel="noopener">${label}</a>`;
}
export function renderToolsHtml(
tools: RenderableTool[],
options: {
query?: string;
highlightTitle?: (title: string, query: string) => string;
} = {}
): string {
const { query = "", highlightTitle } = options;
if (tools.length === 0) {
return `
<div class="empty-state">
<h3>No tools found</h3>
<p>Try a different search term or adjust filters</p>
</div>
`;
}
return tools
.map((tool) => {
const badges: string[] = [];
if (tool.featured) {
badges.push('<span class="tool-badge featured">Featured</span>');
}
badges.push(
`<span class="tool-badge category">${escapeHtml(tool.category)}</span>`
);
const features =
tool.features && tool.features.length > 0
? `<div class="tool-section">
<h3>Features</h3>
<ul>${tool.features
.map((feature) => `<li>${escapeHtml(feature)}</li>`)
.join("")}</ul>
</div>`
: "";
const requirements =
tool.requirements && tool.requirements.length > 0
? `<div class="tool-section">
<h3>Requirements</h3>
<ul>${tool.requirements
.map((requirement) => `<li>${escapeHtml(requirement)}</li>`)
.join("")}</ul>
</div>`
: "";
const tags =
tool.tags && tool.tags.length > 0
? `<div class="tool-tags">
${tool.tags
.map((tag) => `<span class="tool-tag">${escapeHtml(tag)}</span>`)
.join("")}
</div>`
: "";
const config = tool.configuration
? `<div class="tool-config">
<h3>Configuration</h3>
<div class="tool-config-wrapper">
<pre><code>${escapeHtml(tool.configuration.content)}</code></pre>
</div>
<button class="copy-config-btn" data-config="${encodeURIComponent(
tool.configuration.content
)}">
<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="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"/>
</svg>
Copy Configuration
</button>
</div>`
: "";
const actions = [
getToolActionLink(tool.links.blog, "📖 Blog", "btn btn-secondary"),
getToolActionLink(
tool.links.marketplace,
"🏪 Marketplace",
"btn btn-secondary"
),
getToolActionLink(tool.links.npm, "📦 npm", "btn btn-secondary"),
getToolActionLink(tool.links.pypi, "🐍 PyPI", "btn btn-secondary"),
getToolActionLink(
tool.links.documentation,
"📚 Docs",
"btn btn-secondary"
),
getToolActionLink(tool.links.github, "GitHub", "btn btn-secondary"),
getToolActionLink(
tool.links.vscode,
"Install in VS Code",
"btn btn-primary"
),
getToolActionLink(
tool.links["vscode-insiders"],
"VS Code Insiders",
"btn btn-outline"
),
getToolActionLink(
tool.links["visual-studio"],
"Visual Studio",
"btn btn-outline"
),
].filter(Boolean);
const actionsHtml =
actions.length > 0
? `<div class="tool-actions">${actions.join("")}</div>`
: "";
const titleHtml =
query && highlightTitle
? highlightTitle(tool.name, query)
: escapeHtml(tool.name);
return `
<div class="tool-card">
<div class="tool-header">
<h2>${titleHtml}</h2>
<div class="tool-badges">
${badges.join("")}
</div>
</div>
<p class="tool-description">${formatMultilineText(tool.description)}</p>
${features}
${requirements}
${config}
${tags}
${actionsHtml}
</div>
`;
})
.join("");
}

View File

@@ -2,7 +2,8 @@
* Tools page functionality
*/
import { FuzzySearch, type SearchableItem } from "../search";
import { fetchData, debounce, escapeHtml } from "../utils";
import { fetchData, debounce } from "../utils";
import { renderToolsHtml } from "./tools-render";
export interface Tool extends SearchableItem {
id: string;
@@ -40,15 +41,13 @@ interface ToolsData {
}
let allItems: Tool[] = [];
let search: FuzzySearch<Tool>;
let search = new FuzzySearch<Tool>();
let currentFilters = {
categories: [] as string[],
query: "",
};
function formatMultilineText(text: string): string {
return escapeHtml(text).replace(/\r?\n/g, "<br>");
}
let copyHandlersReady = false;
let initialized = false;
function applyFiltersAndRender(): void {
const searchInput = document.getElementById(
@@ -78,182 +77,50 @@ function applyFiltersAndRender(): void {
function renderTools(tools: Tool[], query = ""): void {
const container = document.getElementById("tools-list");
if (!container) return;
if (tools.length === 0) {
container.innerHTML = `
<div class="empty-state">
<h3>No tools found</h3>
<p>Try a different search term or adjust filters</p>
</div>
`;
return;
}
container.innerHTML = tools
.map((tool) => {
const badges: string[] = [];
if (tool.featured) {
badges.push('<span class="tool-badge featured">Featured</span>');
}
badges.push(
`<span class="tool-badge category">${escapeHtml(tool.category)}</span>`
);
const features =
tool.features && tool.features.length > 0
? `<div class="tool-section">
<h3>Features</h3>
<ul>${tool.features
.map((f) => `<li>${escapeHtml(f)}</li>`)
.join("")}</ul>
</div>`
: "";
const requirements =
tool.requirements && tool.requirements.length > 0
? `<div class="tool-section">
<h3>Requirements</h3>
<ul>${tool.requirements
.map((r) => `<li>${escapeHtml(r)}</li>`)
.join("")}</ul>
</div>`
: "";
const tags =
tool.tags && tool.tags.length > 0
? `<div class="tool-tags">
${tool.tags
.map((t) => `<span class="tool-tag">${escapeHtml(t)}</span>`)
.join("")}
</div>`
: "";
const config = tool.configuration
? `<div class="tool-config">
<h3>Configuration</h3>
<div class="tool-config-wrapper">
<pre><code>${escapeHtml(tool.configuration.content)}</code></pre>
</div>
<button class="copy-config-btn" data-config="${encodeURIComponent(
tool.configuration.content
)}">
<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="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"/>
</svg>
Copy Configuration
</button>
</div>`
: "";
const actions: string[] = [];
if (tool.links.blog) {
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.npm) {
actions.push(
`<a href="${tool.links.npm}" class="btn btn-secondary" target="_blank" rel="noopener">📦 npm</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.documentation) {
actions.push(
`<a href="${tool.links.documentation}" class="btn btn-secondary" target="_blank" rel="noopener">📚 Docs</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
? `<div class="tool-actions">${actions.join("")}</div>`
: "";
const titleHtml = query
? search.highlight(tool.name, query)
: escapeHtml(tool.name);
const descriptionHtml = formatMultilineText(tool.description);
return `
<div class="tool-card">
<div class="tool-header">
<h2>${titleHtml}</h2>
<div class="tool-badges">
${badges.join("")}
</div>
</div>
<p class="tool-description">${descriptionHtml}</p>
${features}
${requirements}
${config}
${tags}
${actionsHtml}
</div>
`;
})
.join("");
setupCopyConfigHandlers();
}
function setupCopyConfigHandlers(): void {
document.querySelectorAll(".copy-config-btn").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.stopPropagation();
const button = e.currentTarget as HTMLButtonElement;
const config = decodeURIComponent(button.dataset.config || "");
try {
await navigator.clipboard.writeText(config);
button.classList.add("copied");
const originalHtml = button.innerHTML;
button.innerHTML = `
<svg width="14" height="14" viewBox="0 0 16 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>
Copied!
`;
setTimeout(() => {
button.classList.remove("copied");
button.innerHTML = originalHtml;
}, 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
});
container.innerHTML = renderToolsHtml(tools, {
query,
highlightTitle: (title, highlightQuery) =>
search.highlight(title, highlightQuery),
});
}
function setupCopyConfigHandlers(): void {
if (copyHandlersReady) return;
document.addEventListener("click", async (event) => {
const button = (event.target as HTMLElement).closest(
".copy-config-btn"
) as HTMLButtonElement | null;
if (!button) return;
event.stopPropagation();
const config = decodeURIComponent(button.dataset.config || "");
try {
await navigator.clipboard.writeText(config);
button.classList.add("copied");
const originalHtml = button.innerHTML;
button.innerHTML = `
<svg width="14" height="14" viewBox="0 0 16 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>
Copied!
`;
setTimeout(() => {
button.classList.remove("copied");
button.innerHTML = originalHtml;
}, 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
});
copyHandlersReady = true;
}
export async function initToolsPage(): Promise<void> {
const container = document.getElementById("tools-list");
if (initialized) return;
initialized = true;
const searchInput = document.getElementById(
"search-input"
) as HTMLInputElement;
@@ -262,12 +129,9 @@ export async function initToolsPage(): Promise<void> {
) as HTMLSelectElement;
const clearFiltersBtn = document.getElementById("clear-filters");
if (container) {
container.innerHTML = '<div class="loading">Loading tools...</div>';
}
const data = await fetchData<ToolsData>("tools.json");
if (!data || !data.items) {
const container = document.getElementById("tools-list");
if (container)
container.innerHTML =
'<div class="empty-state"><h3>Failed to load tools</h3></div>';
@@ -289,7 +153,7 @@ export async function initToolsPage(): Promise<void> {
'<option value="">All Categories</option>' +
data.filters.categories
.map(
(c) => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`
(c) => `<option value="${c}">${c}</option>`
)
.join("");
@@ -315,7 +179,7 @@ export async function initToolsPage(): Promise<void> {
applyFiltersAndRender();
});
applyFiltersAndRender();
setupCopyConfigHandlers();
}
// Auto-initialize when DOM is ready

View File

@@ -0,0 +1,76 @@
import {
escapeHtml,
getActionButtonsHtml,
getGitHubUrl,
getLastUpdatedHtml,
} from '../utils';
export interface RenderableWorkflow {
title: string;
description?: string;
path: string;
triggers: string[];
lastUpdated?: string | null;
}
export type WorkflowSortOption = 'title' | 'lastUpdated';
export function sortWorkflows<T extends RenderableWorkflow>(
items: T[],
sort: WorkflowSortOption
): T[] {
return [...items].sort((a, b) => {
if (sort === '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);
});
}
export function renderWorkflowsHtml(
items: RenderableWorkflow[],
options: {
query?: string;
highlightTitle?: (title: string, query: string) => string;
} = {}
): string {
const { query = '', highlightTitle } = options;
if (items.length === 0) {
return `
<div class="empty-state">
<h3>No workflows found</h3>
<p>Try a different search term or adjust filters</p>
</div>
`;
}
return items
.map((item) => {
const titleHtml =
query && highlightTitle
? highlightTitle(item.title, query)
: escapeHtml(item.title);
return `
<div class="resource-item" data-path="${escapeHtml(item.path)}">
<div class="resource-info">
<div class="resource-title">${titleHtml}</div>
<div class="resource-description">${escapeHtml(item.description || 'No description')}</div>
<div class="resource-meta">
${item.triggers.map((trigger) => `<span class="resource-tag tag-trigger">${escapeHtml(trigger)}</span>`).join('')}
${getLastUpdatedHtml(item.lastUpdated)}
</div>
</div>
<div class="resource-actions">
${getActionButtonsHtml(item.path)}
<a href="${getGitHubUrl(item.path)}" class="btn btn-secondary" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">GitHub</a>
</div>
</div>
`;
})
.join('');
}

View File

@@ -6,15 +6,17 @@ import { FuzzySearch, type SearchItem } from "../search";
import {
fetchData,
debounce,
escapeHtml,
getGitHubUrl,
getActionButtonsHtml,
setupActionHandlers,
getLastUpdatedHtml,
} from "../utils";
import { setupModal, openFileModal } from "../modal";
import {
renderWorkflowsHtml,
sortWorkflows,
type RenderableWorkflow,
type WorkflowSortOption,
} from "./workflows-render";
interface Workflow extends SearchItem {
interface Workflow extends SearchItem, RenderableWorkflow {
id: string;
path: string;
triggers: string[];
@@ -28,8 +30,6 @@ interface WorkflowsData {
};
}
type SortOption = "title" | "lastUpdated";
const resourceType = "workflow";
let allItems: Workflow[] = [];
let search = new FuzzySearch<Workflow>();
@@ -37,17 +37,11 @@ let triggerSelect: Choices;
let currentFilters = {
triggers: [] as string[],
};
let currentSort: SortOption = "title";
let currentSort: WorkflowSortOption = "title";
let resourceListHandlersReady = false;
function sortItems(items: Workflow[]): Workflow[] {
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);
});
return sortWorkflows(items, currentSort);
}
function applyFiltersAndRender(): void {
@@ -86,54 +80,32 @@ function renderItems(items: Workflow[], query = ""): void {
const list = document.getElementById("resource-list");
if (!list) return;
if (items.length === 0) {
list.innerHTML =
'<div class="empty-state"><h3>No workflows found</h3><p>Try a different search term or adjust filters</p></div>';
return;
}
list.innerHTML = items
.map(
(item) => `
<div class="resource-item" data-path="${escapeHtml(item.path)}">
<div class="resource-info">
<div class="resource-title">${
query ? search.highlight(item.title, query) : escapeHtml(item.title)
}</div>
<div class="resource-description">${escapeHtml(
item.description || "No description"
)}</div>
<div class="resource-meta">
${item.triggers
.map(
(t) =>
`<span class="resource-tag tag-trigger">${escapeHtml(t)}</span>`
)
.join("")}
${getLastUpdatedHtml(item.lastUpdated)}
</div>
</div>
<div class="resource-actions">
${getActionButtonsHtml(item.path)}
<a href="${getGitHubUrl(
item.path
)}" class="btn btn-secondary" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">GitHub</a>
</div>
</div>
`
)
.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);
});
list.innerHTML = renderWorkflowsHtml(items, {
query,
highlightTitle: (title, highlightQuery) =>
search.highlight(title, highlightQuery),
});
}
function setupResourceListHandlers(list: HTMLElement | null): void {
if (!list || resourceListHandlersReady) return;
list.addEventListener("click", (event) => {
const target = event.target as HTMLElement;
if (target.closest(".resource-actions")) {
return;
}
const item = target.closest(".resource-item") as HTMLElement | null;
const path = item?.dataset.path;
if (path) {
openFileModal(path, resourceType);
}
});
resourceListHandlersReady = true;
}
export async function initWorkflowsPage(): Promise<void> {
const list = document.getElementById("resource-list");
const searchInput = document.getElementById(
@@ -144,6 +116,8 @@ export async function initWorkflowsPage(): Promise<void> {
"sort-select"
) as HTMLSelectElement;
setupResourceListHandlers(list as HTMLElement | null);
const data = await fetchData<WorkflowsData>("workflows.json");
if (!data || !data.items) {
if (list)
@@ -171,11 +145,15 @@ export async function initWorkflowsPage(): Promise<void> {
});
sortSelect?.addEventListener("change", () => {
currentSort = sortSelect.value as SortOption;
currentSort = sortSelect.value as WorkflowSortOption;
applyFiltersAndRender();
});
applyFiltersAndRender();
const countEl = document.getElementById("results-count");
if (countEl) {
countEl.textContent = `${allItems.length} of ${allItems.length} workflows`;
}
searchInput?.addEventListener(
"input",
debounce(() => applyFiltersAndRender(), 200)

View File

@@ -1,3 +1,5 @@
import { getEmbeddedData as getEmbeddedPageData } from "./embedded-data";
/**
* Utility functions for the Awesome Copilot website
*/
@@ -43,6 +45,9 @@ export function getBasePath(): string {
export async function fetchData<T = unknown>(
filename: string
): Promise<T | null> {
const embeddedData = getEmbeddedPageData<T>(filename);
if (embeddedData !== null) return embeddedData;
try {
const basePath = getBasePath();
const response = await fetch(`${basePath}data/${filename}`);
@@ -54,6 +59,17 @@ export async function fetchData<T = unknown>(
}
}
let jsZipPromise: Promise<typeof import("./jszip")> | null = null;
/**
* Lazy-load JSZip only when downloads are requested
*/
export async function loadJSZip() {
jsZipPromise ??= import("./jszip");
const { default: JSZip } = await jsZipPromise;
return JSZip;
}
/**
* Fetch raw file content from GitHub
*/
@@ -209,9 +225,12 @@ export function debounce<T extends (...args: unknown[]) => void>(
* Escape HTML to prevent XSS
*/
export function escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/**
@@ -246,10 +265,8 @@ export function truncate(text: string | undefined, maxLength: number): string {
export function getResourceType(filePath: string): string {
if (filePath.endsWith(".agent.md")) return "agent";
if (filePath.endsWith(".instructions.md")) return "instruction";
if (/(^|\/)skills\//.test(filePath) && filePath.endsWith("SKILL.md"))
return "skill";
if (/(^|\/)hooks\//.test(filePath) && filePath.endsWith("README.md"))
return "hook";
if (/(^|\/)skills\//.test(filePath)) return "skill";
if (/(^|\/)hooks\//.test(filePath)) return "hook";
if (/(^|\/)workflows\//.test(filePath) && filePath.endsWith(".md"))
return "workflow";
// Check for plugin directories (e.g., plugins/<id>, plugins/<id>/)

View File

@@ -9,7 +9,7 @@
/* Nerd Fonts for programming language icons */
@font-face {
font-family: 'Monaspace Argon NF';
src: url('../fonts/MonaspaceArgonNF-Regular.woff2') format('woff2');
src: url('../../public/fonts/MonaspaceArgonNF-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
@@ -740,7 +740,7 @@ body:has(#main-content) {
padding: 24px;
}
.modal.hidden {
.modal.hidden, .hidden {
display: none;
}
@@ -759,27 +759,165 @@ body:has(#main-content) {
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: column;
gap: 14px;
padding: 20px 24px;
border-bottom: 1px solid var(--color-glass-border);
background: var(--color-glass);
}
.modal-header h3 {
.modal-header-top {
gap: 16px;
min-width: 0;
}
.modal-header-top h3 {
font-size: 16px;
font-weight: 600;
color: var(--color-text-emphasis);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1;
}
.modal-file-switcher {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.modal-file-switcher.hidden {
display: none;
}
.modal-file-switcher label {
font-size: 12px;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.modal-file-switcher-label {
font-size: 12px;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.modal-file-dropdown {
position: relative;
display: inline-flex;
min-width: min(320px, 100%);
max-width: min(420px, 100%);
flex: 1;
}
.modal-file-button {
justify-content: flex-start;
min-width: 0;
flex: 1;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: 1px solid var(--color-border);
}
.modal-file-button span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modal-file-toggle {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding: 10px;
min-width: auto;
}
.modal-file-toggle svg {
transition: transform var(--transition);
}
.modal-file-dropdown.open .modal-file-toggle svg {
transform: rotate(180deg);
}
.modal-file-menu {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
box-shadow: var(--shadow-md);
z-index: 1000;
opacity: 0;
visibility: hidden;
transform: translateY(-8px);
transition: all var(--transition);
max-height: 280px;
overflow-y: auto;
}
.modal-file-dropdown.open .modal-file-menu {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.modal-file-menu-item {
width: 100%;
display: block;
padding: 10px 14px;
text-align: left;
background: transparent;
border: none;
color: var(--color-text);
font-size: 13px;
cursor: pointer;
transition: background var(--transition);
margin: 0;
}
.modal-file-menu-item:hover,
.modal-file-menu-item:focus-visible {
background: var(--color-bg-tertiary);
outline: none;
}
.modal-file-menu-item.active {
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
color: var(--color-text-emphasis);
font-weight: 600;
}
.modal-file-menu-item:first-child {
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
.modal-file-menu-item:last-child {
border-radius: 0 0 var(--border-radius) var(--border-radius);
}
.modal-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
.modal-actions button, .modal-actions div {
margin: 0;
}
.modal-body {
flex: 1;
overflow: auto;
@@ -799,6 +937,29 @@ body:has(#main-content) {
min-height: 200px;
}
.modal-rendered-content {
padding: 24px;
min-height: 200px;
background: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
}
.modal-rendered-content > :first-child {
margin-top: 0;
}
.modal-rendered-content > :last-child {
margin-bottom: 0;
}
.modal-code-content .shiki {
margin: 0 !important;
padding: 24px;
min-height: 200px;
border-radius: 0;
}
/* Collection Modal View */
.collection-view {
padding: 20px 24px;
@@ -967,25 +1128,28 @@ body:has(#main-content) {
/* Page Layouts */
.page-header {
padding: 56px 0 40px;
padding: 40px 0 28px;
border-bottom: 1px solid var(--color-glass-border);
}
.page-header h1 {
font-size: 36px;
font-size: clamp(2rem, 4vw, 2.25rem);
font-weight: 800;
color: var(--color-text);
margin-bottom: 12px;
margin-bottom: 8px;
}
.page-header p {
font-size: 17px;
font-size: 16px;
color: var(--color-text-muted);
line-height: 1.55;
margin: 0;
max-width: 42rem;
}
.page-header-row {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 24px;
}
@@ -999,9 +1163,9 @@ body:has(#main-content) {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 6px;
padding: 8px 16px;
font-size: 14px;
margin-top: 0;
padding: 8px 14px;
font-size: 13px;
font-weight: 600;
color: var(--color-accent);
border: 1px solid var(--color-accent);
@@ -1027,23 +1191,36 @@ body:has(#main-content) {
}
.page-content {
padding: 40px 0 80px;
padding: 24px 0 80px;
}
/* Search and Filter Bar */
.search-bar {
.listing-toolbar {
display: flex;
gap: 16px;
margin-bottom: 24px;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding: 10px 14px;
background: var(--color-glass);
backdrop-filter: blur(10px);
border: 1px solid var(--color-glass-border);
border-radius: var(--border-radius-lg);
position: relative;
z-index: 10;
}
.search-bar {
display: contents;
}
.search-bar input {
flex: 1;
min-width: 250px;
padding: 14px 18px;
font-size: 15px;
background: var(--color-glass);
flex: 1 1 220px;
min-width: 220px;
padding: 9px 14px;
font-size: 13px;
margin: 0;
background: var(--color-bg);
border: 1px solid var(--color-glass-border);
border-radius: var(--border-radius-lg);
color: var(--color-text);
@@ -1059,42 +1236,30 @@ body:has(#main-content) {
/* Filters Bar */
.filters-bar {
display: flex;
gap: 16px;
margin-bottom: 24px;
flex-wrap: wrap;
align-items: center;
padding: 18px 20px;
background: var(--color-glass);
backdrop-filter: blur(10px);
border: 1px solid var(--color-glass-border);
border-radius: var(--border-radius-lg);
position: relative;
z-index: 10;
display: contents;
}
.filter-group {
display: flex;
align-items: center;
gap: 10px;
gap: 6px;
margin: 0px;
}
.filter-group label {
font-size: 13px;
font-weight: 500;
color: var(--color-text-muted);
white-space: nowrap;
.filters-bar .filter-group > label:not(.checkbox-label) {
display: none;
}
.filter-group select {
padding: 8px 14px;
padding: 6px 10px;
font-size: 13px;
background: var(--color-bg);
border: 1px solid var(--color-glass-border);
border-radius: var(--border-radius);
color: var(--color-text);
min-width: 150px;
min-width: 130px;
cursor: pointer;
margin: 0px;
}
.filter-group select:focus {
@@ -1103,6 +1268,10 @@ body:has(#main-content) {
border-color: var(--color-accent);
}
.filters-bar button {
margin: 0px;
}
.checkbox-label {
display: flex;
align-items: center;
@@ -1122,13 +1291,14 @@ body:has(#main-content) {
}
.btn-small {
padding: 8px 14px;
font-size: 13px;
padding: 6px 10px;
font-size: 12px;
}
/* Choices.js Theme Overrides */
.filter-group .choices {
min-width: 200px;
min-width: 170px;
margin-top: 0;
}
.choices {
@@ -1138,17 +1308,17 @@ body:has(#main-content) {
.choices__inner {
background-color: var(--color-bg);
border-color: var(--color-border);
border-radius: var(--border-radius);
min-height: 42px;
padding: 6px 10px;
font-size: 14px;
border-radius: var(--border-radius-lg) !important;
min-height: 38px;
padding: 4px 8px;
font-size: 13px;
}
.choices__input {
background-color: transparent;
color: var(--color-text);
font-size: 14px;
padding: 4px 0;
font-size: 13px;
padding: 2px 0;
}
.choices__input::placeholder {
@@ -1266,10 +1436,16 @@ body:has(#main-content) {
.results-count {
font-size: 14px;
color: var(--color-text-muted);
margin-bottom: 20px;
margin-bottom: 16px;
font-weight: 500;
}
@media (max-width: 760px) {
.listing-toolbar {
align-items: stretch;
}
}
/* Resource List */
.resource-list {
display: flex;
@@ -1361,6 +1537,10 @@ body:has(#main-content) {
flex-shrink: 0;
}
.resource-actions button {
margin: 0px;
}
/* Last Updated */
.last-updated {
font-size: 12px;