mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-15 02:21:04 +00:00
chore: publish from main
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user