chore: publish from main

This commit is contained in:
github-actions[bot]
2026-07-10 03:24:21 +00:00
parent 9a8e446f02
commit 51b6fa0253
56 changed files with 4496 additions and 992 deletions
+20 -10
View File
@@ -15,8 +15,10 @@ import {
escapeHtml,
getResourceIconSvg,
sanitizeUrl,
isSafeRepoFilePath,
REPO_IDENTIFIER,
} from "./utils";
import { externalRepoUrl } from "../lib/external-source";
type ModalViewMode = "rendered" | "raw";
@@ -431,6 +433,8 @@ interface PluginSource {
source: string;
repo?: string;
path?: string;
ref?: string;
sha?: string;
}
interface Plugin {
@@ -770,8 +774,19 @@ function handleHashChange(): void {
const hash = window.location.hash;
if (hash && hash.startsWith("#file=")) {
const filePath = decodeURIComponent(hash.slice(6));
if (filePath && filePath !== currentFilePath) {
let filePath: string | null = null;
try {
filePath = decodeURIComponent(hash.slice(6));
} catch {
filePath = null;
}
// Ignore malformed or traversal (`../`) paths to prevent client-side path
// traversal from loading arbitrary content into the modal.
if (
filePath &&
isSafeRepoFilePath(filePath) &&
filePath !== currentFilePath
) {
const type = getResourceType(filePath);
openFileModal(filePath, type, false); // Don't update hash since we're responding to it
}
@@ -1162,14 +1177,9 @@ async function openPluginModal(
* Get the best URL for an external plugin, preferring the deep path within the repo
*/
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 && 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);
// Sanitize URLs from JSON to prevent XSS via javascript:/data: schemes and
// pin GitHub links to the source's ref/sha when available.
return externalRepoUrl(plugin.source, [plugin.repository, plugin.homepage]);
}
/**
+16 -2
View File
@@ -1,6 +1,5 @@
import {
escapeHtml,
getActionButtonsHtml,
getGitHubUrl,
getInstallDropdownHtml,
getLastUpdatedHtml,
@@ -8,6 +7,7 @@ import {
import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render";
export interface RenderableAgent {
id: string;
title: string;
description?: string;
path: string;
@@ -21,6 +21,13 @@ export type AgentSortOption = "title" | "lastUpdated";
const resourceType = "agent";
/**
* Build the URL of an agent's dedicated detail page.
*/
export function getAgentDetailUrl(id: string): string {
return `/agent/${id}/`;
}
export function sortAgents<T extends RenderableAgent>(
items: T[],
sort: AgentSortOption
@@ -72,7 +79,13 @@ export function renderAgentsHtml(items: RenderableAgent[]): string {
const actionsHtml = `
${getInstallDropdownHtml(resourceType, item.path, true)}
${getActionButtonsHtml(item.path, true)}
<button class="btn btn-secondary btn-small action-download" data-path="${escapeHtml(
item.path
)}" title="Download file">
<svg viewBox="0 0 16 16" width="14" height="14" 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>
</button>
<a href="${getGitHubUrl(item.path)}" class="btn btn-secondary btn-small" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">
GitHub
</a>
@@ -81,6 +94,7 @@ export function renderAgentsHtml(items: RenderableAgent[]): string {
return renderSharedCardHtml({
title: item.title,
description: item.description || "No description",
href: getAgentDetailUrl(item.id),
articleAttributes: {
"data-path": item.path,
},
-100
View File
@@ -2,16 +2,12 @@
* Agents page functionality
*/
import {
escapeHtml,
fetchData,
formatRelativeTime,
getQueryParam,
getVSCodeInstallUrl,
setupActionHandlers,
setupDropdownCloseHandlers,
updateQueryParams,
} from '../utils';
import { openCardDetailsModal, setupModal } from '../modal';
import {
renderAgentsHtml,
sortAgents,
@@ -20,7 +16,6 @@ import {
} from './agents-render';
interface Agent extends RenderableAgent {
id?: string;
lastUpdated?: string | null;
}
@@ -29,10 +24,7 @@ interface AgentsData {
}
let allItems: Agent[] = [];
let agentByPath = new Map<string, Agent>();
let currentSort: AgentSortOption = 'title';
let resourceListHandlersReady = false;
let modalReady = false;
function applyFiltersAndRender(): void {
const countEl = document.getElementById('results-count');
@@ -51,90 +43,6 @@ function renderItems(items: Agent[]): void {
list.innerHTML = renderAgentsHtml(items);
}
function openAgentDetailsModal(path: string, trigger?: HTMLElement): void {
const item = agentByPath.get(path);
if (!item) {
return;
}
const metaParts: string[] = [];
if (item.model) {
metaParts.push(
`<span class="resource-tag tag-model">${escapeHtml(
Array.isArray(item.model) ? item.model.join(', ') : item.model
)}</span>`
);
}
if (item.hasHandoffs) {
metaParts.push('<span class="resource-tag tag-handoffs">handoffs</span>');
}
if (item.lastUpdated) {
metaParts.push(
`<span class="last-updated">Updated ${escapeHtml(
formatRelativeTime(item.lastUpdated)
)}</span>`
);
}
const toolItems = item.tools || [];
const displayTools = toolItems.slice(0, 24);
const tagParts = displayTools.map(
(tool) => `<span class="resource-tag">${escapeHtml(tool)}</span>`
);
if (toolItems.length > displayTools.length) {
tagParts.push(
`<span class="resource-tag">+${toolItems.length - displayTools.length} more</span>`
);
}
const vscodeUrl = getVSCodeInstallUrl('agent', path, false);
const insidersUrl = getVSCodeInstallUrl('agent', path, true);
const actions = [
vscodeUrl
? `<a class="btn btn-primary btn-small" href="${escapeHtml(vscodeUrl)}" target="_blank" rel="noopener noreferrer">Install (VS Code)</a>`
: '',
insidersUrl
? `<a class="btn btn-secondary btn-small" href="${escapeHtml(insidersUrl)}" target="_blank" rel="noopener noreferrer">Install (Insiders)</a>`
: '',
`<button class="btn btn-secondary btn-small" type="button" data-open-file-path="${escapeHtml(
path
)}" data-open-file-type="agent">Source</button>`,
].filter(Boolean);
openCardDetailsModal({
title: item.title,
description: item.description || 'No description',
previewIcon: '🤖',
previewText: 'Agent metadata and install options',
metaHtml: metaParts.join(''),
tagsHtml: tagParts.join(''),
actionsHtml: actions.join(''),
trigger,
});
}
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 button = item?.querySelector('.resource-preview') as HTMLElement | undefined;
const path = item?.dataset.path;
if (path) {
openAgentDetailsModal(path, button);
}
});
resourceListHandlersReady = true;
}
function syncUrlState(): void {
updateQueryParams({
q: '',
@@ -149,13 +57,6 @@ export async function initAgentsPage(): Promise<void> {
const list = document.getElementById('resource-list');
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
if (!modalReady) {
setupModal();
modalReady = true;
}
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>';
@@ -163,7 +64,6 @@ export async function initAgentsPage(): Promise<void> {
}
allItems = data.items;
agentByPath = new Map(allItems.map((item) => [item.path, item]));
const initialSort = getQueryParam('sort');
if (initialSort === 'lastUpdated') {
+18 -5
View File
@@ -11,6 +11,12 @@ export interface SharedCardRenderItem {
infoExtraHtml?: string;
metaHtml?: string;
actionsHtml?: string;
/**
* When provided, the card preview renders as a link to a dedicated detail
* page instead of a button that opens a modal. This enables real URL deep
* linking and native open-in-new-tab behaviour.
*/
href?: string;
}
function renderAttributes(attributes?: Record<string, string>): string {
@@ -35,9 +41,7 @@ export function renderSharedCardHtml(item: SharedCardRenderItem): string {
? `resource-item ${item.articleClassName}`
: "resource-item";
return `
<div class="${articleClass}" role="${escapeHtml(role)}"${item.tabIndex !== undefined ? ` tabindex="${String(item.tabIndex)}"` : ""}${renderAttributes(item.articleAttributes)}>
<button type="button" class="resource-preview">
const previewInner = `
${item.previewMediaHtml || ""}
<div class="resource-info">
<div class="resource-title">${escapeHtml(item.title)}</div>
@@ -46,8 +50,17 @@ export function renderSharedCardHtml(item: SharedCardRenderItem): string {
<div class="resource-meta">
${item.metaHtml || ""}
</div>
</div>
</button>
</div>`;
const preview = item.href
? `<a class="resource-preview" href="${escapeHtml(item.href)}">${previewInner}
</a>`
: `<button type="button" class="resource-preview">${previewInner}
</button>`;
return `
<div class="${articleClass}" role="${escapeHtml(role)}"${item.tabIndex !== undefined ? ` tabindex="${String(item.tabIndex)}"` : ""}${renderAttributes(item.articleAttributes)}>
${preview}
${item.actionsHtml ? `<div class="resource-actions">${item.actionsHtml}</div>` : ""}
</div>
`;
@@ -0,0 +1,41 @@
/**
* Extension detail page image gallery.
* Switches the main preview image when a thumbnail is selected.
*/
function initExtensionGallery(): void {
const mainImage = document.getElementById(
"extension-gallery-image"
) as HTMLImageElement | null;
const thumbs = document.querySelectorAll<HTMLButtonElement>(
".extension-gallery-thumb"
);
if (!mainImage || thumbs.length === 0) return;
thumbs.forEach((thumb) => {
thumb.addEventListener("click", () => {
const url = thumb.dataset.galleryUrl;
if (!url) return;
mainImage.src = url;
thumbs.forEach((other) => {
const isActive = other === thumb;
other.classList.toggle("active", isActive);
if (isActive) {
other.setAttribute("aria-current", "true");
} else {
other.removeAttribute("aria-current");
}
});
});
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initExtensionGallery, {
once: true,
});
} else {
initExtensionGallery();
}
+45 -22
View File
@@ -4,8 +4,16 @@ import {
getGitHubUrl,
getLastUpdatedHtml,
} from "../utils";
import { sanitizeHttpUrl } from "../../lib/external-source";
import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render";
// Allow only http(s) URLs from external/generated data; unsafe values collapse
// to "" so downstream truthiness guards (disabled buttons, omitted links) hold.
function safeUrl(value?: string | null): string {
const sanitized = sanitizeHttpUrl(value);
return sanitized === "#" ? "" : sanitized;
}
export interface RenderableExtension {
id: string;
canvasId?: string;
@@ -46,6 +54,10 @@ export interface RenderableExtension {
export type ExtensionSortOption = "title" | "lastUpdated";
export function getExtensionDetailUrl(id: string): string {
return `/extension/${id}/`;
}
export function sortExtensions<T extends RenderableExtension>(
items: T[],
sort: ExtensionSortOption
@@ -71,23 +83,30 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string {
return items
.map((item) => {
const installUrl =
const installUrl = safeUrl(
item.installUrl ||
(item.path && item.ref
? `https://github.com/github/awesome-copilot/tree/${item.ref}/${item.path.replace(
/\\/g,
"/"
)}`
: "");
const installCommand =
item.installCommand ||
(item.pluginName ? `copilot plugin install ${item.pluginName}@awesome-copilot` : "");
const sourceUrl =
item.sourceUrl || (item.path ? getGitHubUrl(item.path) : "");
(item.path && item.ref
? `https://github.com/github/awesome-copilot/tree/${item.ref}/${item.path.replace(
/\\/g,
"/"
)}`
: "")
);
const sourceUrl = safeUrl(
item.sourceUrl || (item.path ? getGitHubUrl(item.path) : "")
);
const pluginId = item.pluginName || item.id;
const ghappInstallUrl =
!item.external && pluginId
? `ghapp://plugins/install?source=${encodeURIComponent(
`${pluginId}@awesome-copilot`
)}`
: "";
const previewMediaHtml = item.imageUrl
? `<div class="resource-thumbnail-btn" data-extension-id="${escapeHtml(item.id)}" aria-hidden="true">
<img class="resource-thumbnail" src="${escapeHtml(item.imageUrl)}" alt="${escapeHtml(item.name)} preview" loading="lazy" />
const previewImageUrl = safeUrl(item.imageUrl);
const previewMediaHtml = previewImageUrl
? `<div class="resource-thumbnail-btn" aria-hidden="true">
<img class="resource-thumbnail" src="${escapeHtml(previewImageUrl)}" alt="${escapeHtml(item.name)} preview" loading="lazy" />
</div>`
: `<div class="resource-thumbnail resource-thumbnail-placeholder" aria-hidden="true">Canvas</div>`;
@@ -122,14 +141,17 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string {
`;
const actionsHtml = `
<button
class="btn btn-primary btn-small copy-install-url-btn"
data-install-command="${escapeHtml(installCommand)}"
title="Copy plugin install command"
${installCommand ? "" : "disabled"}
${
ghappInstallUrl
? `<a
class="btn btn-primary btn-small"
href="${escapeHtml(ghappInstallUrl)}"
title="Install in the GitHub Copilot app"
>
Copy Install
</button>
Install in Copilot app
</a>`
: ""
}
<button
class="btn btn-secondary btn-small copy-install-url-btn"
data-install-url="${escapeHtml(installUrl)}"
@@ -150,6 +172,7 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string {
return renderSharedCardHtml({
title: item.name,
description: item.description || "Canvas extension",
href: getExtensionDetailUrl(item.id),
previewMediaHtml,
infoExtraHtml,
metaHtml,
+1 -326
View File
@@ -8,19 +8,13 @@ import {
type Choices,
} from "../choices";
import {
escapeHtml,
copyToClipboard,
fetchData,
formatRelativeTime,
getGitHubHandle,
getGitHubUrl,
getQueryParam,
getQueryParamValues,
sanitizeUrl,
showToast,
updateQueryParams,
} from "../utils";
import { openCardDetailsModal, setupModal } from "../modal";
import {
renderExtensionsHtml,
sortExtensions,
@@ -40,229 +34,13 @@ interface ExtensionsData {
};
}
interface ExtensionScreenshot {
path?: string | null;
type?: string | null;
}
let allItems: Extension[] = [];
let extensionById = new Map<string, Extension>();
let currentSort: ExtensionSortOption = "title";
let keywordSelect: Choices;
let currentFilters = {
keywords: [] as string[],
};
let actionHandlersReady = false;
let modalReady = false;
function normalizeScreenshotEntries(value: unknown): ExtensionScreenshot[] {
if (!value) return [];
if (Array.isArray(value)) {
return value.filter((entry): entry is ExtensionScreenshot => Boolean(entry));
}
if (typeof value === "object") {
return [value as ExtensionScreenshot];
}
return [];
}
function getInstallUrl(item: Extension): string {
return (
item.installUrl ||
(item.path && item.ref
? `https://github.com/github/awesome-copilot/tree/${item.ref}/${item.path.replace(
/\\/g,
"/"
)}`
: "")
);
}
function getInstallCommand(item: Extension): string {
return (
item.installCommand ||
(item.pluginName ? `copilot plugin install ${item.pluginName}@awesome-copilot` : "")
);
}
function getSourceUrl(item: Extension): string {
return item.sourceUrl || (item.path ? getGitHubUrl(item.path) : "");
}
function toRawAssetUrl(item: Extension, assetPath: string | null | undefined): string {
if (!assetPath || !item.ref) return "";
if (/^https?:\/\//i.test(assetPath)) return assetPath;
return `https://raw.githubusercontent.com/github/awesome-copilot/${item.ref}/${assetPath.replace(
/\\/g,
"/"
)}`;
}
function getGalleryImages(item: Extension): string[] {
const images: string[] = [];
if (item.imageUrl) {
images.push(item.imageUrl);
}
const iconPath = item.screenshots?.icon?.path;
if (iconPath) {
const url = toRawAssetUrl(item, iconPath);
if (url) images.push(url);
}
const galleryPaths = normalizeScreenshotEntries(item.screenshots?.gallery);
for (const entry of galleryPaths) {
const url = toRawAssetUrl(item, entry.path);
if (url) images.push(url);
}
return Array.from(new Set(images));
}
function renderGalleryThumbnails(images: string[], selectedUrl: string): void {
const gallery = document.getElementById("extension-details-gallery");
if (!gallery) return;
gallery.innerHTML = "";
images.forEach((url, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "extension-details-thumbnail-btn";
button.dataset.galleryImageUrl = url;
button.setAttribute("aria-label", `Show image ${index + 1}`);
button.setAttribute("role", "listitem");
if (url === selectedUrl) {
button.classList.add("active");
button.setAttribute("aria-current", "true");
}
const image = document.createElement("img");
image.src = url;
image.alt = `Gallery image ${index + 1}`;
image.className = "extension-details-thumbnail";
image.loading = "lazy";
button.appendChild(image);
gallery.appendChild(button);
});
}
function setSelectedGalleryImage(url: string, extensionName: string): void {
const image = document.getElementById(
"extension-details-image"
) as HTMLImageElement | null;
const gallery = document.getElementById("extension-details-gallery");
if (!image) return;
image.src = url;
image.alt = `${extensionName} screenshot`;
gallery?.querySelectorAll<HTMLButtonElement>(".extension-details-thumbnail-btn").forEach((button) => {
const isActive = button.dataset.galleryImageUrl === url;
button.classList.toggle("active", isActive);
if (isActive) {
button.setAttribute("aria-current", "true");
} else {
button.removeAttribute("aria-current");
}
});
}
function openDetailsModal(
extensionId: string,
preferredImageUrl?: string,
trigger?: HTMLElement
): void {
const item = extensionById.get(extensionId);
if (!item) {
return;
}
const keywordHtml = (item.keywords || [])
.map((keyword) => `<span class="keyword-tag">${escapeHtml(keyword)}</span>`)
.join("");
const metaParts: string[] = [];
if (item.external) {
metaParts.push('<span class="resource-tag">External</span>');
}
if (item.author?.name) {
const authorName = item.author.name;
const authorUrl = item.author.url;
const authorHandle = authorUrl
? getGitHubHandle(authorUrl, authorName)
: authorName;
metaParts.push(
authorUrl
? `<span class="resource-author">by <a href="${escapeHtml(
sanitizeUrl(authorUrl)
)}" target="_blank" rel="noopener noreferrer" title="${escapeHtml(
authorName
)}">${escapeHtml(authorHandle)}</a></span>`
: `<span class="resource-author">by ${escapeHtml(
authorName
)}</span>`
);
}
if (item.lastUpdated) {
metaParts.push(
`<span class="last-updated">Updated ${escapeHtml(
formatRelativeTime(item.lastUpdated)
)}</span>`
);
}
const installUrl = getInstallUrl(item);
const installCommand = getInstallCommand(item);
const sourceUrl = getSourceUrl(item);
const detailsHtml = `
<div class="extension-details-body">
<div class="extension-details-main">
<img id="extension-details-image" class="extension-preview-image extension-details-image" src="" alt="" />
<div id="extension-details-gallery" class="extension-details-gallery" role="list"></div>
</div>
<div class="extension-details-content">
<p id="extension-details-description" class="extension-details-description">${escapeHtml(
item.description || "Canvas extension"
)}</p>
<div id="extension-details-keywords" class="resource-keywords extension-details-keywords">${keywordHtml}</div>
<div id="extension-details-meta" class="resource-meta extension-details-meta">${metaParts.join(
""
)}</div>
<div class="resource-actions extension-details-actions">
<button id="extension-details-install" class="btn btn-primary btn-small" type="button" data-install-command="${escapeHtml(
installCommand
)}" ${installCommand ? "" : "disabled"}>Copy Install</button>
<button id="extension-details-copy-url" class="btn btn-secondary btn-small" type="button" data-install-url="${escapeHtml(
installUrl
)}" ${installUrl ? "" : "disabled"}>Copy URL</button>
${sourceUrl
? `<a id="extension-details-source" class="btn btn-secondary btn-small" href="${escapeHtml(
sourceUrl
)}" target="_blank" rel="noopener noreferrer">Source</a>`
: ""
}
</div>
</div>
</div>
`;
openCardDetailsModal({
title: item.name,
description: item.description || "Canvas extension",
detailsHtml,
contentClassName: "modal-card-details modal-card-details-extension",
trigger,
});
const galleryImages = getGalleryImages(item);
const initialImage = preferredImageUrl || galleryImages[0] || "";
renderGalleryThumbnails(galleryImages, initialImage);
if (initialImage) {
setSelectedGalleryImage(initialImage, item.name);
}
}
function sortItems(items: Extension[]): Extension[] {
return sortExtensions(items, currentSort);
@@ -313,6 +91,7 @@ function setupActionHandlers(list: HTMLElement | null): void {
if (!installButton) return;
event.preventDefault();
event.stopPropagation();
const installCommand = installButton.dataset.installCommand || "";
const installUrl = installButton.dataset.installUrl || "";
@@ -330,104 +109,6 @@ function setupActionHandlers(list: HTMLElement | null): void {
: "Failed to copy install target",
success ? "success" : "error"
);
return;
});
list.addEventListener("click", (event) => {
const target = event.target as HTMLElement;
const thumbnailButton = target.closest(
".resource-thumbnail-btn"
) as HTMLElement | null;
if (thumbnailButton) {
event.preventDefault();
event.stopPropagation();
const extensionId = thumbnailButton.dataset.extensionId;
if (!extensionId) return;
const previewButton = thumbnailButton.closest(".resource-preview") as HTMLElement | null;
openDetailsModal(extensionId, undefined, previewButton || undefined);
return;
}
if (
target.closest(".resource-actions") ||
target.closest(".extension-details-thumbnail-btn")
) {
return;
}
const card = target.closest(".resource-item") as HTMLElement | null;
const previewButton = card?.querySelector(".resource-preview") as HTMLElement | null;
const extensionId = card?.dataset.extensionId;
if (extensionId) {
openDetailsModal(extensionId, undefined, previewButton || undefined);
}
});
list.addEventListener("keydown", (event) => {
const target = event.target as HTMLElement;
const card = target.closest(".resource-item") as HTMLElement | null;
if (!card) return;
if (target.closest("a, button, select, input, textarea")) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
const extensionId = card.dataset.extensionId;
const previewButton = card.querySelector(".resource-preview") as HTMLElement | null;
if (extensionId) {
openDetailsModal(extensionId, undefined, previewButton || undefined);
}
}
});
document.addEventListener("click", async (event) => {
const target = event.target as HTMLElement;
const detailsInstallButton = target.closest(
"#extension-details-install"
) as HTMLButtonElement | null;
if (detailsInstallButton) {
const installCommand = detailsInstallButton.dataset.installCommand || "";
if (!installCommand) {
showToast("No plugin install command available for this extension", "error");
return;
}
const success = await copyToClipboard(installCommand);
showToast(
success ? "Install command copied!" : "Failed to copy install command",
success ? "success" : "error"
);
return;
}
const detailsCopyUrlButton = target.closest(
"#extension-details-copy-url"
) as HTMLButtonElement | null;
if (detailsCopyUrlButton) {
const installUrl = detailsCopyUrlButton.dataset.installUrl || "";
if (!installUrl) {
showToast("No install URL available for this extension", "error");
return;
}
const success = await copyToClipboard(installUrl);
showToast(
success ? "Install URL copied!" : "Failed to copy install URL",
success ? "success" : "error"
);
return;
}
const button = target.closest(
".extension-details-thumbnail-btn"
) as HTMLButtonElement | null;
if (!button) return;
const imageUrl = button.dataset.galleryImageUrl;
const titleText = document.getElementById("modal-title")?.textContent;
if (!imageUrl || !titleText) return;
setSelectedGalleryImage(imageUrl, titleText);
});
actionHandlersReady = true;
@@ -448,11 +129,6 @@ export async function initExtensionsPage(): Promise<void> {
"sort-select"
) as HTMLSelectElement;
if (!modalReady) {
setupModal();
modalReady = true;
}
setupActionHandlers(list as HTMLElement | null);
const data = await fetchData<ExtensionsData>("extensions.json");
@@ -464,7 +140,6 @@ export async function initExtensionsPage(): Promise<void> {
}
allItems = data.items;
extensionById = new Map(allItems.map((item) => [item.id, item]));
const availableKeywords = (
data.filters?.keywords ||
+407
View File
@@ -0,0 +1,407 @@
/**
* Client behaviour for multi-file bundle detail pages (skills and hooks).
*
* Skills and hooks are multi-file bundles: there is no single-command CLI
* install for hooks, and skills install with the GitHub CLI. This script wires
* up the shared file browser (the primary file — SKILL.md or README.md — is
* embedded at build time; other files are lazily fetched and rendered — markdown
* via `marked`, code via a lazily-imported Shiki, images via their raw GitHub
* URL, everything else as plain text), plus the "copy install command",
* "Download ZIP", copy-file and Share actions. Deep links use the existing
* `#file=<path>` hash convention.
*/
import { marked } from "marked";
import { enhanceMarkdownA11y } from "../../lib/markdown-a11y";
import { sanitizeHtml } from "../../lib/sanitize-html";
import {
copyToClipboard,
downloadZipBundle,
escapeHtml,
getRawGitHubUrl,
isSafeRepoFilePath,
showToast,
type ZipDownloadFile,
} from "../utils";
interface CachedFile {
html?: string;
rawText?: string;
}
type RenderedFile = CachedFile & { html: string };
interface FileDescriptor {
path: string;
name: string;
lang: string;
kind: string;
}
function isImageKind(kind: string): boolean {
return kind === "image";
}
function encodeRepoPath(filePath: string): string {
return filePath.split("/").map(encodeURIComponent).join("/");
}
function getSafeGitHubFileUrl(
githubBase: string,
filePath: string
): string | null {
if (!isSafeRepoFilePath(filePath)) return null;
try {
const base = new URL(githubBase);
if (base.protocol !== "https:" || base.hostname !== "github.com") return null;
base.pathname = `${base.pathname.replace(/\/+$/, "")}/${encodeRepoPath(
filePath
)}`;
base.search = "";
base.hash = "";
return base.toString();
} catch {
return null;
}
}
let highlighterPromise: Promise<
(code: string, lang: string) => Promise<string>
> | null = null;
/**
* Lazily load Shiki and return a highlight helper. Falls back to plain,
* escaped `<pre>` output if Shiki (or the requested language) is unavailable.
*/
function loadHighlighter() {
highlighterPromise ??= import("shiki").then(({ codeToHtml }) => {
return async (code: string, lang: string) => {
try {
const highlighted = await codeToHtml(code, {
lang,
themes: { light: "github-light", dark: "github-dark" },
});
// Shiki emits a scrollable <pre>; make it keyboard focusable.
return highlighted.replace(
/<pre(?![^>]*\btabindex=)/,
'<pre tabindex="0"'
);
} catch {
return `<pre tabindex="0" class="skill-file-plain"><code>${escapeHtml(code)}</code></pre>`;
}
};
});
return highlighterPromise;
}
function initFileBrowser(): void {
const root = document.querySelector<HTMLElement>("[data-file-browser-page]");
if (!root) return;
const browser = root.querySelector<HTMLElement>("[data-file-browser]");
const contentEl = root.querySelector<HTMLElement>("[data-file-content]");
const statusEl = root.querySelector<HTMLElement>("[data-file-status]");
const currentNameEl = root.querySelector<HTMLElement>(
"[data-current-file-name]"
);
const fileSelect = root.querySelector<HTMLSelectElement>("[data-file-select]");
const githubLink = root.querySelector<HTMLAnchorElement>("[data-file-github]");
const primaryFilePath = browser?.dataset.primaryFile ?? "";
const githubBase = browser?.dataset.githubBase ?? "";
const cache = new Map<string, CachedFile>();
let activePath = primaryFilePath;
// Seed the cache with the primary file's raw source and its already-rendered
// HTML. The server-rendered primary view has frontmatter stripped (gray-matter),
// so caching the rendered HTML avoids re-parsing the raw (frontmatter-including)
// source if the user navigates away and back to the primary file.
if (contentEl) {
const rawPrimary =
root.querySelector<HTMLTextAreaElement>("[data-raw-markdown]")?.value ??
"";
cache.set(primaryFilePath, {
rawText: rawPrimary,
html: contentEl.innerHTML,
});
}
// Build the canonical file list from the <select> options. Single-file
// bundles have no select, so fall back to just the embedded primary file.
const fileDescriptors: FileDescriptor[] = fileSelect
? Array.from(fileSelect.options).map((opt) => ({
path: opt.value,
name: opt.dataset.fileName ?? opt.value,
lang: opt.dataset.fileLang ?? "text",
kind: opt.dataset.fileKind ?? "other",
}))
: [
{
path: primaryFilePath,
name: currentNameEl?.textContent?.trim() || primaryFilePath,
lang: "markdown",
kind: "markdown",
},
];
const setStatus = (message: string | null): void => {
if (!statusEl) return;
if (!message) {
statusEl.hidden = true;
statusEl.textContent = "";
return;
}
statusEl.hidden = false;
statusEl.textContent = message;
};
const setActive = (path: string): void => {
if (fileSelect && fileSelect.value !== path) fileSelect.value = path;
};
const showLoadError = (fileUrl: string | null): void => {
contentEl?.replaceChildren();
contentEl?.classList.remove("is-code");
contentEl?.classList.remove("is-image");
setStatus(null);
if (!contentEl) return;
const message = document.createElement("p");
message.className = "detail-empty";
message.append("Couldn't load this file.");
if (fileUrl) {
message.append(" ");
const link = document.createElement("a");
link.href = fileUrl;
link.target = "_blank";
link.rel = "noopener";
link.textContent = "View it on GitHub";
message.append(link, ".");
}
contentEl.append(message);
};
async function renderFile(
path: string,
name: string,
lang: string,
kind: string
): Promise<RenderedFile> {
const cached = cache.get(path);
if (cached?.html !== undefined) return cached as RenderedFile;
if (isImageKind(kind)) {
const imageUrl = getRawGitHubUrl(path);
const entry: RenderedFile = {
html: `<img class="skill-file-image" src="${escapeHtml(imageUrl)}" alt="${escapeHtml(name)}" loading="lazy" decoding="async">`,
};
cache.set(path, entry);
return entry;
}
let rawText = cached?.rawText;
if (rawText === undefined) {
setStatus("Loading…");
const response = await fetch(getRawGitHubUrl(path));
if (!response.ok) throw new Error(`Failed to load ${name}`);
rawText = await response.text();
}
let html: string;
if (kind === "markdown") {
html = enhanceMarkdownA11y(
sanitizeHtml(marked.parse(rawText, { async: false }) as string)
);
} else if (kind === "code") {
const highlight = await loadHighlighter();
html = await highlight(rawText, lang);
} else {
html = `<pre tabindex="0" class="skill-file-plain"><code>${escapeHtml(rawText)}</code></pre>`;
}
const entry: RenderedFile = { html, rawText };
cache.set(path, entry);
return entry;
}
async function selectFile(
path: string,
name: string,
lang: string,
kind: string,
updateHash = true
): Promise<void> {
if (!contentEl) return;
const fileUrl = githubBase ? getSafeGitHubFileUrl(githubBase, path) : null;
if (!isSafeRepoFilePath(path)) {
showLoadError(null);
return;
}
activePath = path;
setActive(path);
if (currentNameEl) currentNameEl.textContent = name;
if (githubLink) githubLink.href = fileUrl ?? "#";
try {
const entry = await renderFile(path, name, lang, kind);
contentEl.innerHTML = entry.html;
contentEl.classList.toggle("is-code", kind === "code");
contentEl.classList.toggle("is-image", isImageKind(kind));
setStatus(null);
} catch {
showLoadError(fileUrl);
}
if (updateHash) {
const newHash = `#file=${encodeURIComponent(path)}`;
if (window.location.hash !== newHash) {
history.replaceState(null, "", newHash);
}
}
}
// --- File selection ---
fileSelect?.addEventListener("change", () => {
const opt = fileSelect.selectedOptions[0];
if (!opt) return;
const path = opt.value;
if (!path || path === activePath) return;
void selectFile(
path,
opt.dataset.fileName ?? path,
opt.dataset.fileLang ?? "text",
opt.dataset.fileKind ?? "other"
);
});
// --- Copy current file contents ---
root
.querySelector<HTMLButtonElement>("[data-action='copy-file']")
?.addEventListener("click", async () => {
const activeDescriptor = fileDescriptors.find((d) => d.path === activePath);
if (activeDescriptor && isImageKind(activeDescriptor.kind)) {
showToast("Images can't be copied as text", "error");
return;
}
let entry = cache.get(activePath);
if (entry?.rawText === undefined) {
try {
const response = await fetch(getRawGitHubUrl(activePath));
if (response.ok) {
const rawText = await response.text();
entry = { ...entry, rawText };
cache.set(activePath, entry);
}
} catch {
/* handled below */
}
}
if (entry?.rawText === undefined) {
showToast("Nothing to copy", "error");
return;
}
const success = await copyToClipboard(entry.rawText);
showToast(
success ? "File copied!" : "Failed to copy file",
success ? "success" : "error"
);
});
// --- Copy install command ---
const installBlock = root.querySelector<HTMLElement>("[data-install-command]");
root
.querySelector<HTMLButtonElement>("[data-action='copy-install']")
?.addEventListener("click", async () => {
const command = installBlock?.dataset.installCommand ?? "";
if (!command) return;
const success = await copyToClipboard(command);
showToast(
success ? "Install command copied!" : "Failed to copy",
success ? "success" : "error"
);
});
// --- Download ZIP bundle ---
root
.querySelector<HTMLButtonElement>("[data-action='download-zip']")
?.addEventListener("click", async (event) => {
const btn = event.currentTarget as HTMLButtonElement;
const bundleId = root.dataset.bundleId ?? "bundle";
const files: ZipDownloadFile[] = fileDescriptors.map((d) => ({
name: d.name,
path: d.path,
}));
if (files.length === 0) {
showToast("No files found for this item.", "error");
return;
}
const originalContent = btn.innerHTML;
btn.disabled = true;
btn.textContent = "Preparing…";
try {
await downloadZipBundle(bundleId, files);
showToast("Download started!", "success");
} catch (error) {
const message =
error instanceof Error ? error.message : "Download failed.";
showToast(message, "error");
} finally {
btn.disabled = false;
btn.innerHTML = originalContent;
}
});
// --- Share (deep link to the active file) ---
root
.querySelector<HTMLButtonElement>("[data-action='share']")
?.addEventListener("click", async () => {
const url = `${window.location.origin}${window.location.pathname}#file=${encodeURIComponent(
activePath
)}`;
const success = await copyToClipboard(url);
showToast(
success ? "Link copied!" : "Failed to copy link",
success ? "success" : "error"
);
});
// --- Honour a #file= deep link (on load and on later hash navigation) ---
const selectFromHash = (updateHash: boolean): void => {
const hashMatch = window.location.hash.match(/^#file=(.+)$/);
if (!hashMatch) return;
let wanted: string | undefined;
try {
wanted = decodeURIComponent(hashMatch[1]);
} catch {
wanted = undefined;
}
const desc = wanted
? fileDescriptors.find((d) => d.path === wanted)
: undefined;
if (desc && wanted !== activePath) {
void selectFile(desc.path, desc.name, desc.lang, desc.kind, updateHash);
}
};
selectFromHash(false);
// React to same-page hash changes (address-bar edits, in-page anchor links,
// and browser back/forward navigation between shared #file= links).
window.addEventListener("hashchange", () => {
selectFromHash(false);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initFileBrowser, {
once: true,
});
} else {
initFileBrowser();
}
+14
View File
@@ -5,6 +5,11 @@ import {
} from "../utils";
import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render";
export interface RenderableHookFile {
name: string;
path: string;
}
export interface RenderableHook {
id: string;
title: string;
@@ -14,11 +19,19 @@ export interface RenderableHook {
hooks: string[];
tags: string[];
assets: string[];
files: RenderableHookFile[];
lastUpdated?: string | null;
}
export type HookSortOption = "title" | "lastUpdated";
/**
* Build the URL of a hook's dedicated detail page.
*/
export function getHookDetailUrl(id: string): string {
return `/hook/${id}/`;
}
export function sortHooks<T extends RenderableHook>(
items: T[],
sort: HookSortOption
@@ -78,6 +91,7 @@ export function renderHooksHtml(items: RenderableHook[]): string {
return renderSharedCardHtml({
title: item.title,
description: item.description || "No description",
href: getHookDetailUrl(item.id),
articleAttributes: {
"data-path": item.readmeFile,
"data-hook-id": item.id,
+1 -83
View File
@@ -2,16 +2,13 @@
* Hooks page functionality
*/
import {
escapeHtml,
fetchData,
formatRelativeTime,
getQueryParam,
getQueryParamValues,
showToast,
downloadZipBundle,
updateQueryParams,
} from '../utils';
import { openCardDetailsModal, setupModal } from '../modal';
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
import {
renderHooksHtml,
@@ -30,14 +27,12 @@ interface HooksData {
}
let allItems: Hook[] = [];
let hookById = new Map<string, Hook>();
let tagSelectEl: HTMLSelectElement | null = null;
let currentFilters = {
tags: [] as string[],
};
let currentSort: HookSortOption = 'title';
let resourceListHandlersReady = false;
let modalReady = false;
function sortItems(items: Hook[]): Hook[] {
return sortHooks(items, currentSort);
@@ -111,57 +106,6 @@ async function downloadHook(hookId: string, btn: HTMLButtonElement): Promise<voi
}
}
function openHookDetailsModal(hookId: string, trigger?: HTMLElement): void {
const item = hookById.get(hookId);
if (!item) {
return;
}
const metaParts = item.hooks.map(
(hookName) => `<span class="resource-tag tag-hook">${escapeHtml(hookName)}</span>`
);
if (item.assets.length > 0) {
metaParts.push(
`<span class="resource-tag tag-assets">${item.assets.length} asset${
item.assets.length === 1 ? '' : 's'
}</span>`
);
}
if (item.lastUpdated) {
metaParts.push(
`<span class="last-updated">Updated ${escapeHtml(
formatRelativeTime(item.lastUpdated)
)}</span>`
);
}
const tagHtml = item.tags
.map((tagText) => `<span class="resource-tag tag-tag">${escapeHtml(tagText)}</span>`)
.join('');
const actionsHtml = `
<button id="hook-details-download" class="btn btn-primary" type="button" data-hook-id="${escapeHtml(
item.id
)}">Download</button>
<button class="btn btn-secondary" type="button" data-open-file-path="${escapeHtml(
item.readmeFile
)}" data-open-file-type="hook">Source</button>
`;
openCardDetailsModal({
title: item.title,
description: item.description || 'No description',
previewIcon: '🪝',
previewText: 'Hook events and download options',
metaHtml: metaParts.join(''),
tagsHtml: tagHtml,
actionsHtml,
trigger,
});
}
function setupResourceListHandlers(list: HTMLElement | null): void {
if (!list || resourceListHandlersReady) return;
@@ -169,32 +113,12 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
const target = event.target as HTMLElement;
const downloadButton = target.closest('.download-hook-btn') as HTMLButtonElement | null;
if (downloadButton) {
event.preventDefault();
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 button = item?.querySelector('.resource-preview') as HTMLElement | undefined;
const hookId = item?.dataset.hookId;
if (hookId) {
openHookDetailsModal(hookId, button);
}
});
document.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
const modalDownloadButton = target.closest(
'#hook-details-download'
) as HTMLButtonElement | null;
if (!modalDownloadButton) return;
const hookId = modalDownloadButton.dataset.hookId;
if (hookId) downloadHook(hookId, modalDownloadButton);
});
resourceListHandlersReady = true;
@@ -214,11 +138,6 @@ export async function initHooksPage(): Promise<void> {
const clearFiltersBtn = document.getElementById('clear-filters');
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
if (!modalReady) {
setupModal();
modalReady = true;
}
setupResourceListHandlers(list as HTMLElement | null);
const data = await fetchData<HooksData>('hooks.json');
@@ -228,7 +147,6 @@ export async function initHooksPage(): Promise<void> {
}
allItems = data.items;
hookById = new Map(allItems.map((item) => [item.id, item]));
tagSelectEl = document.getElementById('filter-tag') as HTMLSelectElement | null;
if (tagSelectEl) {
@@ -1,6 +1,5 @@
import {
escapeHtml,
getActionButtonsHtml,
getGitHubUrl,
getInstallDropdownHtml,
getLastUpdatedHtml,
@@ -8,6 +7,7 @@ import {
import { renderEmptyStateHtml, renderSharedCardHtml } from './card-render';
export interface RenderableInstruction {
id: string;
title: string;
description?: string;
path: string;
@@ -18,6 +18,13 @@ export interface RenderableInstruction {
export type InstructionSortOption = 'title' | 'lastUpdated';
/**
* Build the URL of an instruction's dedicated detail page.
*/
export function getInstructionDetailUrl(id: string): string {
return `/instruction/${id}/`;
}
export function sortInstructions<T extends RenderableInstruction>(
items: T[],
sort: InstructionSortOption
@@ -55,7 +62,13 @@ export function renderInstructionsHtml(
const actionsHtml = `
${getInstallDropdownHtml('instructions', item.path, true)}
${getActionButtonsHtml(item.path, true)}
<button class="btn btn-secondary btn-small action-download" data-path="${escapeHtml(
item.path
)}" title="Download file">
<svg viewBox="0 0 16 16" width="14" height="14" 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>
</button>
<a href="${getGitHubUrl(item.path)}" class="btn btn-secondary btn-small" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">
GitHub
</a>
@@ -64,6 +77,7 @@ export function renderInstructionsHtml(
return renderSharedCardHtml({
title: item.title,
description: item.description || 'No description',
href: getInstructionDetailUrl(item.id),
articleAttributes: {
'data-path': item.path,
},
-82
View File
@@ -2,17 +2,13 @@
* Instructions page functionality
*/
import {
escapeHtml,
fetchData,
formatRelativeTime,
getQueryParam,
getQueryParamValues,
getVSCodeInstallUrl,
setupActionHandlers,
setupDropdownCloseHandlers,
updateQueryParams,
} from '../utils';
import { openCardDetailsModal, setupModal } from '../modal';
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
import {
renderInstructionsHtml,
@@ -36,12 +32,9 @@ interface InstructionsData {
}
let allItems: Instruction[] = [];
let instructionByPath = new Map<string, Instruction>();
let extensionSelectEl: HTMLSelectElement | null = null;
let currentFilters = { extensions: [] as string[] };
let currentSort: InstructionSortOption = 'title';
let resourceListHandlersReady = false;
let modalReady = false;
function sortItems(items: Instruction[]): Instruction[] {
return sortInstructions(items, currentSort);
@@ -80,73 +73,6 @@ function renderItems(items: Instruction[]): void {
list.innerHTML = renderInstructionsHtml(items);
}
function openInstructionDetailsModal(path: string, trigger?: HTMLElement): void {
const item = instructionByPath.get(path);
if (!item) {
return;
}
const metaParts: string[] = [];
const applyToText = Array.isArray(item.applyTo) ? item.applyTo.join(', ') : item.applyTo;
if (applyToText) {
metaParts.push(`<span class="resource-tag">applies to: ${escapeHtml(applyToText)}</span>`);
}
metaParts.push(
...(item.extensions || []).map(
(extension) => `<span class="resource-tag tag-extension">${escapeHtml(extension)}</span>`
)
);
if (item.lastUpdated) {
metaParts.push(`<span class="last-updated">Updated ${escapeHtml(formatRelativeTime(item.lastUpdated))}</span>`);
}
const vscodeUrl = getVSCodeInstallUrl('instructions', path, false);
const insidersUrl = getVSCodeInstallUrl('instructions', path, true);
const actions = [
vscodeUrl
? `<a class="btn btn-primary btn-small" href="${escapeHtml(vscodeUrl)}" target="_blank" rel="noopener noreferrer">Install (VS Code)</a>`
: '',
insidersUrl
? `<a class="btn btn-secondary btn-small" href="${escapeHtml(insidersUrl)}" target="_blank" rel="noopener noreferrer">Install (Insiders)</a>`
: '',
`<button class="btn btn-secondary btn-small" type="button" data-open-file-path="${escapeHtml(
path
)}" data-open-file-type="instruction">Source</button>`,
].filter(Boolean);
openCardDetailsModal({
title: item.title,
description: item.description || 'No description',
previewIcon: '📋',
previewText: 'Instruction metadata and install options',
metaHtml: metaParts.join(''),
actionsHtml: actions.join(''),
trigger,
});
}
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 button = item?.querySelector('.resource-preview') as HTMLElement | undefined;
const path = item?.dataset.path;
if (path) {
openInstructionDetailsModal(path, button);
}
});
resourceListHandlersReady = true;
}
function syncUrlState(): void {
updateQueryParams({
q: '',
@@ -160,13 +86,6 @@ export async function initInstructionsPage(): Promise<void> {
const clearFiltersBtn = document.getElementById('clear-filters');
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
if (!modalReady) {
setupModal();
modalReady = true;
}
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>';
@@ -174,7 +93,6 @@ export async function initInstructionsPage(): Promise<void> {
}
allItems = data.items;
instructionByPath = new Map(allItems.map((item) => [item.path, item]));
extensionSelectEl = document.getElementById('filter-extension') as HTMLSelectElement | null;
if (extensionSelectEl) {
+10 -7
View File
@@ -1,8 +1,8 @@
import {
escapeHtml,
getGitHubUrl,
sanitizeUrl,
} from '../utils';
import { externalRepoUrl } from '../../lib/external-source';
import { renderEmptyStateHtml, renderSharedCardHtml } from './card-render';
interface PluginAuthor {
@@ -14,9 +14,12 @@ interface PluginSource {
source: string;
repo?: string;
path?: string;
ref?: string;
sha?: string;
}
export interface RenderablePlugin {
id: string;
name: string;
description?: string;
path: string;
@@ -30,6 +33,10 @@ export interface RenderablePlugin {
source?: PluginSource | null;
}
export function getPluginDetailUrl(id: string): string {
return `/plugin/${id}/`;
}
export type PluginSortOption = 'title' | 'lastUpdated';
export function sortPlugins<T extends RenderablePlugin>(
@@ -48,12 +55,7 @@ export function sortPlugins<T extends RenderablePlugin>(
}
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 && plugin.source.path !== '/' ? `${base}/tree/main/${plugin.source.path}` : base;
}
return sanitizeUrl(plugin.repository || plugin.homepage);
return externalRepoUrl(plugin.source, [plugin.repository, plugin.homepage]);
}
export function renderPluginsHtml(items: RenderablePlugin[]): string {
@@ -88,6 +90,7 @@ export function renderPluginsHtml(items: RenderablePlugin[]): string {
return renderSharedCardHtml({
title: item.name,
description: item.description || 'No description',
href: getPluginDetailUrl(item.id),
articleClassName: isExternal ? 'resource-item-external' : '',
articleAttributes: {
'data-path': item.path,
-131
View File
@@ -2,17 +2,11 @@
* Plugins page functionality
*/
import {
copyToClipboard,
escapeHtml,
fetchData,
formatRelativeTime,
getGitHubUrl,
getQueryParam,
getQueryParamValues,
showToast,
updateQueryParams,
} from '../utils';
import { openCardDetailsModal, setupModal } from '../modal';
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
import {
renderPluginsHtml,
@@ -60,14 +54,11 @@ interface PluginsData {
}
let allItems: Plugin[] = [];
let pluginByPath = new Map<string, Plugin>();
let tagSelectEl: HTMLSelectElement | null = null;
let currentSort: PluginSortOption = 'title';
let currentFilters = {
tags: [] as string[],
};
let resourceListHandlersReady = false;
let modalReady = false;
function sortItems(items: Plugin[]): Plugin[] {
return sortPlugins(items, currentSort);
@@ -102,120 +93,6 @@ function renderItems(items: Plugin[]): void {
list.innerHTML = renderPluginsHtml(items);
}
function getPluginRepositoryUrl(item: Plugin): string {
if (item.external && item.repository) return item.repository;
if (item.homepage) return item.homepage;
if (item.repository) return item.repository;
return getGitHubUrl(item.path);
}
function getPluginItemLabel(item: PluginItem): string {
const normalizedPath = item.path.replace(/^\.\//, '');
return `${item.kind}: ${normalizedPath}`;
}
function openPluginDetailsModal(path: string, trigger?: HTMLElement): void {
const item = pluginByPath.get(path);
if (!item) {
return;
}
const metaParts: string[] = [];
metaParts.push(
`<span class="resource-tag">${
item.external ? 'External plugin' : `${item.itemCount} items`
}</span>`
);
if (item.author?.name) {
metaParts.push(`<span class="resource-tag">by ${escapeHtml(item.author.name)}</span>`);
}
if (item.lastUpdated) {
metaParts.push(
`<span class="last-updated">Updated ${escapeHtml(
formatRelativeTime(item.lastUpdated)
)}</span>`
);
}
const tagHtml = (item.tags || [])
.map((tagText) => `<span class="resource-tag">${escapeHtml(tagText)}</span>`)
.join('');
const includedItems = item.items || [];
const includedItemHtml = includedItems
.slice(0, 24)
.map(
(pluginItem) =>
`<span class="resource-tag tag-plugin-item">${escapeHtml(getPluginItemLabel(pluginItem))}</span>`
)
.join('');
const includedMoreHtml =
includedItems.length > 24
? `<span class="resource-tag">+${includedItems.length - 24} more</span>`
: '';
const actions = [
item.external
? ''
: `<button id="plugin-details-install" class="btn btn-primary" type="button" data-plugin-name="${escapeHtml(
item.name
)}">Copy Install</button>`,
item.external
? `<a class="btn btn-secondary" href="${escapeHtml(
getPluginRepositoryUrl(item)
)}" target="_blank" rel="noopener noreferrer">Repository</a>`
: `<button class="btn btn-secondary" type="button" data-open-file-path="${escapeHtml(
item.path
)}" data-open-file-type="plugin">Source</button>`,
].filter(Boolean);
openCardDetailsModal({
title: item.name,
description: item.description || 'No description',
previewIcon: '🔌',
previewText: 'Plugin metadata and install options',
metaHtml: metaParts.join(''),
tagsHtml: [tagHtml, includedItemHtml, includedMoreHtml].filter(Boolean).join(''),
actionsHtml: actions.join(''),
trigger,
});
}
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 button = item?.querySelector('.resource-preview') as HTMLElement | undefined;
const path = item?.dataset.path;
if (path) {
openPluginDetailsModal(path, button);
}
});
document.addEventListener('click', async (event) => {
const target = event.target as HTMLElement;
const installButton = target.closest(
'#plugin-details-install'
) as HTMLButtonElement | null;
if (!installButton) return;
const pluginName = installButton.dataset.pluginName || '';
if (!pluginName) return;
const command = `copilot plugin install ${pluginName}@awesome-copilot`;
const success = await copyToClipboard(command);
showToast(success ? 'Install command copied!' : 'Failed to copy', success ? 'success' : 'error');
});
resourceListHandlersReady = true;
}
function syncUrlState(): void {
updateQueryParams({
q: '',
@@ -229,13 +106,6 @@ export async function initPluginsPage(): Promise<void> {
const clearFiltersBtn = document.getElementById('clear-filters');
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
if (!modalReady) {
setupModal();
modalReady = true;
}
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>';
@@ -243,7 +113,6 @@ export async function initPluginsPage(): Promise<void> {
}
allItems = data.items;
pluginByPath = new Map(allItems.map((item) => [item.path, item]));
tagSelectEl = document.getElementById('filter-tag') as HTMLSelectElement | null;
if (tagSelectEl) {
@@ -0,0 +1,250 @@
/**
* Shared client behaviour for resource detail pages (agents, instructions, ...).
*
* The heavy lifting (metadata, rendered documentation) is done at build time,
* so this only wires up the install split-button dropdown plus the Download,
* Copy markdown, and Share actions in the sidebar Actions card. Any detail page
* that renders a root element with `data-resource-detail` gets this behaviour.
*/
import { copyToClipboard, downloadFile, showToast } from "../utils";
function initResourceDetail(): void {
const root = document.querySelector<HTMLElement>("[data-resource-detail]");
if (!root) return;
const filePath = root.dataset.path;
// --- Install split-button dropdown ---
const dropdown = root.querySelector<HTMLElement>("[data-install-menu]");
const toggle = dropdown?.querySelector<HTMLButtonElement>(
"[data-install-toggle]"
);
const menuItems = dropdown
? Array.from(
dropdown.querySelectorAll<HTMLAnchorElement>(
".install-dropdown-menu a[role='menuitem']"
)
)
: [];
const closeMenu = (returnFocus = false) => {
if (!dropdown) return;
dropdown.classList.remove("open");
toggle?.setAttribute("aria-expanded", "false");
if (returnFocus) {
toggle?.focus();
}
};
const openMenu = () => {
if (!dropdown) return;
dropdown.classList.add("open");
toggle?.setAttribute("aria-expanded", "true");
menuItems[0]?.focus();
};
toggle?.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const isOpen = dropdown!.classList.toggle("open");
toggle.setAttribute("aria-expanded", String(isOpen));
if (isOpen) {
menuItems[0]?.focus();
}
});
toggle?.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
e.preventDefault();
openMenu();
}
});
menuItems.forEach((item, index) => {
item.addEventListener("keydown", (e) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
menuItems[(index + 1) % menuItems.length]?.focus();
break;
case "ArrowUp":
e.preventDefault();
menuItems[
(index - 1 + menuItems.length) % menuItems.length
]?.focus();
break;
case "Home":
e.preventDefault();
menuItems[0]?.focus();
break;
case "End":
e.preventDefault();
menuItems[menuItems.length - 1]?.focus();
break;
case "Escape":
e.preventDefault();
closeMenu(true);
break;
case "Tab":
closeMenu();
break;
}
});
item.addEventListener("click", () => {
closeMenu();
});
});
// Close the menu on outside click / Escape.
document.addEventListener("click", (e) => {
if (dropdown && !dropdown.contains(e.target as Node)) closeMenu();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && dropdown?.classList.contains("open")) {
closeMenu(true);
}
});
// --- Download (also available as a menu item) ---
root
.querySelectorAll<HTMLElement>("[data-action='download']")
.forEach((el) => {
el.addEventListener("click", async (e) => {
e.preventDefault();
closeMenu();
if (!filePath) return;
const success = await downloadFile(filePath);
showToast(
success ? "Download started!" : "Download failed",
success ? "success" : "error"
);
});
});
// --- Copy raw markdown (embedded at build time) ---
const rawMarkdown =
root.querySelector<HTMLTextAreaElement>("[data-raw-markdown]")?.value ?? "";
root
.querySelectorAll<HTMLElement>("[data-action='copy-markdown']")
.forEach((el) => {
el.addEventListener("click", async (e) => {
e.preventDefault();
closeMenu();
if (!rawMarkdown) return;
const success = await copyToClipboard(rawMarkdown);
showToast(
success ? "Markdown copied!" : "Failed to copy markdown",
success ? "success" : "error"
);
});
});
// --- Copy install command (embedded at build time) ---
const installBlock = root.querySelector<HTMLElement>(
"[data-install-command]"
);
root
.querySelectorAll<HTMLElement>("[data-action='copy-install']")
.forEach((el) => {
el.addEventListener("click", async (e) => {
e.preventDefault();
closeMenu();
const command = installBlock?.dataset.installCommand ?? "";
if (!command) return;
const success = await copyToClipboard(command);
showToast(
success ? "Install command copied!" : "Failed to copy command",
success ? "success" : "error"
);
});
});
// --- Copy install URL (fallback install target) ---
root
.querySelectorAll<HTMLElement>("[data-action='copy-install-url']")
.forEach((el) => {
el.addEventListener("click", async (e) => {
e.preventDefault();
closeMenu();
const url = el.dataset.installUrl ?? "";
if (!url) return;
const success = await copyToClipboard(url);
showToast(
success ? "Install URL copied!" : "Failed to copy URL",
success ? "success" : "error"
);
});
});
// --- Share ---
const shareBtn = root.querySelector<HTMLButtonElement>(
"[data-action='share']"
);
shareBtn?.addEventListener("click", async () => {
const success = await copyToClipboard(window.location.href);
showToast(
success ? "Link copied!" : "Failed to copy link",
success ? "success" : "error"
);
});
// --- Copy buttons on documentation code blocks ---
enhanceCodeBlocks(root);
}
const COPY_ICON = `<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>`;
const CHECK_ICON = `<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"><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>`;
/**
* Adds a "Copy" button to every fenced code block in the rendered
* documentation. The markdown is turned into plain `<pre><code>` at build
* time (no highlighter), so there is no copy affordance otherwise — a real
* pain on instruction/prompt pages that are mostly config and code snippets.
*/
function enhanceCodeBlocks(root: HTMLElement): void {
const blocks = root.querySelectorAll<HTMLPreElement>(".article-content pre");
blocks.forEach((pre) => {
const code = pre.querySelector("code");
// Skip blocks with no code or that were already enhanced.
if (!code || pre.parentElement?.classList.contains("code-block")) return;
const wrapper = document.createElement("div");
wrapper.className = "code-block";
pre.parentNode?.insertBefore(wrapper, pre);
wrapper.appendChild(pre);
const btn = document.createElement("button");
btn.type = "button";
btn.className = "code-copy-btn";
btn.setAttribute("aria-label", "Copy code to clipboard");
btn.innerHTML = COPY_ICON;
let resetTimer: ReturnType<typeof setTimeout> | undefined;
btn.addEventListener("click", async () => {
const success = await copyToClipboard(code.textContent ?? "");
showToast(
success ? "Code copied!" : "Failed to copy code",
success ? "success" : "error"
);
if (!success) return;
btn.classList.add("copied");
btn.innerHTML = CHECK_ICON;
window.clearTimeout(resetTimer);
resetTimer = setTimeout(() => {
btn.classList.remove("copied");
btn.innerHTML = COPY_ICON;
}, 2000);
});
wrapper.appendChild(btn);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initResourceDetail, {
once: true,
});
} else {
initResourceDetail();
}
@@ -25,6 +25,13 @@ export interface RenderableSkill {
export type SkillSortOption = "title" | "lastUpdated";
/**
* Build the URL of a skill's dedicated detail page.
*/
export function getSkillDetailUrl(id: string): string {
return `/skill/${id}/`;
}
export function sortSkills<T extends RenderableSkill>(
items: T[],
sort: SkillSortOption
@@ -88,6 +95,7 @@ export function renderSkillsHtml(items: RenderableSkill[]): string {
return renderSharedCardHtml({
title: item.title,
description: item.description || "No description",
href: getSkillDetailUrl(item.id),
articleAttributes: {
"data-path": item.skillFile,
"data-skill-id": item.id,
+2 -98
View File
@@ -2,9 +2,7 @@
* Skills page functionality
*/
import {
escapeHtml,
fetchData,
formatRelativeTime,
getQueryParam,
showToast,
downloadZipBundle,
@@ -12,7 +10,6 @@ import {
copyToClipboard,
REPO_IDENTIFIER,
} from '../utils';
import { openCardDetailsModal, setupModal } from '../modal';
import {
renderSkillsHtml,
sortSkills,
@@ -34,10 +31,8 @@ interface SkillsData {
}
let allItems: Skill[] = [];
let skillById = new Map<string, Skill>();
let currentSort: SkillSortOption = 'title';
let resourceListHandlersReady = false;
let modalReady = false;
function applyFiltersAndRender(): void {
const countEl = document.getElementById('results-count');
@@ -99,66 +94,6 @@ async function downloadSkill(skillId: string, btn: HTMLButtonElement): Promise<v
}
}
function openSkillDetailsModal(skillId: string, trigger?: HTMLElement): void {
const item = skillById.get(skillId);
if (!item) {
return;
}
const metaParts: string[] = [];
if (item.hasAssets) {
metaParts.push(
`<span class="resource-tag tag-assets">${item.assetCount} asset${
item.assetCount === 1 ? '' : 's'
}</span>`
);
}
metaParts.push(
`<span class="resource-tag">${item.files.length} file${
item.files.length === 1 ? '' : 's'
}</span>`
);
if (item.lastUpdated) {
metaParts.push(
`<span class="last-updated">Updated ${escapeHtml(
formatRelativeTime(item.lastUpdated)
)}</span>`
);
}
const fileTagParts = item.files
.slice(0, 24)
.map((file) => `<span class="resource-tag">${escapeHtml(file.name)}</span>`);
if (item.files.length > 24) {
fileTagParts.push(`<span class="resource-tag">+${item.files.length - 24} more</span>`);
}
const actionsHtml = `
<button id="skill-details-install" class="btn btn-secondary" type="button" data-skill-id="${escapeHtml(
item.id
)}">Copy Install</button>
<button id="skill-details-download" class="btn btn-primary" type="button" data-skill-id="${escapeHtml(
item.id
)}">Download</button>
<button class="btn btn-secondary" type="button" data-open-file-path="${escapeHtml(
item.skillFile
)}" data-open-file-type="skill">Source</button>
`;
openCardDetailsModal({
title: item.title,
description: item.description || 'No description',
previewIcon: '⚡',
previewText: 'Skill files and install options',
metaHtml: metaParts.join(''),
tagsHtml: fileTagParts.join(''),
actionsHtml,
trigger,
});
}
function setupResourceListHandlers(list: HTMLElement | null): void {
if (!list || resourceListHandlersReady) return;
@@ -167,6 +102,7 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
const copyInstallButton = target.closest('.copy-install-btn') as HTMLButtonElement | null;
if (copyInstallButton) {
event.preventDefault();
event.stopPropagation();
const skillId = copyInstallButton.dataset.skillId;
if (skillId) copyInstallCommand(skillId, copyInstallButton);
@@ -175,38 +111,12 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
const downloadButton = target.closest('.download-skill-btn') as HTMLButtonElement | null;
if (downloadButton) {
event.preventDefault();
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 button = item?.querySelector('.resource-preview') as HTMLElement | undefined;
const skillId = item?.dataset.skillId;
if (skillId) openSkillDetailsModal(skillId, button);
});
document.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
const modalInstallButton = target.closest(
'#skill-details-install'
) as HTMLButtonElement | null;
if (modalInstallButton) {
const skillId = modalInstallButton.dataset.skillId;
if (skillId) copyInstallCommand(skillId, modalInstallButton);
return;
}
const modalDownloadButton = target.closest(
'#skill-details-download'
) as HTMLButtonElement | null;
if (modalDownloadButton) {
const skillId = modalDownloadButton.dataset.skillId;
if (skillId) downloadSkill(skillId, modalDownloadButton);
}
});
resourceListHandlersReady = true;
@@ -225,11 +135,6 @@ export async function initSkillsPage(): Promise<void> {
const list = document.getElementById('resource-list');
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
if (!modalReady) {
setupModal();
modalReady = true;
}
setupResourceListHandlers(list as HTMLElement | null);
const data = await fetchData<SkillsData>('skills.json');
@@ -239,7 +144,6 @@ export async function initSkillsPage(): Promise<void> {
}
allItems = data.items;
skillById = new Map(allItems.map((item) => [item.id, item]));
const initialSort = getQueryParam('sort');
if (initialSort === 'lastUpdated') {
@@ -7,6 +7,7 @@ import {
import { renderEmptyStateHtml, renderSharedCardHtml } from './card-render';
export interface RenderableWorkflow {
id: string;
title: string;
description?: string;
path: string;
@@ -16,6 +17,13 @@ export interface RenderableWorkflow {
export type WorkflowSortOption = 'title' | 'lastUpdated';
/**
* Build the URL of a workflow's dedicated detail page.
*/
export function getWorkflowDetailUrl(id: string): string {
return `/workflow/${id}/`;
}
export function sortWorkflows<T extends RenderableWorkflow>(
items: T[],
sort: WorkflowSortOption
@@ -55,6 +63,7 @@ export function renderWorkflowsHtml(
return renderSharedCardHtml({
title: item.title,
description: item.description || 'No description',
href: getWorkflowDetailUrl(item.id),
articleAttributes: {
'data-path': item.path,
},
-87
View File
@@ -2,17 +2,12 @@
* Workflows page functionality
*/
import {
copyToClipboard,
escapeHtml,
fetchData,
formatRelativeTime,
getQueryParam,
getQueryParamValues,
showToast,
setupActionHandlers,
updateQueryParams,
} from '../utils';
import { openCardDetailsModal, setupModal } from '../modal';
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
import {
renderWorkflowsHtml,
@@ -36,14 +31,11 @@ interface WorkflowsData {
}
let allItems: Workflow[] = [];
let workflowByPath = new Map<string, Workflow>();
let triggerSelectEl: HTMLSelectElement | null = null;
let currentFilters = {
triggers: [] as string[],
};
let currentSort: WorkflowSortOption = 'title';
let resourceListHandlersReady = false;
let modalReady = false;
function sortItems(items: Workflow[]): Workflow[] {
return sortWorkflows(items, currentSort);
@@ -74,77 +66,6 @@ function renderItems(items: Workflow[]): void {
list.innerHTML = renderWorkflowsHtml(items);
}
function openWorkflowDetailsModal(path: string, trigger?: HTMLElement): void {
const item = workflowByPath.get(path);
if (!item) {
return;
}
const metaParts: string[] = [];
if (item.lastUpdated) {
metaParts.push(
`<span class="last-updated">Updated ${escapeHtml(
formatRelativeTime(item.lastUpdated)
)}</span>`
);
}
const triggerTags = item.triggers
.map((triggerName) => `<span class="resource-tag tag-trigger">${escapeHtml(triggerName)}</span>`)
.join('');
const actionsHtml = `
<button id="workflow-details-copy-path" class="btn btn-secondary" type="button" data-workflow-path="${escapeHtml(
item.path
)}">Copy Path</button>
<button class="btn btn-secondary" type="button" data-open-file-path="${escapeHtml(
item.path
)}" data-open-file-type="workflow">Source</button>
`;
openCardDetailsModal({
title: item.title,
description: item.description || 'No description',
previewIcon: '⚡',
previewText: 'Workflow trigger details and source',
metaHtml: metaParts.join(''),
tagsHtml: triggerTags,
actionsHtml,
trigger,
});
}
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 button = item?.querySelector('.resource-preview') as HTMLElement | undefined;
const path = item?.dataset.path;
if (path) {
openWorkflowDetailsModal(path, button);
}
});
document.addEventListener('click', async (event) => {
const target = event.target as HTMLElement;
const copyPathButton = target.closest(
'#workflow-details-copy-path'
) as HTMLButtonElement | null;
if (!copyPathButton) return;
const workflowPath = copyPathButton.dataset.workflowPath || '';
if (!workflowPath) return;
const success = await copyToClipboard(workflowPath);
showToast(success ? 'Path copied!' : 'Failed to copy path', success ? 'success' : 'error');
});
resourceListHandlersReady = true;
}
function syncUrlState(): void {
updateQueryParams({
q: '',
@@ -158,13 +79,6 @@ export async function initWorkflowsPage(): Promise<void> {
const clearFiltersBtn = document.getElementById('clear-filters');
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
if (!modalReady) {
setupModal();
modalReady = true;
}
setupResourceListHandlers(list as HTMLElement | null);
const data = await fetchData<WorkflowsData>('workflows.json');
if (!data || !data.items) {
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
@@ -172,7 +86,6 @@ export async function initWorkflowsPage(): Promise<void> {
}
allItems = data.items;
workflowByPath = new Map(allItems.map((item) => [item.path, item]));
triggerSelectEl = document.getElementById('filter-trigger') as HTMLSelectElement | null;
if (triggerSelectEl) {
+43 -3
View File
@@ -13,6 +13,42 @@ const REPO_GITHUB_URL = "https://github.com/github/awesome-copilot/blob/main";
*/
export const REPO_IDENTIFIER = "github/awesome-copilot";
/**
* Validate that a value is a safe, repo-relative file path before it is used to
* build a raw.githubusercontent.com URL.
*
* This blocks client-side path traversal (CSPT): without it, a value such as
* `../../../attacker/repo/main/evil.md` (typically supplied via the `#file=`
* deep-link hash) would resolve outside the awesome-copilot repo prefix once the
* URL is normalized by `fetch`, letting an attacker load arbitrary content that
* is then rendered into the page.
*/
export function isSafeRepoFilePath(filePath: unknown): filePath is string {
if (typeof filePath !== "string") return false;
const path = filePath.trim();
if (path === "") return false;
// Reject absolute and protocol-relative paths.
if (path.startsWith("/") || path.startsWith("\\")) return false;
// Reject anything with a URL scheme or Windows drive letter (colons never
// appear in legitimate repo file paths).
if (path.includes(":")) return false;
// Reject backslashes; repo paths only ever use forward slashes.
if (path.includes("\\")) return false;
// Fail closed on percent-encoding: legitimate repo paths never contain "%",
// and encoded dot-segments (e.g. %2e%2e or double-encoded %252e%252e) could be
// normalized by the browser URL parser during fetch, reintroducing traversal.
if (path.includes("%")) return false;
// Reject traversal (".."), current-dir ("."), and empty segments (from "//").
if (
path
.split("/")
.some((segment) => segment === "" || segment === "." || segment === "..")
) {
return false;
}
return true;
}
// VS Code install URL configurations
const VSCODE_INSTALL_CONFIG: Record<
string,
@@ -110,7 +146,7 @@ export async function downloadZipBundle(
return {
name: file.name,
content: await response.text(),
content: await response.arrayBuffer(),
};
} catch {
return null;
@@ -142,7 +178,7 @@ export async function fetchFileContent(
filePath: string
): Promise<string | null> {
try {
const response = await fetch(`${REPO_BASE_URL}/${filePath}`);
const response = await fetch(getRawGitHubUrl(filePath));
if (!response.ok) throw new Error(`Failed to fetch ${filePath}`);
return await response.text();
} catch (error) {
@@ -185,6 +221,7 @@ export function getVSCodeInstallUrl(
): string | null {
const config = VSCODE_INSTALL_CONFIG[type];
if (!config) return null;
if (!isSafeRepoFilePath(filePath)) return null;
const rawUrl = `${REPO_BASE_URL}/${filePath}`;
const vscodeScheme = insiders ? "vscode-insiders" : "vscode";
@@ -206,6 +243,9 @@ export function getGitHubUrl(filePath: string): string {
* Get raw GitHub URL for a file (for fetching content)
*/
export function getRawGitHubUrl(filePath: string): string {
if (!isSafeRepoFilePath(filePath)) {
throw new Error(`Unsafe repository file path: ${filePath}`);
}
return `${REPO_BASE_URL}/${filePath}`;
}
@@ -214,7 +254,7 @@ export function getRawGitHubUrl(filePath: string): string {
*/
export async function downloadFile(filePath: string): Promise<boolean> {
try {
const response = await fetch(`${REPO_BASE_URL}/${filePath}`);
const response = await fetch(getRawGitHubUrl(filePath));
if (!response.ok) throw new Error("Failed to fetch file");
const content = await response.text();