mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-14 01:51:02 +00:00
Replace resource modals with dedicated detail pages and richer install options (#2246)
* feat(website): dedicated agent detail pages instead of modal Replace the popup/modal viewer for agents with dedicated per-agent pages at /agent/<id>/ for real URLs and better deep linking. - Build-time rendered docs (marked + gray-matter) with a details sidebar - Sidebar Actions card: Install split-button (VS Code/Insiders/Download/Copy markdown), Share, View on GitHub - Cards now link natively via anchors (no modal); card-render gains optional href (backward compatible for other types) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(website): dedicated instruction detail pages + shared detail layout Extend the dedicated detail-page pattern (introduced for agents) to instructions, and factor the shared behavior/styles out for reuse: - Add instruction/[id].astro with build-time markdown render, breadcrumb, install split-button (VS Code/Insiders/Download/Copy markdown), Share, View on GitHub, and a details sidebar (Applies to / Source / Last updated) plus collapsible frontmatter. - Extract shared client behavior into resource-detail.ts (renamed from agent-detail.ts) keyed on [data-resource-detail]. - Move detail-page CSS into global.css under .resource-detail-page. - Point the agent detail page at the shared script/styles. - Instruction cards now link to /instruction/<id>/ and the modal is removed from the instructions listing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(website): extract shared detail-page components Break the agent and instruction detail pages down into reusable Astro components under src/components/pages: Breadcrumb, Header, Main, Sidebar, SidebarChips, InstallButtons, and RawMarkdown. Both detail pages now compose these components instead of duplicating markup. Fix RawMarkdown to read the `markdown` prop (matching both call sites) and emit exact text via set:text, which restores the Copy markdown action that had silently broken when the hidden textarea stopped rendering. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(website): share detail-page build logic in lib/detail-page Both detail pages duplicated the install/GitHub URL builders, the build-time markdown read+render, and the last-updated formatting. Move all of it into a DOM-free build-time helper (src/lib/detail-page.ts) exposing loadDetailPage(item, type), and reduce each [id].astro to a single call plus its type-specific chip data. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add dedicated skill detail pages with multi-file browser Migrate skills from the popup/modal viewer to dedicated per-item pages (/skill/<id>/) matching the agent and instruction detail pages. Skills need a few skill-specific mechanics: - Install via `gh skills install github/awesome-copilot <id>` (copyable), since skills have no VS Code install URL. - Download ZIP for the multi-file skill contents. - A file browser that defaults to SKILL.md and lets you inspect other files, with Shiki syntax highlighting for code and marked-rendered markdown. SKILL.md is embedded (rendered + raw) at build time; other files are lazy-fetched on demand and cached. Deep links via #file=. Also fixes a production build regression in the shared detail-page helper: repoRoot now resolves via process.cwd() instead of import.meta .url, which resolved incorrectly once bundled and silently returned empty markdown (breaking rendered docs + copy-markdown for agents and instructions too). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Replace skill file list with a dropdown selector The two-column file browser (side list + content pane) was cramped, splitting the already-narrow main column in half. Replace the side list with a dropdown in the file view header so the content pane spans the full width. - Multi-file skills get a <select> grouped by folder via <optgroup>, SKILL.md first. Single-file skills keep a static filename label. - Client script drives selection from the <select> change event instead of the removed file buttons; deep links, copy-file, Download ZIP, and Share all read the file list from the select options. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Fix skill sidebar card overflowing its column The install command in the actions card used white-space: nowrap, which gave the grid tracks a large min-content size. Grid items default to min-width: auto (won't shrink below content), so the actions card grew past the 352px sidebar, making it look wider than the agent/instruction sidebars. Add min-width: 0 down the sidebar grid chain so the command box stays within the column and scrolls horizontally instead. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Make skill file content pane grow to full page height Remove the fixed max-height/overflow on .skill-file-content so file content flows to natural height and the page scrolls, instead of a nested inner scroll region. Shiki <pre> keeps its own horizontal scroll. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Center-align skill file picker label with the select Reset the Starlight-injected margin-top on .skill-file-select so the 'File' label and the dropdown share a common vertical midline. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Render code files edge-to-edge in skill file viewer For code files, drop the container padding and the pre border/radius so the highlighted code fills the full column width. Markdown files keep their padded, bordered layout. Toggled via an is-code class on the content pane based on the selected file kind. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add dedicated detail pages for hooks with a reusable file browser Hooks had no good install path (manual copy or ZIP), so mirror the Skills dedicated-page pattern: a multi-file browser plus a Download ZIP action, replacing the modal on the hooks listing. Generalise the Skills-specific file browser so both resource types share one implementation: - Rename SkillFileBrowser.astro -> FileBrowser.astro with neutral props. - Rename skill-detail.ts -> file-browser.ts with neutral data attributes (data-file-browser-page, data-bundle-id, data-primary-file). - Rescope install-slot styles under a shared .bundle-detail-page class. - generate-website-data: rename getSkillFiles -> getFolderFiles and emit a files[] + readmeFileName for each hook. Hooks list cards now deep-link to /hook/<id>/ instead of opening a modal. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add dedicated detail pages for workflows Migrate agentic workflows from the popup/modal viewer to dedicated per-item pages (/workflow/<id>/) with deep linking, matching the pattern used for agents, instructions, skills, and hooks. Workflows are single .md files, so they reuse the single-file detail components (Main, Sidebar, Header, Breadcrumb, RawMarkdown) and the resource-detail client script. Since workflows have no VS Code install, the install slot instead documents the gh aw CLI flow and offers Download + Copy markdown actions. Also rescope the shared install-slot CSS from .bundle-detail-page to .detail-actions-card so skill, hook, and workflow pages share it without a page-specific class, and drop the now-unused bundle-detail-page class from the skill and hook pages. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Format workflow install note Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add dedicated plugin detail pages Replace the plugins modal with dedicated /plugin/<id>/ pages that deep link, render the bundled README, and surface an Included items section linking each constituent agent/skill/instruction/hook to its own detail page (falling back to GitHub for items without a page, e.g. extensions). - generate-website-data: resolve plugin items to detail URLs + titles, add readmeFile for local and extension-derived plugins - new IncludedItems component groups bundled resources by kind - plugin/[id].astro handles local, extension-derived, and external plugins with VS Code + CLI install actions - resource-detail: copy-install handler shared with detail pages - plugins list cards now deep link; modal wiring removed Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Show plugin version, ref, and commit in detail sidebar Capture version from plugin.json (local + extension-derived) and external.json, and surface it in the plugin detail Details card. For external plugins, also show the pinned source ref and short commit SHA when present. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add dedicated canvas extension detail pages with preview gallery Replace the extensions modal with per-extension pages at /extension/<id>/, mirroring the skill/plugin detail layout. Each page shows a preview image gallery, README docs (or an About fallback), and a sidebar with install actions and details (version, canvas ID, keywords, author, commit). - generate-website-data.mjs: emit readmeFile for extensions - extensions-render.ts: deep-link cards to detail pages - extensions.ts: strip modal/gallery wiring, keep filter/sort/copy actions - resource-detail.ts: add copy-install-url action - extension-gallery.ts: thumbnail switching for multi-image previews Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add VS Code Insiders and GitHub Copilot app install links Introduce a shared PluginInstall component used by both plugin and canvas extension detail pages. It renders a split-button dropdown deep-linking into VS Code, VS Code Insiders, and the GitHub Copilot app (ghapp://), plus the CLI command for internal items. - Internal plugins/extensions: ghapp://plugins/install?source=<id>@awesome-copilot - External plugins: ghapp://plugins/marketplace/add?source=<owner/repo> - ghapp source values are URL-encoded per the app's deep-link contract Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Make GitHub Copilot app the default install option Promote the ghapp:// deep link to the primary split-button action and list it first in the dropdown, ahead of VS Code and VS Code Insiders. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Ignoring impeccable files * Make Extensions grid Copy Install a direct Copilot app install Replace the CLI-command copy button on extension cards with an 'Install in Copilot app' deep link (ghapp://plugins/install) for internal extensions, matching the detail page. External extensions keep the Copy URL fallback since they have no Copilot app install path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address PR review feedback on detail pages - Remove DOM innerHTML read/write round trip in file browser cache (CodeQL js/xss-through-dom); seed primary file from raw text and render lazily. - Use Shiki dual light/dark themes in the file browser and add dark mode CSS overrides. - Guard decodeURIComponent for #file= deep links against malformed percent-encoding. - Slugify SidebarChips title before using it in the tag class name. - Use a neutral aria-label on the shared detail Sidebar. - URL-encode the external plugin source in VS Code and Insiders install links. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Prevent client-side path traversal in file deep links Addresses the CSPT-to-XSS class reported in github/open-source#1739. A '#file=' hash value containing '../' sequences could resolve outside the awesome-copilot repo prefix once normalized by fetch, loading attacker-controlled content that was then rendered into the page. - Add isSafeRepoFilePath() and enforce it at the raw-URL choke point (getRawGitHubUrl, fetchFileContent, downloadFile, getVSCodeInstallUrl), so no consumer can escape the repo prefix. - Validate the decoded '#file=' path in the modal hash handler and guard its decodeURIComponent against malformed input. The new hash-based file browser already restricts deep links to an allowlist of build-time file descriptors; the choke-point guard is an additional backstop. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Fix accessibility violations on resource detail pages Run the a11y audit against the new detail pages and resolve every critical/serious axe violation it surfaced: - Nest the detail column as a <div> instead of a second <main>, fixing the duplicate/non-top-level/non-unique landmark rules on every detail page. - Add a shared enhanceMarkdownA11y() helper that makes rendered <pre>/<table> blocks keyboard focusable and gives task-list checkboxes a state-based accessible name; apply it at build time and in the client file browser. - Make Shiki-highlighted code and install-command <code> blocks focusable. - Underline links inside rendered docs and install notes so they are distinguishable without color, overriding the global #main-content reset. - Extend the a11y audit to cover one representative page per detail type. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Fix file browser GitHub URL handling Encode selected file paths before assigning GitHub detail links and render load errors with DOM APIs so DOM-derived file names are not reinterpreted as markup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address PR review feedback on detail pages and file browser - Deep-link plugin extension items to their /extension/<id>/ detail pages: generate canvas extensions before building the resource index, and index extensions (by id and folder basename) so resolvePluginItem can resolve them. - Render image files in the bundle file browser via an <img> tag built from the safe raw URL instead of decoding binary assets as UTF-8 text; skip the copy-file action for images. - Use a neutral 'Install' heading on internal plugin and extension pages so the split-button primary label accurately communicates the target. - Warn (with the file path) when readResourceMarkdown fails instead of swallowing the error silently, keeping the build unbroken. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address PR review feedback on install links, dropdown a11y, and ZIP downloads - externalRepoUrl (plugin detail pages) now builds the 'View on GitHub' tree URL from the pinned source.ref or source.sha, falling back to main only when neither is present, so the link matches the sidebar Ref/Commit chips. - Install split-button dropdown on resource detail pages is now keyboard accessible: opening focuses the first item, ArrowUp/ArrowDown wrap, Home/End jump, and Escape closes and restores focus to the toggle, mirroring modal.ts. - downloadZipBundle fetches each file as an ArrayBuffer and hands it to JSZip so binary assets (PNG/JPG/etc.) are preserved instead of corrupted by UTF-8 text decoding. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address PR review feedback on external repo links and workflow install note - externalRepoUrl now uses a pinned source.ref/sha even when the external plugin has no path, returning /tree/<ref> so "View on GitHub" points at the pinned revision and stays consistent with the sidebar Ref/Commit chips. - Reflow the workflow install note so each inline code and link element stays on a single line (using explicit whitespace expressions for word spacing), removing the split end-tag artifacts while preserving the exact rendered text and spacing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Harden repo path validation and fix Included Items file links - isSafeRepoFilePath now fails closed on any "%" so percent-encoded dot-segments (%2e%2e and double-encoded %252e%252e) cannot be normalized back into path traversal by the browser URL parser during fetch, and also rejects "." and empty path segments. Legitimate repo paths never contain these. - IncludedItems githubHref now links file paths via /blob/ and directories via /tree/, detecting files by a trailing extension on the last path segment, so the fallback links for agent/instruction/command items no longer 404. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Harden detail pages: sanitize markdown, fix search deep-links and external URLs Addresses rubber-duck review items 1-5 for the detail-page migration: 1. Sanitize rendered markdown as untrusted HTML. Add isomorphic sanitize-html helper (isomorphic-dompurify) applied in the build-time pipeline (detail-page.ts) and the client file browser before a11y enhancement, so marked output can no longer inject scripts/handlers. 2. Point Pagefind search results at canonical /type/id/ detail pages instead of inert #file= listing hashes for types that have a detail page. 3. Sanitize external/generated URLs on plugin and extension detail pages and their render scripts so only http(s) links are emitted. 4. Add a shared externalRepoUrl helper that pins GitHub links to the source ref/sha (preferring sha) with encoded path segments, replacing the duplicated always-main logic in the pages, modal, and renderers. 5. Handle #file= hash navigation after initial load in the file browser via a hashchange listener. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Detail pages: clickable filter chips + code-block copy buttons (#2255) * Add clickable filter chips on detail pages Turn read-only metadata chips into navigation: tags (hooks/plugins), keywords (extensions), and extensions (instructions) now link to their list page pre-filtered by that value (e.g. /hooks/?tag=testing). SidebarChips gains optional filterBase/filterParam props; when both are set each chip renders as an <a> with an aria-label and hover/focus styling, otherwise it stays a plain <span>. Agent/skill/workflow chips are unchanged. Filtering was verified end to end against each list page's existing query-param handling. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add copy buttons to documentation code blocks Detail-page markdown is rendered as plain <pre><code> with no syntax highlighter, so code and config snippets had no copy affordance. Add a hover-revealed copy button to every code block in the rendered docs: copies to the clipboard, shows a toast, and swaps to a check icon for confirmation. Buttons are keyboard-accessible, always visible on touch devices, and respect reduced-motion. The sidebar frontmatter block is left untouched. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix detail-page GitHub links, empty-state link contrast, and file cache Addresses four PR review comments on the detail-page migration: - skill/hook detail pages: build the sidebar "View on GitHub" link from a /tree/main base instead of /blob/main, since item.path is a directory and /blob/<dir> URLs 404. The FileBrowser githubBase stays on /blob for individual file links. - global.css: include .detail-empty a in the underline override so links in empty-state notes stay distinguishable without relying on color alone (WCAG 1.4.1 / axe link-in-text-block). - file-browser.ts: seed the primary file's cache with its already-rendered (frontmatter-stripped) HTML in addition to raw text, so re-selecting the primary file after navigating away no longer re-renders the raw source and surfaces frontmatter. Copy still uses the full raw text. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Ayan Gupta <74832088+ayangupt@users.noreply.github.com>
This commit is contained in:
@@ -24,3 +24,4 @@ website/public/llms.txt
|
||||
*.sln
|
||||
obj/
|
||||
bin/
|
||||
.impeccable
|
||||
|
||||
+129
-15
@@ -251,6 +251,10 @@ function generateHooksData(gitDates) {
|
||||
.replace(/\\/g, "/");
|
||||
const readmeRelativePath = `${relativePath}/README.md`;
|
||||
|
||||
// Get all files in the hook folder recursively (for the file browser and
|
||||
// ZIP download on the detail page).
|
||||
const files = getFolderFiles(hookPath, relativePath);
|
||||
|
||||
// Track unique values
|
||||
(metadata.hooks || []).forEach((h) => allHookTypes.add(h));
|
||||
(metadata.tags || []).forEach((t) => allTags.add(t));
|
||||
@@ -262,8 +266,10 @@ function generateHooksData(gitDates) {
|
||||
hooks: metadata.hooks || [],
|
||||
tags: metadata.tags || [],
|
||||
assets: metadata.assets || [],
|
||||
files,
|
||||
path: relativePath,
|
||||
readmeFile: readmeRelativePath,
|
||||
readmeFileName: "README.md",
|
||||
lastUpdated: gitDates.get(readmeRelativePath) || null,
|
||||
});
|
||||
}
|
||||
@@ -465,7 +471,7 @@ function generateSkillsData(gitDates) {
|
||||
.replace(/\\/g, "/");
|
||||
|
||||
// Get all files in the skill folder recursively
|
||||
const files = getSkillFiles(skillPath, relativePath);
|
||||
const files = getFolderFiles(skillPath, relativePath);
|
||||
|
||||
// Get last updated from SKILL.md file
|
||||
const skillFilePath = `${relativePath}/SKILL.md`;
|
||||
@@ -511,9 +517,9 @@ function generateSkillsData(gitDates) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all files in a skill folder recursively
|
||||
* Get all files in a resource folder (skill or hook) recursively.
|
||||
*/
|
||||
function getSkillFiles(skillPath, relativePath) {
|
||||
function getFolderFiles(skillPath, relativePath) {
|
||||
const files = [];
|
||||
|
||||
function walkDir(dir, relDir) {
|
||||
@@ -555,10 +561,89 @@ function getAgentFiles(agentDir, pluginRootPath) {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a lookup index of resource id -> { title, url } for the kinds that have
|
||||
* dedicated detail pages, so plugin items can deep-link to them.
|
||||
*/
|
||||
function buildResourceIndex({ agents, skills, instructions, hooks, extensions }) {
|
||||
const toMap = (items, urlPrefix) => {
|
||||
const map = new Map();
|
||||
for (const item of items || []) {
|
||||
if (!item?.id) continue;
|
||||
map.set(item.id, {
|
||||
title: item.title || item.id,
|
||||
url: `/${urlPrefix}/${item.id}/`,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
};
|
||||
const extensionMap = new Map();
|
||||
for (const item of extensions || []) {
|
||||
if (!item?.id) continue;
|
||||
|
||||
const entry = {
|
||||
title: item.name || item.title || item.id,
|
||||
url: `/extension/${item.id}/`,
|
||||
};
|
||||
const keys = [
|
||||
item.id,
|
||||
item.extensionId,
|
||||
item.path ? pluginItemCandidateId(item.path) : null,
|
||||
].filter(Boolean);
|
||||
|
||||
for (const key of keys) {
|
||||
if (!extensionMap.has(key)) {
|
||||
extensionMap.set(key, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agent: toMap(agents, "agent"),
|
||||
skill: toMap(skills, "skill"),
|
||||
instruction: toMap(instructions, "instruction"),
|
||||
hook: toMap(hooks, "hook"),
|
||||
extension: extensionMap,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the candidate resource id for a plugin item path (basename without a
|
||||
* known resource extension), e.g. "./skills/foo/" -> "foo",
|
||||
* "plugins/x/agents/bar.md" -> "bar".
|
||||
*/
|
||||
function pluginItemCandidateId(itemPath) {
|
||||
const trimmed = String(itemPath || "")
|
||||
.replace(/^\.\/+/, "")
|
||||
.replace(/\/+$/, "");
|
||||
const base = trimmed.split("/").pop() || "";
|
||||
return base
|
||||
.replace(/\.agent\.md$/i, "")
|
||||
.replace(/\.prompt\.md$/i, "")
|
||||
.replace(/\.instructions\.md$/i, "")
|
||||
.replace(/\.md$/i, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich a plugin item ({ kind, path }) with a display title and, when the item
|
||||
* resolves to a known resource with a detail page, a detailUrl.
|
||||
*/
|
||||
function resolvePluginItem(item, resourceIndex) {
|
||||
const candidateId = pluginItemCandidateId(item.path);
|
||||
const lookup = resourceIndex?.[item.kind];
|
||||
const match = lookup?.get(candidateId);
|
||||
|
||||
return {
|
||||
...item,
|
||||
title: match?.title || candidateId || item.path,
|
||||
detailUrl: match?.url || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate plugins metadata
|
||||
*/
|
||||
function generatePluginsData(gitDates) {
|
||||
function generatePluginsData(gitDates, resourceIndex = {}) {
|
||||
const plugins = [];
|
||||
const extensionEntriesByName = new Map();
|
||||
|
||||
@@ -594,16 +679,25 @@ function generatePluginsData(gitDates) {
|
||||
? [...new Set(extensionPlugin.keywords.filter((keyword) => typeof keyword === "string").map((keyword) => keyword.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b))
|
||||
: [];
|
||||
const relPath = `extensions/${extensionDirName}`;
|
||||
const extensionItem = {
|
||||
kind: "extension",
|
||||
path: relPath,
|
||||
};
|
||||
const extensionItem = resolvePluginItem(
|
||||
{
|
||||
kind: "extension",
|
||||
path: relPath,
|
||||
},
|
||||
resourceIndex
|
||||
);
|
||||
const extReadmePath = path.join(extensionDir, "README.md");
|
||||
const extReadmeFile = fs.existsSync(extReadmePath)
|
||||
? `${relPath}/README.md`
|
||||
: null;
|
||||
|
||||
extensionEntriesByName.set(pluginName, {
|
||||
id: pluginName,
|
||||
name: pluginName,
|
||||
description: pluginDescription,
|
||||
path: relPath,
|
||||
readmeFile: extReadmeFile,
|
||||
version: normalizeText(extensionPlugin.version, null),
|
||||
tags: extensionKeywords,
|
||||
itemCount: 1,
|
||||
items: [extensionItem],
|
||||
@@ -661,16 +755,23 @@ function generatePluginsData(gitDates) {
|
||||
...(data.commands || []).map((p) => ({ kind: "prompt", path: p })),
|
||||
...(data.skills || []).map((p) => ({ kind: "skill", path: p })),
|
||||
...extensionItems,
|
||||
];
|
||||
].map((item) => resolvePluginItem(item, resourceIndex));
|
||||
|
||||
const tags = data.keywords || data.tags || [];
|
||||
const pluginName = data.name || dir.name;
|
||||
|
||||
const readmePath = path.join(pluginDir, "README.md");
|
||||
const readmeFile = fs.existsSync(readmePath)
|
||||
? `${relPath}/README.md`
|
||||
: null;
|
||||
|
||||
plugins.push({
|
||||
id: dir.name,
|
||||
name: pluginName,
|
||||
description: data.description || "",
|
||||
path: relPath,
|
||||
readmeFile,
|
||||
version: normalizeText(data.version, null),
|
||||
tags: tags,
|
||||
itemCount: items.length,
|
||||
items: items,
|
||||
@@ -720,6 +821,7 @@ function generatePluginsData(gitDates) {
|
||||
name: ext.name,
|
||||
description: ext.description || "",
|
||||
path: `plugins/${ext.name}`,
|
||||
version: normalizeText(ext.version, null),
|
||||
tags: tags,
|
||||
itemCount: 0,
|
||||
items: [],
|
||||
@@ -1196,6 +1298,9 @@ function generateCanvasManifest(gitDates, commitSha) {
|
||||
);
|
||||
const extensionName = normalizeText(pluginJson.name, normalizeText(packageJson.name, dir.name));
|
||||
const extensionVersion = normalizeText(pluginJson.version, normalizeText(packageJson.version, "1.0.0"));
|
||||
const readmeFile = fs.existsSync(path.join(extensionDir, "README.md"))
|
||||
? `${relPath}/README.md`
|
||||
: null;
|
||||
const screenshots = resolveExtensionScreenshots(pluginJson, extensionDir, relPath, commitSha);
|
||||
const canvasFiles = getExtensionCanvasFiles(extensionDir);
|
||||
const canvases = [];
|
||||
@@ -1224,6 +1329,7 @@ function generateCanvasManifest(gitDates, commitSha) {
|
||||
pluginName: extensionName,
|
||||
name: canvasName,
|
||||
version: extensionVersion,
|
||||
readmeFile,
|
||||
description: canvasDescription,
|
||||
path: relPath,
|
||||
ref: commitSha,
|
||||
@@ -1299,6 +1405,7 @@ function generateCanvasManifest(gitDates, commitSha) {
|
||||
pluginName: null,
|
||||
name,
|
||||
version: normalizeText(ext?.version, "1.0.0"),
|
||||
readmeFile: null,
|
||||
description: normalizeText(ext?.description, "External canvas extension"),
|
||||
path: null,
|
||||
ref: null,
|
||||
@@ -1686,12 +1793,6 @@ async function main() {
|
||||
const skills = skillsData.items;
|
||||
console.log(`✓ Generated ${skills.length} skills`);
|
||||
|
||||
const pluginsData = generatePluginsData(gitDates);
|
||||
const plugins = pluginsData.items;
|
||||
console.log(
|
||||
`✓ Generated ${plugins.length} plugins (${pluginsData.filters.tags.length} tags)`
|
||||
);
|
||||
|
||||
const extensionManifestData = generateCanvasManifest(gitDates, commitSha);
|
||||
const extensionsData = generateExtensionsData(extensionManifestData);
|
||||
const extensions = extensionsData.items;
|
||||
@@ -1699,6 +1800,19 @@ async function main() {
|
||||
`✓ Generated ${extensions.length} extensions (${extensionsData.filters.keywords.length} keywords)`
|
||||
);
|
||||
|
||||
const resourceIndex = buildResourceIndex({
|
||||
agents,
|
||||
skills,
|
||||
instructions,
|
||||
hooks,
|
||||
extensions,
|
||||
});
|
||||
const pluginsData = generatePluginsData(gitDates, resourceIndex);
|
||||
const plugins = pluginsData.items;
|
||||
console.log(
|
||||
`✓ Generated ${plugins.length} plugins (${pluginsData.filters.tags.length} tags)`
|
||||
);
|
||||
|
||||
const toolsData = generateToolsData();
|
||||
const tools = toolsData.items;
|
||||
console.log(
|
||||
|
||||
Generated
+549
@@ -15,6 +15,7 @@
|
||||
"choices.js": "^11.2.2",
|
||||
"front-matter": "^4.0.2",
|
||||
"gray-matter": "^4.0.3",
|
||||
"isomorphic-dompurify": "^2.36.0",
|
||||
"jszip": "^3.10.1",
|
||||
"marked": "^18.0.2",
|
||||
"shiki": "^4.0.2"
|
||||
@@ -24,6 +25,56 @@
|
||||
"playwright": "^1.49.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@acemir/cssom": {
|
||||
"version": "0.9.31",
|
||||
"resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
|
||||
"integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "5.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@csstools/css-calc": "^3.2.0",
|
||||
"@csstools/css-color-parser": "^4.1.0",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "6.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
|
||||
"integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.1.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/generational-cache": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
||||
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/nwsapi": {
|
||||
"version": "2.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
||||
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@astrojs/compiler-binding": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.2.3.tgz",
|
||||
@@ -415,6 +466,18 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-tree": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@bruits/satteri-darwin-arm64": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.9.4.tgz",
|
||||
@@ -568,6 +631,140 @@
|
||||
"node": ">= 20.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
|
||||
"integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
|
||||
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz",
|
||||
"integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.1.0",
|
||||
"@csstools/css-calc": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz",
|
||||
"integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"peerDependencies": {
|
||||
"css-tree": "^3.2.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"css-tree": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ctrl/tinycolor": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
|
||||
@@ -1024,6 +1221,23 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
|
||||
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@noble/hashes": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@expressive-code/core": {
|
||||
"version": "0.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@expressive-code/core/-/core-0.44.0.tgz",
|
||||
@@ -2181,6 +2395,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -2214,6 +2435,15 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/am-i-vibing": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.4.0.tgz",
|
||||
@@ -2445,6 +2675,15 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
@@ -2723,6 +2962,34 @@
|
||||
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz",
|
||||
"integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^5.0.1",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.0.28",
|
||||
"css-tree": "^3.1.0",
|
||||
"lru-cache": "^11.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2740,6 +3007,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decode-named-character-reference": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
||||
@@ -2877,6 +3150,15 @@
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.11",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
|
||||
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
@@ -3712,6 +3994,18 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
|
||||
@@ -3744,6 +4038,32 @@
|
||||
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.3",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.3.tgz",
|
||||
@@ -3912,6 +4232,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-wsl": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
|
||||
@@ -3933,6 +4259,19 @@
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isomorphic-dompurify": {
|
||||
"version": "2.36.0",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-2.36.0.tgz",
|
||||
"integrity": "sha512-E8YkGyPY3a/U5s0WOoc8Ok+3SWL/33yn2IHCoxCFLBUUPVy9WGa++akJZFxQCcJIhI+UvYhbrbnTIFQkHKZbgA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.3.1",
|
||||
"jsdom": "^28.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.5"
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
@@ -3955,6 +4294,70 @@
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "28.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
|
||||
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.31",
|
||||
"@asamuzakjp/dom-selector": "^6.8.1",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"cssstyle": "^6.0.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^6.0.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"parse5": "^8.0.0",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.0",
|
||||
"undici": "^7.21.0",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/entities": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonc-parser": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
|
||||
@@ -5821,6 +6224,15 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/radix3": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz",
|
||||
@@ -6156,6 +6568,15 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
@@ -6296,6 +6717,18 @@
|
||||
"node": ">=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/section-matter": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
||||
@@ -6543,6 +6976,12 @@
|
||||
"url": "https://opencollective.com/svgo"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
@@ -6583,6 +7022,48 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.4.7",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.7.tgz",
|
||||
"integrity": "sha512-56L0/9HELHSsG1bFCzay8UoLxzRL7kpFf7Wl5q/kSYwiSJGACvro61xnKzPNM+SadxllzdtXsKDSXE7HPeqIAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.4.7"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.4.7",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.7.tgz",
|
||||
"integrity": "sha512-rNlAI8fKn/JckBMUSbNL/ES2kmDiurWaE49l+ikwEc9A6lFR7gMx9AhgQMQKBK4H5w4pKLH64JzZfB99uRsGNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
|
||||
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/trim-lines": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||
@@ -6628,6 +7109,15 @@
|
||||
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
@@ -7052,6 +7542,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/web-namespaces": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
||||
@@ -7062,6 +7564,38 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which-pm-runs": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz",
|
||||
@@ -7071,6 +7605,21 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xxhash-wasm": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"choices.js": "^11.2.2",
|
||||
"front-matter": "^4.0.2",
|
||||
"gray-matter": "^4.0.3",
|
||||
"isomorphic-dompurify": "^2.36.0",
|
||||
"jszip": "^3.10.1",
|
||||
"marked": "^18.0.2",
|
||||
"shiki": "^4.0.2"
|
||||
|
||||
@@ -36,6 +36,15 @@ const routes = [
|
||||
'/contributors/',
|
||||
'/learning-hub/cookbook/',
|
||||
'/learning-hub/github-copilot-app/',
|
||||
// Representative dedicated detail pages (one per resource type) so the audit
|
||||
// covers the shared detail layout, sidebar, install buttons, and file browser.
|
||||
'/agent/accessibility/',
|
||||
'/instruction/a11y/',
|
||||
'/skill/acquire-codebase-knowledge/',
|
||||
'/hook/dependency-license-checker/',
|
||||
'/workflow/daily-issues-report/',
|
||||
'/plugin/accessibility-kanban/',
|
||||
'/extension/accessibility-kanban/',
|
||||
];
|
||||
|
||||
const themes = ['dark', 'light'];
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
export type Breadcrumb = {
|
||||
paths: { name: string; href?: string }[];
|
||||
title: string;
|
||||
};
|
||||
|
||||
const { paths, title } = Astro.props as Breadcrumb;
|
||||
---
|
||||
|
||||
<nav class="detail-breadcrumbs" aria-label="Breadcrumb">
|
||||
{
|
||||
paths.map((path, index) => (
|
||||
<>
|
||||
{path.href ? (
|
||||
<a href={path.href}>{path.name}</a>
|
||||
) : (
|
||||
<span>{path.name}</span>
|
||||
)}
|
||||
{index < paths.length - 1 && <span aria-hidden="true">/</span>}
|
||||
</>
|
||||
))
|
||||
}
|
||||
<span aria-hidden="true">/</span>
|
||||
<span aria-current="page">{title}</span>
|
||||
</nav>
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
import { getFileMeta } from "../../lib/file-types";
|
||||
|
||||
export interface BrowserFile {
|
||||
name: string;
|
||||
path: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface FileBrowserProps {
|
||||
files: BrowserFile[];
|
||||
/** Full repo path of the primary file, e.g. skills/foo/SKILL.md or hooks/foo/README.md */
|
||||
primaryPath: string;
|
||||
/** Display name of the primary file, e.g. SKILL.md or README.md */
|
||||
primaryName: string;
|
||||
/** Pre-rendered HTML for the primary file (shown by default) */
|
||||
primaryHtml: string;
|
||||
/** GitHub blob base, e.g. https://github.com/github/awesome-copilot/blob/main */
|
||||
githubBase: string;
|
||||
}
|
||||
|
||||
const { files, primaryPath, primaryName, primaryHtml, githubBase } =
|
||||
Astro.props as FileBrowserProps;
|
||||
|
||||
// Primary file first, then everything else alphabetically by name.
|
||||
const sortedFiles = [...files].sort((a, b) => {
|
||||
if (a.path === primaryPath) return -1;
|
||||
if (b.path === primaryPath) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const otherFiles = sortedFiles.filter((f) => f.path !== primaryPath);
|
||||
const rootFiles = otherFiles.filter((f) => !f.name.includes("/"));
|
||||
|
||||
// Group nested files by their top-level folder for optgroup rendering.
|
||||
const folderGroups = new Map<string, BrowserFile[]>();
|
||||
for (const file of otherFiles) {
|
||||
const slash = file.name.indexOf("/");
|
||||
if (slash === -1) continue;
|
||||
const folder = file.name.slice(0, slash);
|
||||
const bucket = folderGroups.get(folder) ?? [];
|
||||
bucket.push(file);
|
||||
folderGroups.set(folder, bucket);
|
||||
}
|
||||
const sortedFolders = [...folderGroups.keys()].sort((a, b) =>
|
||||
a.localeCompare(b)
|
||||
);
|
||||
|
||||
const hasMultipleFiles = sortedFiles.length > 1;
|
||||
|
||||
function optionAttrs(file: BrowserFile) {
|
||||
const meta = getFileMeta(file.name);
|
||||
return {
|
||||
value: file.path,
|
||||
"data-file-name": file.name,
|
||||
"data-file-lang": meta.lang,
|
||||
"data-file-kind": meta.kind,
|
||||
};
|
||||
}
|
||||
---
|
||||
|
||||
<section
|
||||
class="skill-file-browser"
|
||||
aria-label="Bundle files"
|
||||
data-file-browser
|
||||
data-github-base={githubBase}
|
||||
data-primary-file={primaryPath}
|
||||
>
|
||||
<div class="skill-file-view">
|
||||
<header class="skill-file-view-header">
|
||||
{
|
||||
hasMultipleFiles ? (
|
||||
<div class="skill-file-picker">
|
||||
<label class="skill-file-picker-label" for="file-browser-select">
|
||||
File
|
||||
</label>
|
||||
<select
|
||||
id="file-browser-select"
|
||||
class="skill-file-select"
|
||||
data-file-select
|
||||
aria-label="Select a file to view"
|
||||
>
|
||||
<option {...optionAttrs({ name: primaryName, path: primaryPath })} selected>
|
||||
{primaryName}
|
||||
</option>
|
||||
{rootFiles.map((file) => (
|
||||
<option {...optionAttrs(file)}>{file.name}</option>
|
||||
))}
|
||||
{sortedFolders.map((folder) => (
|
||||
<optgroup label={`${folder}/`}>
|
||||
{folderGroups.get(folder)!.map((file) => (
|
||||
<option {...optionAttrs(file)}>
|
||||
{file.name.slice(folder.length + 1)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<code class="skill-file-current" data-current-file-name>
|
||||
{primaryName}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
<div class="skill-file-view-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-small"
|
||||
data-action="copy-file"
|
||||
title="Copy file contents"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
><path
|
||||
d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"
|
||||
></path><path
|
||||
d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"
|
||||
></path></svg
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<a
|
||||
class="btn btn-secondary btn-small"
|
||||
data-file-github
|
||||
href={`${githubBase}/${primaryPath}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="View file on GitHub">GitHub</a
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="skill-file-content article-content"
|
||||
data-file-content
|
||||
set:html={primaryHtml}
|
||||
/>
|
||||
<div class="skill-file-status" data-file-status hidden></div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
export type HeaderItem = {
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const { item } = Astro.props as { item: HeaderItem };
|
||||
---
|
||||
|
||||
<header class="detail-header">
|
||||
<h1>{item.title}</h1>
|
||||
{item.description && <p class="detail-description">{item.description}</p>}
|
||||
</header>
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
export interface PluginIncludedItem {
|
||||
kind: string;
|
||||
path: string;
|
||||
title?: string;
|
||||
detailUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface IncludedItemsProps {
|
||||
items: PluginIncludedItem[];
|
||||
pluginPath: string;
|
||||
githubBase: string;
|
||||
}
|
||||
|
||||
const { items, pluginPath, githubBase } = Astro.props as IncludedItemsProps;
|
||||
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
agent: "Agents",
|
||||
skill: "Skills",
|
||||
instruction: "Instructions",
|
||||
hook: "Hooks",
|
||||
prompt: "Commands",
|
||||
extension: "Extensions",
|
||||
};
|
||||
|
||||
const KIND_ORDER = ["agent", "skill", "instruction", "hook", "prompt", "extension"];
|
||||
|
||||
function githubHref(itemPath: string): string {
|
||||
const normalized = itemPath.replace(/^\.\/+/, "").replace(/\/+$/, "");
|
||||
// Repo-root-relative paths (e.g. extensions/foo, plugins/foo) are used as-is;
|
||||
// plugin-relative paths are resolved against the plugin folder.
|
||||
const isRepoRelative = /^[a-z0-9._-]+\//i.test(normalized) &&
|
||||
(normalized.startsWith("extensions/") ||
|
||||
normalized.startsWith("plugins/") ||
|
||||
normalized.startsWith("agents/") ||
|
||||
normalized.startsWith("skills/") ||
|
||||
normalized.startsWith("instructions/") ||
|
||||
normalized.startsWith("hooks/"));
|
||||
const repoPath = isRepoRelative ? normalized : `${pluginPath}/${normalized}`;
|
||||
// GitHub uses /blob/ for files and /tree/ for directories; /tree/<ref>/<file>
|
||||
// returns 404. Detect files by a trailing extension on the last path segment.
|
||||
const lastSegment = repoPath.split("/").pop() ?? "";
|
||||
const isFile = /\.[a-z0-9]+$/i.test(lastSegment);
|
||||
const base = isFile ? githubBase.replace("/tree/", "/blob/") : githubBase;
|
||||
return `${base}/${repoPath}`;
|
||||
}
|
||||
|
||||
const grouped = KIND_ORDER.map((kind) => ({
|
||||
kind,
|
||||
label: KIND_LABELS[kind] ?? kind,
|
||||
entries: (items ?? []).filter((item) => item.kind === kind),
|
||||
})).filter((group) => group.entries.length > 0);
|
||||
---
|
||||
|
||||
{
|
||||
grouped.length > 0 && (
|
||||
<section class="detail-section included-items" aria-labelledby="included-heading">
|
||||
<h2 id="included-heading">Included items</h2>
|
||||
<p class="included-items-hint">
|
||||
This plugin bundles the following resources. Installing the plugin brings
|
||||
them all in together.
|
||||
</p>
|
||||
{grouped.map((group) => (
|
||||
<div class="included-group">
|
||||
<h3 class="included-group-title">{group.label}</h3>
|
||||
<ul class="included-list">
|
||||
{group.entries.map((item) => {
|
||||
const href = item.detailUrl ?? githubHref(item.path);
|
||||
const isExternal = !item.detailUrl;
|
||||
return (
|
||||
<li class="included-entry">
|
||||
<a
|
||||
class="included-link"
|
||||
href={href}
|
||||
{...(isExternal
|
||||
? { target: "_blank", rel: "noopener" }
|
||||
: {})}
|
||||
>
|
||||
<span class="included-kind-badge">{item.kind}</span>
|
||||
<span class="included-title">{item.title || item.path}</span>
|
||||
{isExternal && (
|
||||
<span class="included-external-hint">GitHub ↗</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
const { vscodeUrl, insidersUrl, rawMarkdown } = Astro.props as { vscodeUrl: string; insidersUrl: string; rawMarkdown?: string };
|
||||
---
|
||||
|
||||
<div class="install-dropdown" data-install-menu>
|
||||
<a
|
||||
class="btn btn-primary install-btn-main"
|
||||
href={vscodeUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"><path d="M12 5v14M5 12h14"></path></svg
|
||||
>
|
||||
Install
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary install-btn-toggle"
|
||||
aria-label="More install options"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
data-install-toggle
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="12"
|
||||
height="12"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
><path
|
||||
d="M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z"
|
||||
></path></svg
|
||||
>
|
||||
</button>
|
||||
<div class="install-dropdown-menu" role="menu">
|
||||
<a href={vscodeUrl} target="_blank" rel="noopener" role="menuitem"
|
||||
>Install in VS Code</a
|
||||
>
|
||||
<a href={insidersUrl} target="_blank" rel="noopener" role="menuitem"
|
||||
>Install in VS Code Insiders</a
|
||||
>
|
||||
<div class="dropdown-divider" role="separator" aria-hidden="true"></div>
|
||||
<a href="#" role="menuitem" data-action="download">Download file</a>
|
||||
{
|
||||
rawMarkdown && (
|
||||
<a href="#" role="menuitem" data-action="copy-markdown">
|
||||
Copy markdown
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
const githubUrl = Astro.props.githubUrl as string;
|
||||
const markdownHtml = Astro.props.markdownHtml as string;
|
||||
---
|
||||
|
||||
<div class="detail-main">
|
||||
{markdownHtml ? (
|
||||
<section class="detail-section" aria-labelledby="doc-heading">
|
||||
<h2 id="doc-heading">Documentation</h2>
|
||||
<div class="article-content" set:html={markdownHtml} />
|
||||
</section>
|
||||
) : (
|
||||
<section class="detail-section">
|
||||
<p class="detail-empty">Documentation preview is unavailable. View the file <a href={githubUrl} target="_blank" rel="noopener">on GitHub</a>.</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
@@ -0,0 +1,171 @@
|
||||
---
|
||||
/**
|
||||
* Install actions for plugins and canvas extensions.
|
||||
*
|
||||
* Renders a split-button dropdown that deep-links into VS Code, VS Code
|
||||
* Insiders, and the GitHub Copilot app, plus an optional CLI command block.
|
||||
* The dropdown open/close is wired by resource-detail.ts via `data-install-menu`.
|
||||
*/
|
||||
export interface PluginInstallProps {
|
||||
isExternal?: boolean;
|
||||
/** awesome-copilot plugin id (internal installs). */
|
||||
pluginId?: string | null;
|
||||
/** Upstream marketplace source (owner/repo or git URL) for external installs. */
|
||||
externalSource?: string | null;
|
||||
/** CLI install command (internal only). */
|
||||
installCommand?: string | null;
|
||||
/** Heading shown above the buttons. */
|
||||
label?: string;
|
||||
/** Note shown for external installs. */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
isExternal = false,
|
||||
pluginId = null,
|
||||
externalSource = null,
|
||||
installCommand = null,
|
||||
label,
|
||||
note,
|
||||
} = Astro.props as PluginInstallProps;
|
||||
|
||||
const MARKETPLACE = "awesome-copilot";
|
||||
|
||||
let vscodeUrl: string | null = null;
|
||||
let insidersUrl: string | null = null;
|
||||
let ghappUrl: string | null = null;
|
||||
|
||||
if (isExternal) {
|
||||
if (externalSource) {
|
||||
vscodeUrl = `vscode://chat-plugin/install?source=${encodeURIComponent(
|
||||
externalSource
|
||||
)}`;
|
||||
insidersUrl = `vscode-insiders://chat-plugin/install?source=${encodeURIComponent(
|
||||
externalSource
|
||||
)}`;
|
||||
ghappUrl = `ghapp://plugins/marketplace/add?source=${encodeURIComponent(
|
||||
externalSource
|
||||
)}`;
|
||||
}
|
||||
} else if (pluginId) {
|
||||
const plugin = encodeURIComponent(pluginId);
|
||||
vscodeUrl = `vscode://chat-plugin/install?source=github/awesome-copilot&plugin=${plugin}`;
|
||||
insidersUrl = `vscode-insiders://chat-plugin/install?source=github/awesome-copilot&plugin=${plugin}`;
|
||||
ghappUrl = `ghapp://plugins/install?source=${encodeURIComponent(
|
||||
`${pluginId}@${MARKETPLACE}`
|
||||
)}`;
|
||||
}
|
||||
|
||||
const ghappLabel = isExternal
|
||||
? "Open in GitHub Copilot app"
|
||||
: "Install in GitHub Copilot app";
|
||||
|
||||
const primaryUrl = ghappUrl ?? vscodeUrl ?? insidersUrl;
|
||||
const primaryLabel =
|
||||
primaryUrl === ghappUrl
|
||||
? ghappLabel
|
||||
: primaryUrl === vscodeUrl
|
||||
? "Install in VS Code"
|
||||
: "Install in VS Code Insiders";
|
||||
const primaryIsGhapp = primaryUrl === ghappUrl;
|
||||
const heading = label ?? (isExternal ? "Install this plugin" : "Install");
|
||||
---
|
||||
|
||||
<div class="skill-install">
|
||||
<p class="skill-install-label">{heading}</p>
|
||||
{note && <p class="skill-install-note">{note}</p>}
|
||||
|
||||
{
|
||||
primaryUrl && (
|
||||
<div class="install-dropdown" data-install-menu>
|
||||
<a
|
||||
class="btn btn-primary install-btn-main"
|
||||
href={primaryUrl}
|
||||
{...(primaryIsGhapp ? {} : { target: "_blank", rel: "noopener" })}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
{primaryLabel}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary install-btn-toggle"
|
||||
aria-label="More install options"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
data-install-toggle
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="12"
|
||||
height="12"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="install-dropdown-menu" role="menu">
|
||||
{ghappUrl && (
|
||||
<a href={ghappUrl} role="menuitem">
|
||||
{ghappLabel}
|
||||
</a>
|
||||
)}
|
||||
{ghappUrl && (vscodeUrl || insidersUrl) && (
|
||||
<div class="dropdown-divider" role="separator" aria-hidden="true" />
|
||||
)}
|
||||
{vscodeUrl && (
|
||||
<a href={vscodeUrl} target="_blank" rel="noopener" role="menuitem">
|
||||
Install in VS Code
|
||||
</a>
|
||||
)}
|
||||
{insidersUrl && (
|
||||
<a href={insidersUrl} target="_blank" rel="noopener" role="menuitem">
|
||||
Install in VS Code Insiders
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
installCommand && (
|
||||
<>
|
||||
<p class="skill-install-label">Or install with the CLI</p>
|
||||
<div class="skill-install-command" data-install-command={installCommand}>
|
||||
<code tabindex="0">{installCommand}</code>
|
||||
<button
|
||||
type="button"
|
||||
class="skill-install-copy"
|
||||
data-action="copy-install"
|
||||
title="Copy install command"
|
||||
aria-label="Copy install command"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="15"
|
||||
height="15"
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
const { markdown } = Astro.props as { markdown?: string };
|
||||
---
|
||||
|
||||
{
|
||||
markdown && (
|
||||
<textarea data-raw-markdown hidden aria-hidden="true" readonly set:text={markdown}></textarea>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
import type { SidebarChipsProps } from "./SidebarChips.astro";
|
||||
import SidebarChips from "./SidebarChips.astro";
|
||||
|
||||
export interface SidebarProps {
|
||||
item: any;
|
||||
lastUpdated?: string;
|
||||
frontmatterText?: string;
|
||||
githubUrl: string;
|
||||
chips?: SidebarChipsProps[];
|
||||
}
|
||||
const {
|
||||
item,
|
||||
lastUpdated,
|
||||
frontmatterText,
|
||||
githubUrl,
|
||||
chips,
|
||||
} = Astro.props as SidebarProps;
|
||||
---
|
||||
|
||||
<aside class="detail-sidebar" aria-label="Details">
|
||||
<section class="detail-card detail-actions-card" data-actions>
|
||||
<slot name="install" />
|
||||
|
||||
<div class="detail-actions-secondary">
|
||||
<button class="btn btn-secondary" type="button" data-action="share">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="15"
|
||||
height="15"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
><circle cx="18" cy="5" r="3"></circle><circle cx="6" cy="12" r="3"
|
||||
></circle><circle cx="18" cy="19" r="3"></circle><path
|
||||
d="M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98"></path></svg
|
||||
>
|
||||
Share
|
||||
</button>
|
||||
<a
|
||||
class="btn btn-secondary"
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="15"
|
||||
height="15"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
><path
|
||||
d="M12 2C6.48 2 2 6.48 2 12c0 4.42 2.87 8.17 6.84 9.5.5.09.68-.22.68-.48 0-.24-.01-.87-.01-1.71-2.78.6-3.37-1.34-3.37-1.34-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.89 1.53 2.34 1.09 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02.8-.22 1.65-.33 2.5-.33.85 0 1.7.11 2.5.33 1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.69-4.57 4.94.36.31.68.92.68 1.85 0 1.34-.01 2.42-.01 2.75 0 .27.18.58.69.48A10.01 10.01 0 0022 12c0-5.52-4.48-10-10-10z"
|
||||
></path></svg
|
||||
>
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-card">
|
||||
<h2>Details</h2>
|
||||
<dl class="detail-meta">
|
||||
{
|
||||
chips?.map((chip) => (
|
||||
<SidebarChips
|
||||
title={chip.title}
|
||||
items={chip.items}
|
||||
filterBase={chip.filterBase}
|
||||
filterParam={chip.filterParam}
|
||||
/>
|
||||
))
|
||||
}
|
||||
<div>
|
||||
<dt>Source</dt>
|
||||
<dd><code class="detail-path">{item.path}</code></dd>
|
||||
</div>
|
||||
{
|
||||
lastUpdated && (
|
||||
<div>
|
||||
<dt>Last updated</dt>
|
||||
<dd>{lastUpdated}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{
|
||||
frontmatterText && (
|
||||
<section class="detail-card">
|
||||
<details class="detail-frontmatter">
|
||||
<summary>Frontmatter</summary>
|
||||
<pre>
|
||||
<code>{frontmatterText}</code>
|
||||
</pre>
|
||||
</details>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
</aside>
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
export type SidebarChipsProps = {
|
||||
title: string;
|
||||
items: string[];
|
||||
/**
|
||||
* When both `filterBase` and `filterParam` are provided, each chip is
|
||||
* rendered as a link to the resource's list page pre-filtered by that value
|
||||
* (e.g. `/hooks/?tag=testing`), turning read-only metadata into navigation.
|
||||
*/
|
||||
filterBase?: string;
|
||||
filterParam?: string;
|
||||
};
|
||||
|
||||
const { title, items, filterBase, filterParam } =
|
||||
Astro.props as SidebarChipsProps;
|
||||
const slug = title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
const linkable = Boolean(filterBase && filterParam);
|
||||
const chipHref = (value: string) =>
|
||||
`${filterBase}?${filterParam}=${encodeURIComponent(value)}`;
|
||||
---
|
||||
|
||||
<div>
|
||||
<dt>{title}</dt>
|
||||
<dd>
|
||||
{
|
||||
items.length ? (
|
||||
<div class="detail-chips">
|
||||
{items.map((item) =>
|
||||
linkable ? (
|
||||
<a
|
||||
class={`resource-tag resource-tag-link tag-${slug}`}
|
||||
href={chipHref(item)}
|
||||
aria-label={`Filter by ${title.toLowerCase()}: ${item}`}
|
||||
>
|
||||
{item}
|
||||
</a>
|
||||
) : (
|
||||
<span class={`resource-tag tag-${slug}`}>{item}</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span class="detail-none">Not specified</span>
|
||||
)
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -41,6 +41,19 @@ const TYPE_PAGES: Record<string, string> = {
|
||||
tool: "/tools/",
|
||||
};
|
||||
|
||||
// Resource types that have a dedicated detail page at /<type>/<id>/. Search
|
||||
// results for these should deep-link to the canonical detail page rather than
|
||||
// the listing page with an inert #file= hash (listing pages no longer open a
|
||||
// modal from that hash).
|
||||
const DETAIL_ROUTE_TYPES = new Set([
|
||||
"agent",
|
||||
"instruction",
|
||||
"skill",
|
||||
"hook",
|
||||
"workflow",
|
||||
"plugin",
|
||||
]);
|
||||
|
||||
export default function pagefindResources(): AstroIntegration {
|
||||
let siteBase = "/";
|
||||
|
||||
@@ -97,12 +110,17 @@ export default function pagefindResources(): AstroIntegration {
|
||||
|
||||
let added = 0;
|
||||
for (const record of records) {
|
||||
const hasDetailPage =
|
||||
DETAIL_ROUTE_TYPES.has(record.type) && Boolean(record.id);
|
||||
const typePage = TYPE_PAGES[record.type];
|
||||
if (!typePage) continue;
|
||||
// Skip records we can neither deep-link nor point at a listing page.
|
||||
if (!hasDetailPage && !typePage) continue;
|
||||
|
||||
const url = `${base}${typePage.slice(1)}#file=${encodeURIComponent(
|
||||
record.path
|
||||
)}`;
|
||||
const url = hasDetailPage
|
||||
? `${base}${record.type}/${encodeURIComponent(record.id)}/`
|
||||
: `${base}${typePage.slice(1)}#file=${encodeURIComponent(
|
||||
record.path
|
||||
)}`;
|
||||
const typeLabel = TYPE_LABELS[record.type] || record.type;
|
||||
|
||||
const addResult = await index.addCustomRecord({
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { marked } from "marked";
|
||||
import matter from "gray-matter";
|
||||
import { enhanceMarkdownA11y } from "./markdown-a11y";
|
||||
import { sanitizeHtml } from "./sanitize-html";
|
||||
|
||||
/**
|
||||
* Build-time helpers shared by the resource detail pages
|
||||
* (src/pages/agent/[id].astro, src/pages/instruction/[id].astro, ...).
|
||||
*
|
||||
* This module is intentionally free of any DOM/browser dependencies so it can
|
||||
* run in Astro frontmatter at build time (unlike src/scripts/utils.ts, which is
|
||||
* client-side).
|
||||
*/
|
||||
|
||||
const RAW_BASE =
|
||||
"https://raw.githubusercontent.com/github/awesome-copilot/main";
|
||||
const GITHUB_BASE = "https://github.com/github/awesome-copilot/blob/main";
|
||||
|
||||
// Per resource-type VS Code install configuration.
|
||||
const INSTALL_CONFIG = {
|
||||
agent: {
|
||||
command: "chat-agent",
|
||||
baseUrl: "https://aka.ms/awesome-copilot/install/agent",
|
||||
},
|
||||
instruction: {
|
||||
command: "chat-instructions",
|
||||
baseUrl: "https://aka.ms/awesome-copilot/install/instructions",
|
||||
},
|
||||
} as const satisfies Record<string, { command: string; baseUrl: string }>;
|
||||
|
||||
export type ResourceType = keyof typeof INSTALL_CONFIG;
|
||||
|
||||
export interface DetailPageInput {
|
||||
path: string;
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
export interface DetailPageData {
|
||||
vscodeUrl: string;
|
||||
insidersUrl: string;
|
||||
githubUrl: string;
|
||||
markdownHtml: string;
|
||||
frontmatterText: string;
|
||||
rawMarkdown: string;
|
||||
lastUpdated: string | null;
|
||||
}
|
||||
|
||||
// Repo root. Astro runs (both `dev` and `build`) execute with the website/
|
||||
// directory as the working directory, so the repo root is its parent. This is
|
||||
// resolved from the working directory rather than `import.meta.url` because the
|
||||
// latter is unreliable once this module is bundled during a production build.
|
||||
const repoRoot = path.resolve(process.cwd(), "..");
|
||||
|
||||
function buildInstallUrl(
|
||||
filePath: string,
|
||||
type: ResourceType,
|
||||
insiders: boolean
|
||||
): string {
|
||||
const { command, baseUrl } = INSTALL_CONFIG[type];
|
||||
const rawUrl = `${RAW_BASE}/${filePath}`;
|
||||
const editor = insiders ? "vscode-insiders" : "vscode";
|
||||
const innerUrl = `${editor}:${command}/install?url=${encodeURIComponent(rawUrl)}`;
|
||||
return `${baseUrl}?url=${encodeURIComponent(innerUrl)}`;
|
||||
}
|
||||
|
||||
export function formatLastUpdated(
|
||||
lastUpdated?: string | null
|
||||
): string | null {
|
||||
return lastUpdated
|
||||
? new Date(lastUpdated).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a resource's markdown file at build time and return its rendered HTML,
|
||||
* trimmed frontmatter block, and the raw file contents.
|
||||
*/
|
||||
export function readResourceMarkdown(filePath: string): {
|
||||
markdownHtml: string;
|
||||
frontmatterText: string;
|
||||
rawMarkdown: string;
|
||||
} {
|
||||
let markdownHtml = "";
|
||||
let frontmatterText = "";
|
||||
let rawMarkdown = "";
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(repoRoot, filePath), "utf-8");
|
||||
rawMarkdown = raw;
|
||||
const parsed = matter(raw);
|
||||
frontmatterText = parsed.matter?.trim() ?? "";
|
||||
markdownHtml = enhanceMarkdownA11y(
|
||||
sanitizeHtml(marked.parse(parsed.content, { async: false }) as string)
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(
|
||||
`[detail-page] Failed to read or parse markdown for "${filePath}": ${message}`
|
||||
);
|
||||
markdownHtml = "";
|
||||
}
|
||||
return { markdownHtml, frontmatterText, rawMarkdown };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build every piece of shared data a resource detail page needs: install URLs,
|
||||
* the GitHub source URL, rendered/raw markdown, and the formatted update date.
|
||||
*/
|
||||
export function loadDetailPage(
|
||||
item: DetailPageInput,
|
||||
type: ResourceType
|
||||
): DetailPageData {
|
||||
return {
|
||||
vscodeUrl: buildInstallUrl(item.path, type, false),
|
||||
insidersUrl: buildInstallUrl(item.path, type, true),
|
||||
githubUrl: `${GITHUB_BASE}/${item.path}`,
|
||||
...readResourceMarkdown(item.path),
|
||||
lastUpdated: formatLastUpdated(item.lastUpdated),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Shared helpers for building safe GitHub links to external (third-party)
|
||||
* plugin/extension sources.
|
||||
*
|
||||
* This module is intentionally dependency-free (no DOM or node imports) so it
|
||||
* can run both at build time (in Astro frontmatter, e.g. plugin/[id].astro and
|
||||
* extension/[id].astro) and on the client (plugins-render.ts, modal.ts).
|
||||
*/
|
||||
|
||||
export interface ExternalSource {
|
||||
source?: string;
|
||||
repo?: string;
|
||||
path?: string;
|
||||
ref?: string;
|
||||
sha?: string;
|
||||
}
|
||||
|
||||
const GITHUB_REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
||||
// Tags, branch names, or a 40-char commit SHA. Deliberately excludes
|
||||
// whitespace, "..", and characters that could break out of the path segment.
|
||||
const GITHUB_REF_RE = /^(?!\/)(?!.*\/\/)(?!.*\.\.)(?!.*\/$)[A-Za-z0-9._/-]+$/;
|
||||
|
||||
/**
|
||||
* Allow only http(s) URLs; return "#" for anything else (mirrors the
|
||||
* client-side sanitizeUrl in scripts/utils.ts). Prevents javascript:/data:
|
||||
* schemes from reaching an href/src attribute.
|
||||
*/
|
||||
export function sanitizeHttpUrl(url: string | null | undefined): string {
|
||||
if (!url) return "#";
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
||||
return url;
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL
|
||||
}
|
||||
return "#";
|
||||
}
|
||||
|
||||
function encodeRepoPath(rawPath: string): string {
|
||||
return rawPath
|
||||
.replace(/\\/g, "/")
|
||||
.split("/")
|
||||
.filter((segment) => segment !== "")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a GitHub URL for an external plugin/extension source, pinned to the
|
||||
* most immutable revision available (sha, then ref, then the default branch).
|
||||
* Path segments are URL-encoded and a leading "/" (or a bare "/" path) is
|
||||
* treated as "no path".
|
||||
*
|
||||
* When the source is not a valid GitHub repo reference, falls back to the first
|
||||
* safe http(s) URL in `fallbackUrls`, or "#" if none is provided.
|
||||
*/
|
||||
export function externalRepoUrl(
|
||||
source: ExternalSource | null | undefined,
|
||||
fallbackUrls: Array<string | null | undefined> = []
|
||||
): string {
|
||||
if (
|
||||
source?.source === "github" &&
|
||||
typeof source.repo === "string" &&
|
||||
GITHUB_REPO_RE.test(source.repo)
|
||||
) {
|
||||
const base = `https://github.com/${source.repo}`;
|
||||
const candidateRef = source.sha || source.ref;
|
||||
const ref =
|
||||
candidateRef && GITHUB_REF_RE.test(candidateRef) ? candidateRef : "";
|
||||
const path =
|
||||
source.path && source.path !== "/" ? encodeRepoPath(source.path) : "";
|
||||
|
||||
if (path) {
|
||||
return `${base}/tree/${ref || "main"}/${path}`;
|
||||
}
|
||||
return ref ? `${base}/tree/${ref}` : base;
|
||||
}
|
||||
|
||||
for (const url of fallbackUrls) {
|
||||
const safe = sanitizeHttpUrl(url);
|
||||
if (safe !== "#") return safe;
|
||||
}
|
||||
return "#";
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Map a file name to a Shiki language id and a coarse "kind" used by the file
|
||||
* browser to decide how to render it (markdown prose, highlighted code, image,
|
||||
* or a plain fallback). Shared between the build-time FileBrowser component
|
||||
* and the client-side file-browser script, so both agree on languages.
|
||||
*/
|
||||
|
||||
export type FileKind = "markdown" | "code" | "image" | "other";
|
||||
|
||||
const EXT_LANG: Record<string, string> = {
|
||||
ts: "typescript",
|
||||
tsx: "tsx",
|
||||
js: "javascript",
|
||||
jsx: "jsx",
|
||||
mjs: "javascript",
|
||||
cjs: "javascript",
|
||||
py: "python",
|
||||
rb: "ruby",
|
||||
go: "go",
|
||||
rs: "rust",
|
||||
java: "java",
|
||||
cs: "csharp",
|
||||
c: "c",
|
||||
cpp: "cpp",
|
||||
h: "c",
|
||||
php: "php",
|
||||
swift: "swift",
|
||||
kt: "kotlin",
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
zsh: "bash",
|
||||
ps1: "powershell",
|
||||
json: "json",
|
||||
jsonc: "json",
|
||||
yml: "yaml",
|
||||
yaml: "yaml",
|
||||
toml: "toml",
|
||||
html: "html",
|
||||
css: "css",
|
||||
scss: "scss",
|
||||
xml: "xml",
|
||||
sql: "sql",
|
||||
dockerfile: "docker",
|
||||
md: "markdown",
|
||||
markdown: "markdown",
|
||||
txt: "text",
|
||||
};
|
||||
|
||||
const CODE_LANGS = new Set<string>([
|
||||
"typescript",
|
||||
"tsx",
|
||||
"javascript",
|
||||
"jsx",
|
||||
"python",
|
||||
"ruby",
|
||||
"go",
|
||||
"rust",
|
||||
"java",
|
||||
"csharp",
|
||||
"c",
|
||||
"cpp",
|
||||
"php",
|
||||
"swift",
|
||||
"kotlin",
|
||||
"bash",
|
||||
"powershell",
|
||||
"json",
|
||||
"yaml",
|
||||
"toml",
|
||||
"html",
|
||||
"css",
|
||||
"scss",
|
||||
"xml",
|
||||
"sql",
|
||||
"docker",
|
||||
]);
|
||||
|
||||
const IMAGE_EXTS = new Set<string>([
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"svg",
|
||||
"webp",
|
||||
"avif",
|
||||
"bmp",
|
||||
"ico",
|
||||
]);
|
||||
|
||||
export interface FileMeta {
|
||||
ext: string;
|
||||
lang: string;
|
||||
kind: FileKind;
|
||||
}
|
||||
|
||||
export function getFileMeta(name: string): FileMeta {
|
||||
const base = name.split("/").pop() ?? name;
|
||||
const ext =
|
||||
base.toLowerCase() === "dockerfile"
|
||||
? "dockerfile"
|
||||
: (base.split(".").pop() ?? "").toLowerCase();
|
||||
const lang = EXT_LANG[ext] ?? "text";
|
||||
let kind: FileKind = "other";
|
||||
if (IMAGE_EXTS.has(ext)) kind = "image";
|
||||
else if (ext === "md" || ext === "markdown") kind = "markdown";
|
||||
else if (CODE_LANGS.has(lang)) kind = "code";
|
||||
return { ext, lang, kind };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Accessibility post-processing for marked-generated markdown HTML.
|
||||
*
|
||||
* axe flags two issues on rendered markdown that we fix here rather than in the
|
||||
* source content:
|
||||
* - `scrollable-region-focusable`: <pre> and <table> blocks can overflow on the
|
||||
* x-axis, so they must be keyboard focusable to let keyboard users scroll them.
|
||||
* - `label`: GitHub-style task-list checkboxes (`- [ ]` / `- [x]`) render as bare
|
||||
* disabled <input type="checkbox"> elements with no accessible name.
|
||||
*
|
||||
* This module is pure (no DOM or node deps) so it can run both at build time
|
||||
* (src/lib/detail-page.ts) and on the client (src/scripts/pages/file-browser.ts).
|
||||
*/
|
||||
export function enhanceMarkdownA11y(html: string): string {
|
||||
if (!html) return html;
|
||||
let out = html;
|
||||
|
||||
// Make scrollable code/table blocks keyboard focusable.
|
||||
out = out.replace(/<pre(?![^>]*\btabindex=)/g, '<pre tabindex="0"');
|
||||
out = out.replace(/<table(?![^>]*\btabindex=)/g, '<table tabindex="0"');
|
||||
|
||||
// Give task-list checkboxes an accessible name based on their checked state.
|
||||
out = out.replace(
|
||||
/<input\b([^>]*\btype="checkbox"[^>]*)>/g,
|
||||
(match, attrs) => {
|
||||
if (/\baria-label=/.test(attrs)) return match;
|
||||
const label = /\bchecked\b/.test(attrs)
|
||||
? "Completed task"
|
||||
: "Incomplete task";
|
||||
return `<input${attrs} aria-label="${label}">`;
|
||||
}
|
||||
);
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Isomorphic HTML sanitizer for rendered markdown.
|
||||
*
|
||||
* `marked` allows raw HTML to pass through untouched, and the resulting string
|
||||
* is injected via `set:html` / `innerHTML` on the resource detail pages and in
|
||||
* the client-side file browser. Even though the markdown we render originates
|
||||
* from this repository, a compromised or malicious resource file could
|
||||
* otherwise introduce persistent XSS. Sanitizing the generated HTML gives us
|
||||
* defense-in-depth on both the server (build time) and the client.
|
||||
*
|
||||
* `isomorphic-dompurify` resolves to a jsdom-backed DOMPurify in Node (so it
|
||||
* works in Astro frontmatter during `astro build`) and to the native
|
||||
* browser DOMPurify when bundled for the client.
|
||||
*/
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
|
||||
let noopenerHookInstalled = false;
|
||||
|
||||
function ensureNoopenerHook(): void {
|
||||
if (noopenerHookInstalled) return;
|
||||
noopenerHookInstalled = true;
|
||||
|
||||
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
|
||||
const el = node as unknown as {
|
||||
tagName?: string;
|
||||
getAttribute?: (name: string) => string | null;
|
||||
setAttribute?: (name: string, value: string) => void;
|
||||
};
|
||||
|
||||
if (el?.tagName !== "A") return;
|
||||
if (el.getAttribute?.("target") !== "_blank") return;
|
||||
|
||||
const rel = el.getAttribute?.("rel") ?? "";
|
||||
const tokens = new Set(rel.split(/\s+/).filter(Boolean));
|
||||
tokens.add("noopener");
|
||||
tokens.add("noreferrer");
|
||||
el.setAttribute?.("rel", Array.from(tokens).join(" "));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a fragment of HTML produced from trusted-but-untrusted markdown,
|
||||
* stripping scripts, event handlers, and dangerous URL schemes while keeping
|
||||
* the formatting tags GitHub-flavored markdown commonly emits.
|
||||
*/
|
||||
export function sanitizeHtml(html: string): string {
|
||||
if (!html) return html;
|
||||
ensureNoopenerHook();
|
||||
return DOMPurify.sanitize(html, {
|
||||
// Keep links that open in a new tab (target/rel) which some resource docs
|
||||
// author directly as raw HTML.
|
||||
ADD_ATTR: ["target", "rel"],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
|
||||
import BackToTop from "../../components/BackToTop.astro";
|
||||
import agentsData from "../../../public/data/agents.json";
|
||||
import RawMarkdown from "../../components/pages/RawMarkdown.astro";
|
||||
import type { HeaderItem } from "../../components/pages/Header.astro";
|
||||
import DetailHeader from "../../components/pages/Header.astro";
|
||||
import Breadcrumb from "../../components/pages/Breadcrumb.astro";
|
||||
import Main from "../../components/pages/Main.astro";
|
||||
import InstallButtons from "../../components/pages/InstallButtons.astro";
|
||||
import Sidebar from "../../components/pages/Sidebar.astro";
|
||||
import { loadDetailPage } from "../../lib/detail-page";
|
||||
|
||||
interface AgentItem extends HeaderItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
model?: string | string[];
|
||||
tools?: string[];
|
||||
hasHandoffs?: boolean;
|
||||
handoffs?: string[];
|
||||
mcpServers?: string[];
|
||||
path: string;
|
||||
filename?: string;
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
export function getStaticPaths() {
|
||||
return (agentsData.items as AgentItem[]).map((item) => ({
|
||||
params: { id: item.id },
|
||||
props: { item },
|
||||
}));
|
||||
}
|
||||
|
||||
const { item } = Astro.props as { item: AgentItem };
|
||||
|
||||
const {
|
||||
vscodeUrl,
|
||||
insidersUrl,
|
||||
githubUrl,
|
||||
markdownHtml,
|
||||
frontmatterText,
|
||||
rawMarkdown,
|
||||
lastUpdated,
|
||||
} = loadDetailPage(item, "agent");
|
||||
|
||||
const models = Array.isArray(item.model)
|
||||
? item.model
|
||||
: item.model
|
||||
? [item.model]
|
||||
: [];
|
||||
const tools = item.tools ?? [];
|
||||
const handoffs = item.handoffs ?? [];
|
||||
const mcpServers = item.mcpServers ?? [];
|
||||
---
|
||||
|
||||
<StarlightPage
|
||||
frontmatter={{
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
template: "splash",
|
||||
prev: false,
|
||||
next: false,
|
||||
editUrl: false,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
id="main-content"
|
||||
class="resource-detail-page"
|
||||
data-resource-detail
|
||||
data-path={item.path}
|
||||
>
|
||||
<RawMarkdown markdown={rawMarkdown} />
|
||||
<div class="container">
|
||||
<Breadcrumb
|
||||
paths={[
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Agents", href: "/agents/" },
|
||||
]}
|
||||
title={item.title}
|
||||
/>
|
||||
|
||||
<DetailHeader item={item} />
|
||||
|
||||
<div class="detail-layout">
|
||||
<Main markdownHtml={markdownHtml} githubUrl={githubUrl} />
|
||||
|
||||
<Sidebar
|
||||
item={item}
|
||||
lastUpdated={lastUpdated ?? undefined}
|
||||
frontmatterText={frontmatterText || undefined}
|
||||
githubUrl={githubUrl}
|
||||
chips={[
|
||||
{ title: "Models", items: models },
|
||||
{ title: "Handoffs", items: handoffs },
|
||||
{ title: "MCP Servers", items: mcpServers },
|
||||
{ title: "Tools", items: tools },
|
||||
]}
|
||||
>
|
||||
<InstallButtons
|
||||
vscodeUrl={vscodeUrl}
|
||||
insidersUrl={insidersUrl}
|
||||
rawMarkdown={rawMarkdown}
|
||||
slot="install"
|
||||
/>
|
||||
</Sidebar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
|
||||
<script>
|
||||
import "../../scripts/pages/resource-detail";
|
||||
</script>
|
||||
</StarlightPage>
|
||||
@@ -5,7 +5,6 @@ import ContributeCTA from '../components/ContributeCTA.astro';
|
||||
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import BackToTop from '../components/BackToTop.astro';
|
||||
import Modal from '../components/Modal.astro';
|
||||
import { renderAgentsHtml, sortAgents } from '../scripts/pages/agents-render';
|
||||
|
||||
const initialItems = sortAgents(agentsData.items, 'title');
|
||||
@@ -43,7 +42,6 @@ const initialItems = sortAgents(agentsData.items, 'title');
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
<Modal />
|
||||
<EmbeddedPageData filename="agents.json" data={agentsData} />
|
||||
|
||||
<script>
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
---
|
||||
import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
|
||||
import BackToTop from "../../components/BackToTop.astro";
|
||||
import extensionsData from "../../../public/data/extensions.json";
|
||||
import RawMarkdown from "../../components/pages/RawMarkdown.astro";
|
||||
import type { HeaderItem } from "../../components/pages/Header.astro";
|
||||
import DetailHeader from "../../components/pages/Header.astro";
|
||||
import Breadcrumb from "../../components/pages/Breadcrumb.astro";
|
||||
import Sidebar from "../../components/pages/Sidebar.astro";
|
||||
import PluginInstall from "../../components/pages/PluginInstall.astro";
|
||||
import { readResourceMarkdown, formatLastUpdated } from "../../lib/detail-page";
|
||||
import { sanitizeHttpUrl } from "../../lib/external-source";
|
||||
|
||||
const GITHUB_TREE = "https://github.com/github/awesome-copilot/tree/main";
|
||||
|
||||
interface ExtensionScreenshot {
|
||||
path?: string | null;
|
||||
type?: string | null;
|
||||
}
|
||||
|
||||
interface ExtensionAuthor {
|
||||
name: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface ExtensionEntry extends HeaderItem {
|
||||
id: string;
|
||||
canvasId?: string;
|
||||
extensionId?: string;
|
||||
extensionName?: string;
|
||||
pluginName?: string | null;
|
||||
name: string;
|
||||
title?: string;
|
||||
version?: string | null;
|
||||
readmeFile?: string | null;
|
||||
description?: string;
|
||||
path?: string | null;
|
||||
ref?: string | null;
|
||||
lastUpdated?: string | null;
|
||||
screenshots?: {
|
||||
icon?: ExtensionScreenshot | null;
|
||||
gallery?: ExtensionScreenshot | ExtensionScreenshot[] | null;
|
||||
} | null;
|
||||
imageUrl?: string | null;
|
||||
assetPath?: string | null;
|
||||
installUrl?: string | null;
|
||||
installCommand?: string | null;
|
||||
sourceUrl?: string | null;
|
||||
external?: boolean;
|
||||
author?: ExtensionAuthor | null;
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
export function getStaticPaths() {
|
||||
return (extensionsData.items as ExtensionEntry[]).map((item) => ({
|
||||
params: { id: item.id },
|
||||
props: { item },
|
||||
}));
|
||||
}
|
||||
|
||||
const { item } = Astro.props as { item: ExtensionEntry };
|
||||
|
||||
const headerItem = { ...item, title: item.title ?? item.name };
|
||||
|
||||
const isExternal = item.external === true;
|
||||
|
||||
// Allow only http(s) URLs from external/generated data in href/src contexts;
|
||||
// unsafe values collapse to "" so downstream truthiness guards still hold.
|
||||
const safeUrl = (value?: string | null): string => {
|
||||
const sanitized = sanitizeHttpUrl(value);
|
||||
return sanitized === "#" ? "" : sanitized;
|
||||
};
|
||||
const readmePath = item.readmeFile ?? null;
|
||||
const { markdownHtml, frontmatterText, rawMarkdown } = readmePath
|
||||
? readResourceMarkdown(readmePath)
|
||||
: { markdownHtml: "", frontmatterText: "", rawMarkdown: "" };
|
||||
|
||||
const lastUpdated = formatLastUpdated(item.lastUpdated ?? null);
|
||||
|
||||
// Resolve preview images (icon + gallery), converting repo paths to raw URLs.
|
||||
function toRawAssetUrl(assetPath?: string | null): string {
|
||||
if (!assetPath) return "";
|
||||
if (/^https?:\/\//i.test(assetPath)) return assetPath;
|
||||
if (!item.ref) return "";
|
||||
return `https://raw.githubusercontent.com/github/awesome-copilot/${item.ref}/${assetPath.replace(
|
||||
/\\/g,
|
||||
"/"
|
||||
)}`;
|
||||
}
|
||||
|
||||
function normalizeScreenshotEntries(
|
||||
value: ExtensionScreenshot | ExtensionScreenshot[] | null | undefined
|
||||
): ExtensionScreenshot[] {
|
||||
if (!value) return [];
|
||||
return Array.isArray(value) ? value.filter(Boolean) : [value];
|
||||
}
|
||||
|
||||
const galleryImages: string[] = (() => {
|
||||
const images: string[] = [];
|
||||
if (item.imageUrl) images.push(item.imageUrl);
|
||||
const iconUrl = toRawAssetUrl(item.screenshots?.icon?.path);
|
||||
if (iconUrl) images.push(iconUrl);
|
||||
for (const entry of normalizeScreenshotEntries(item.screenshots?.gallery)) {
|
||||
const url = toRawAssetUrl(entry.path);
|
||||
if (url) images.push(url);
|
||||
}
|
||||
return Array.from(new Set(images.map(safeUrl).filter(Boolean)));
|
||||
})();
|
||||
|
||||
const installUrl = safeUrl(
|
||||
item.installUrl ||
|
||||
(item.path && item.ref
|
||||
? `https://github.com/github/awesome-copilot/tree/${item.ref}/${item.path.replace(
|
||||
/\\/g,
|
||||
"/"
|
||||
)}`
|
||||
: "")
|
||||
);
|
||||
const sourceUrl = safeUrl(item.sourceUrl);
|
||||
const installCommand =
|
||||
item.installCommand ||
|
||||
(item.pluginName
|
||||
? `copilot plugin install ${item.pluginName}@awesome-copilot`
|
||||
: "");
|
||||
|
||||
const githubUrl = isExternal
|
||||
? sourceUrl || installUrl || GITHUB_TREE
|
||||
: item.path
|
||||
? `${GITHUB_TREE}/${item.path}`
|
||||
: installUrl || GITHUB_TREE;
|
||||
|
||||
// Sidebar "Source" shows item.path; external extensions have no repo path.
|
||||
const sidebarItem =
|
||||
isExternal && (sourceUrl || installUrl)
|
||||
? { ...item, path: sourceUrl || installUrl }
|
||||
: item;
|
||||
|
||||
const shortHash = (value: string) =>
|
||||
/^[0-9a-f]{40}$/i.test(value) ? value.slice(0, 7) : value;
|
||||
|
||||
const chips: {
|
||||
title: string;
|
||||
items: string[];
|
||||
filterBase?: string;
|
||||
filterParam?: string;
|
||||
}[] = [];
|
||||
if (item.version) {
|
||||
chips.push({ title: "Version", items: [item.version] });
|
||||
}
|
||||
if (item.canvasId) {
|
||||
chips.push({ title: "Canvas ID", items: [item.canvasId] });
|
||||
}
|
||||
if (item.keywords && item.keywords.length > 0) {
|
||||
chips.push({
|
||||
title: "Keywords",
|
||||
items: item.keywords,
|
||||
filterBase: "/extensions/",
|
||||
filterParam: "keyword",
|
||||
});
|
||||
}
|
||||
if (item.author?.name) {
|
||||
chips.push({ title: "Author", items: [item.author.name] });
|
||||
}
|
||||
if (!isExternal && item.ref) {
|
||||
chips.push({ title: "Commit", items: [shortHash(item.ref)] });
|
||||
}
|
||||
---
|
||||
|
||||
<StarlightPage
|
||||
frontmatter={{
|
||||
title: item.title ?? item.name,
|
||||
description: item.description,
|
||||
template: "splash",
|
||||
prev: false,
|
||||
next: false,
|
||||
editUrl: false,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
id="main-content"
|
||||
class="resource-detail-page extension-detail-page"
|
||||
data-resource-detail
|
||||
data-path={item.readmeFile ?? item.path ?? ""}
|
||||
>
|
||||
<RawMarkdown markdown={rawMarkdown} />
|
||||
<div class="container">
|
||||
<Breadcrumb
|
||||
paths={[
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Canvas Extensions", href: "/extensions/" },
|
||||
]}
|
||||
title={item.title ?? item.name}
|
||||
/>
|
||||
|
||||
<DetailHeader item={headerItem} />
|
||||
|
||||
<div class="detail-layout">
|
||||
<div class="detail-main">
|
||||
{
|
||||
galleryImages.length > 0 && (
|
||||
<section class="detail-section extension-gallery">
|
||||
<h2>Preview</h2>
|
||||
<div class="extension-gallery-main">
|
||||
<img
|
||||
id="extension-gallery-image"
|
||||
src={galleryImages[0]}
|
||||
alt={`${item.name} preview`}
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
{galleryImages.length > 1 && (
|
||||
<div class="extension-gallery-thumbs" role="list">
|
||||
{galleryImages.map((url, index) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`extension-gallery-thumb${index === 0 ? " active" : ""}`}
|
||||
data-gallery-url={url}
|
||||
role="listitem"
|
||||
aria-label={`Show image ${index + 1}`}
|
||||
aria-current={index === 0 ? "true" : undefined}
|
||||
>
|
||||
<img src={url} alt={`Gallery image ${index + 1}`} loading="lazy" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
markdownHtml ? (
|
||||
<section class="detail-section" aria-labelledby="doc-heading">
|
||||
<h2 id="doc-heading">Documentation</h2>
|
||||
<div class="article-content" set:html={markdownHtml} />
|
||||
</section>
|
||||
) : (
|
||||
<section class="detail-section">
|
||||
<h2>About</h2>
|
||||
<p>{item.description}</p>
|
||||
<p class="detail-empty">
|
||||
{isExternal
|
||||
? "This is a third-party canvas extension hosted outside this repository."
|
||||
: "No README is bundled with this extension."}{" "}
|
||||
View it{" "}
|
||||
<a href={githubUrl} target="_blank" rel="noopener">
|
||||
on GitHub
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<Sidebar
|
||||
item={sidebarItem}
|
||||
lastUpdated={lastUpdated ?? undefined}
|
||||
frontmatterText={frontmatterText || undefined}
|
||||
githubUrl={githubUrl}
|
||||
chips={chips}
|
||||
>
|
||||
{
|
||||
isExternal ? (
|
||||
<div class="skill-install" slot="install">
|
||||
<p class="skill-install-label">Install this extension</p>
|
||||
<p class="skill-install-note">
|
||||
This extension is maintained in an external repository.
|
||||
</p>
|
||||
{installUrl && (
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary skill-install-url-btn"
|
||||
data-action="copy-install-url"
|
||||
data-install-url={installUrl}
|
||||
>
|
||||
Copy install URL
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<PluginInstall
|
||||
slot="install"
|
||||
pluginId={item.pluginName ?? item.id}
|
||||
installCommand={installCommand || null}
|
||||
label="Install"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Sidebar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
|
||||
<script>
|
||||
import "../../scripts/pages/resource-detail";
|
||||
import "../../scripts/pages/extension-gallery";
|
||||
</script>
|
||||
</StarlightPage>
|
||||
@@ -5,7 +5,6 @@ import ContributeCTA from '../components/ContributeCTA.astro';
|
||||
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import BackToTop from '../components/BackToTop.astro';
|
||||
import Modal from '../components/Modal.astro';
|
||||
import { renderExtensionsHtml, sortExtensions } from '../scripts/pages/extensions-render';
|
||||
|
||||
const initialItems = sortExtensions(extensionsData.items, 'title');
|
||||
@@ -53,7 +52,6 @@ const initialItems = sortExtensions(extensionsData.items, 'title');
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
<Modal />
|
||||
<EmbeddedPageData filename="extensions.json" data={extensionsData} />
|
||||
|
||||
<script>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
|
||||
import BackToTop from "../../components/BackToTop.astro";
|
||||
import hooksData from "../../../public/data/hooks.json";
|
||||
import RawMarkdown from "../../components/pages/RawMarkdown.astro";
|
||||
import type { HeaderItem } from "../../components/pages/Header.astro";
|
||||
import DetailHeader from "../../components/pages/Header.astro";
|
||||
import Breadcrumb from "../../components/pages/Breadcrumb.astro";
|
||||
import Sidebar from "../../components/pages/Sidebar.astro";
|
||||
import FileBrowser from "../../components/pages/FileBrowser.astro";
|
||||
import { readResourceMarkdown, formatLastUpdated } from "../../lib/detail-page";
|
||||
|
||||
const GITHUB_BASE = "https://github.com/github/awesome-copilot/blob/main";
|
||||
const GITHUB_TREE_BASE = "https://github.com/github/awesome-copilot/tree/main";
|
||||
|
||||
interface HookFile {
|
||||
name: string;
|
||||
path: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
interface HookItem extends HeaderItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
readmeFile: string;
|
||||
readmeFileName?: string;
|
||||
files: HookFile[];
|
||||
hooks: string[];
|
||||
tags: string[];
|
||||
assets: string[];
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
export function getStaticPaths() {
|
||||
return (hooksData.items as HookItem[]).map((item) => ({
|
||||
params: { id: item.id },
|
||||
props: { item },
|
||||
}));
|
||||
}
|
||||
|
||||
const { item } = Astro.props as { item: HookItem };
|
||||
|
||||
const { markdownHtml, frontmatterText, rawMarkdown } = readResourceMarkdown(
|
||||
item.readmeFile
|
||||
);
|
||||
const lastUpdated = formatLastUpdated(item.lastUpdated);
|
||||
const githubUrl = `${GITHUB_TREE_BASE}/${item.path}`;
|
||||
const readmeFileName = item.readmeFileName ?? "README.md";
|
||||
|
||||
const fileCount = item.files.length;
|
||||
const chips: {
|
||||
title: string;
|
||||
items: string[];
|
||||
filterBase?: string;
|
||||
filterParam?: string;
|
||||
}[] = [];
|
||||
if (item.hooks.length > 0) {
|
||||
chips.push({ title: "Hook events", items: item.hooks });
|
||||
}
|
||||
chips.push({
|
||||
title: "Files",
|
||||
items: [`${fileCount} file${fileCount === 1 ? "" : "s"}`],
|
||||
});
|
||||
if (item.tags.length > 0) {
|
||||
chips.push({
|
||||
title: "Tags",
|
||||
items: item.tags,
|
||||
filterBase: "/hooks/",
|
||||
filterParam: "tag",
|
||||
});
|
||||
}
|
||||
---
|
||||
|
||||
<StarlightPage
|
||||
frontmatter={{
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
template: "splash",
|
||||
prev: false,
|
||||
next: false,
|
||||
editUrl: false,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
id="main-content"
|
||||
class="resource-detail-page hook-detail-page"
|
||||
data-file-browser-page
|
||||
data-bundle-id={item.id}
|
||||
data-path={item.readmeFile}
|
||||
>
|
||||
<RawMarkdown markdown={rawMarkdown} />
|
||||
<div class="container">
|
||||
<Breadcrumb
|
||||
paths={[
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Hooks", href: "/hooks/" },
|
||||
]}
|
||||
title={item.title}
|
||||
/>
|
||||
|
||||
<DetailHeader item={item} />
|
||||
|
||||
<div class="detail-layout">
|
||||
<div class="detail-main">
|
||||
<FileBrowser
|
||||
files={item.files}
|
||||
primaryPath={item.readmeFile}
|
||||
primaryName={readmeFileName}
|
||||
primaryHtml={markdownHtml}
|
||||
githubBase={GITHUB_BASE}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Sidebar
|
||||
item={item}
|
||||
lastUpdated={lastUpdated ?? undefined}
|
||||
frontmatterText={frontmatterText || undefined}
|
||||
githubUrl={githubUrl}
|
||||
chips={chips}
|
||||
>
|
||||
<div class="skill-install" slot="install">
|
||||
<p class="skill-install-label">Install this hook</p>
|
||||
<p class="skill-install-note">
|
||||
Hooks are multi-file bundles with no CLI installer. Download the
|
||||
ZIP and copy the files into your project's hooks directory.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary skill-download-btn"
|
||||
data-action="download-zip"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
><path
|
||||
d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z"
|
||||
></path><path
|
||||
d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969Z"
|
||||
></path></svg
|
||||
>
|
||||
Download ZIP
|
||||
</button>
|
||||
</div>
|
||||
</Sidebar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
|
||||
<script>
|
||||
import "../../scripts/pages/file-browser";
|
||||
</script>
|
||||
</StarlightPage>
|
||||
@@ -4,7 +4,6 @@ import ContributeCTA from '../components/ContributeCTA.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||
import BackToTop from '../components/BackToTop.astro';
|
||||
import Modal from '../components/Modal.astro';
|
||||
import hooksData from '../../public/data/hooks.json';
|
||||
import { renderHooksHtml, sortHooks } from '../scripts/pages/hooks-render';
|
||||
|
||||
@@ -48,7 +47,6 @@ const initialItems = sortHooks(hooksData.items, 'title');
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
<Modal />
|
||||
|
||||
<EmbeddedPageData filename="hooks.json" data={hooksData} />
|
||||
<script>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
|
||||
import BackToTop from "../../components/BackToTop.astro";
|
||||
import instructionsData from "../../../public/data/instructions.json";
|
||||
import RawMarkdown from "../../components/pages/RawMarkdown.astro";
|
||||
import type { HeaderItem } from "../../components/pages/Header.astro";
|
||||
import DetailHeader from "../../components/pages/Header.astro";
|
||||
import Breadcrumb from "../../components/pages/Breadcrumb.astro";
|
||||
import Main from "../../components/pages/Main.astro";
|
||||
import InstallButtons from "../../components/pages/InstallButtons.astro";
|
||||
import Sidebar from "../../components/pages/Sidebar.astro";
|
||||
import { loadDetailPage } from "../../lib/detail-page";
|
||||
|
||||
interface InstructionItem extends HeaderItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
applyTo?: string | string[] | null;
|
||||
applyToPatterns?: string[];
|
||||
extensions?: string[];
|
||||
path: string;
|
||||
filename?: string;
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
export function getStaticPaths() {
|
||||
return (instructionsData.items as InstructionItem[]).map((item) => ({
|
||||
params: { id: item.id },
|
||||
props: { item },
|
||||
}));
|
||||
}
|
||||
|
||||
const { item } = Astro.props as { item: InstructionItem };
|
||||
|
||||
const {
|
||||
vscodeUrl,
|
||||
insidersUrl,
|
||||
githubUrl,
|
||||
markdownHtml,
|
||||
frontmatterText,
|
||||
rawMarkdown,
|
||||
lastUpdated,
|
||||
} = loadDetailPage(item, "instruction");
|
||||
|
||||
const applyToList = Array.isArray(item.applyTo)
|
||||
? item.applyTo
|
||||
: item.applyTo
|
||||
? [item.applyTo]
|
||||
: (item.applyToPatterns ?? []);
|
||||
const extensions = item.extensions ?? [];
|
||||
---
|
||||
|
||||
<StarlightPage
|
||||
frontmatter={{
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
template: "splash",
|
||||
prev: false,
|
||||
next: false,
|
||||
editUrl: false,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
id="main-content"
|
||||
class="resource-detail-page"
|
||||
data-resource-detail
|
||||
data-path={item.path}
|
||||
>
|
||||
<RawMarkdown markdown={rawMarkdown} />
|
||||
<div class="container">
|
||||
<Breadcrumb
|
||||
paths={[
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Instructions", href: "/instructions/" },
|
||||
]}
|
||||
title={item.title}
|
||||
/>
|
||||
|
||||
<DetailHeader item={item} />
|
||||
|
||||
<div class="detail-layout">
|
||||
<Main markdownHtml={markdownHtml} githubUrl={githubUrl} />
|
||||
|
||||
<Sidebar
|
||||
item={item}
|
||||
lastUpdated={lastUpdated ?? undefined}
|
||||
frontmatterText={frontmatterText || undefined}
|
||||
githubUrl={githubUrl}
|
||||
chips={[
|
||||
{ title: "Applies to", items: applyToList },
|
||||
{
|
||||
title: "Extensions",
|
||||
items: extensions,
|
||||
filterBase: "/instructions/",
|
||||
filterParam: "extension",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<InstallButtons
|
||||
vscodeUrl={vscodeUrl}
|
||||
insidersUrl={insidersUrl}
|
||||
rawMarkdown={rawMarkdown}
|
||||
slot="install"
|
||||
/>
|
||||
</Sidebar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
|
||||
<script>
|
||||
import "../../scripts/pages/resource-detail";
|
||||
</script>
|
||||
</StarlightPage>
|
||||
@@ -5,7 +5,6 @@ import ContributeCTA from '../components/ContributeCTA.astro';
|
||||
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import BackToTop from '../components/BackToTop.astro';
|
||||
import Modal from '../components/Modal.astro';
|
||||
import { renderInstructionsHtml, sortInstructions } from '../scripts/pages/instructions-render';
|
||||
|
||||
const initialItems = sortInstructions(instructionsData.items, 'title');
|
||||
@@ -48,7 +47,6 @@ const initialItems = sortInstructions(instructionsData.items, 'title');
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
<Modal />
|
||||
<EmbeddedPageData filename="instructions.json" data={instructionsData} />
|
||||
|
||||
<script>
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
---
|
||||
import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
|
||||
import BackToTop from "../../components/BackToTop.astro";
|
||||
import pluginsData from "../../../public/data/plugins.json";
|
||||
import RawMarkdown from "../../components/pages/RawMarkdown.astro";
|
||||
import type { HeaderItem } from "../../components/pages/Header.astro";
|
||||
import DetailHeader from "../../components/pages/Header.astro";
|
||||
import Breadcrumb from "../../components/pages/Breadcrumb.astro";
|
||||
import Sidebar from "../../components/pages/Sidebar.astro";
|
||||
import IncludedItems from "../../components/pages/IncludedItems.astro";
|
||||
import PluginInstall from "../../components/pages/PluginInstall.astro";
|
||||
import { readResourceMarkdown, formatLastUpdated } from "../../lib/detail-page";
|
||||
import { externalRepoUrl } from "../../lib/external-source";
|
||||
|
||||
const GITHUB_TREE = "https://github.com/github/awesome-copilot/tree/main";
|
||||
|
||||
interface PluginItemRef {
|
||||
kind: string;
|
||||
path: string;
|
||||
title?: string;
|
||||
detailUrl?: string | null;
|
||||
}
|
||||
|
||||
interface PluginAuthor {
|
||||
name: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface PluginSource {
|
||||
source: string;
|
||||
repo?: string;
|
||||
path?: string;
|
||||
ref?: string;
|
||||
sha?: string;
|
||||
}
|
||||
|
||||
interface PluginEntry extends HeaderItem {
|
||||
id: string;
|
||||
name: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
readmeFile?: string | null;
|
||||
version?: string | null;
|
||||
tags?: string[];
|
||||
itemCount: number;
|
||||
items: PluginItemRef[];
|
||||
external?: boolean;
|
||||
generatedFromExtension?: boolean;
|
||||
repository?: string | null;
|
||||
homepage?: string | null;
|
||||
author?: PluginAuthor | null;
|
||||
source?: PluginSource | null;
|
||||
license?: string | null;
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
export function getStaticPaths() {
|
||||
return (pluginsData.items as PluginEntry[]).map((item) => ({
|
||||
params: { id: item.id },
|
||||
props: { item },
|
||||
}));
|
||||
}
|
||||
|
||||
const { item } = Astro.props as { item: PluginEntry };
|
||||
|
||||
// DetailHeader reads item.title; plugins only have a name.
|
||||
const headerItem = { ...item, title: item.title ?? item.name };
|
||||
|
||||
const isExternal = item.external === true;
|
||||
const readmePath = item.readmeFile ?? null;
|
||||
const { markdownHtml, frontmatterText, rawMarkdown } = readmePath
|
||||
? readResourceMarkdown(readmePath)
|
||||
: { markdownHtml: "", frontmatterText: "", rawMarkdown: "" };
|
||||
|
||||
const lastUpdated = formatLastUpdated(item.lastUpdated);
|
||||
|
||||
function pluginRepoUrl(): string {
|
||||
return externalRepoUrl(item.source, [
|
||||
item.repository,
|
||||
item.homepage,
|
||||
GITHUB_TREE,
|
||||
]);
|
||||
}
|
||||
|
||||
const githubUrl = isExternal
|
||||
? pluginRepoUrl()
|
||||
: `${GITHUB_TREE}/${item.path}`;
|
||||
|
||||
// Install wiring
|
||||
const externalSourceRef = item.source?.repo || item.repository || "";
|
||||
const installCommand = isExternal
|
||||
? null
|
||||
: `copilot plugin install ${item.id}@awesome-copilot`;
|
||||
|
||||
// The sidebar "Source" shows item.path; for external plugins that repo-relative
|
||||
// path doesn't exist here, so surface the upstream source reference instead.
|
||||
const sidebarItem =
|
||||
isExternal && externalSourceRef
|
||||
? { ...item, path: externalSourceRef }
|
||||
: item;
|
||||
|
||||
const chips: {
|
||||
title: string;
|
||||
items: string[];
|
||||
filterBase?: string;
|
||||
filterParam?: string;
|
||||
}[] = [];
|
||||
if (item.version) {
|
||||
chips.push({ title: "Version", items: [item.version] });
|
||||
}
|
||||
if (item.tags && item.tags.length > 0) {
|
||||
chips.push({
|
||||
title: "Tags",
|
||||
items: item.tags,
|
||||
filterBase: "/plugins/",
|
||||
filterParam: "tag",
|
||||
});
|
||||
}
|
||||
if (!isExternal && item.itemCount > 0) {
|
||||
chips.push({
|
||||
title: "Bundled items",
|
||||
items: [`${item.itemCount} item${item.itemCount === 1 ? "" : "s"}`],
|
||||
});
|
||||
}
|
||||
if (isExternal && item.author?.name) {
|
||||
chips.push({ title: "Author", items: [item.author.name] });
|
||||
}
|
||||
if (isExternal && item.license) {
|
||||
chips.push({ title: "License", items: [item.license] });
|
||||
}
|
||||
if (isExternal) {
|
||||
const shortHash = (value: string) =>
|
||||
/^[0-9a-f]{40}$/i.test(value) ? value.slice(0, 7) : value;
|
||||
const ref = item.source?.ref;
|
||||
const sha = item.source?.sha;
|
||||
if (ref) {
|
||||
chips.push({ title: "Ref", items: [shortHash(ref)] });
|
||||
}
|
||||
if (sha) {
|
||||
chips.push({ title: "Commit", items: [shortHash(sha)] });
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
<StarlightPage
|
||||
frontmatter={{
|
||||
title: item.title ?? item.name,
|
||||
description: item.description,
|
||||
template: "splash",
|
||||
prev: false,
|
||||
next: false,
|
||||
editUrl: false,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
id="main-content"
|
||||
class="resource-detail-page plugin-detail-page"
|
||||
data-resource-detail
|
||||
data-path={item.readmeFile ?? item.path}
|
||||
>
|
||||
<RawMarkdown markdown={rawMarkdown} />
|
||||
<div class="container">
|
||||
<Breadcrumb
|
||||
paths={[
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Plugins", href: "/plugins/" },
|
||||
]}
|
||||
title={item.title ?? item.name}
|
||||
/>
|
||||
|
||||
<DetailHeader item={headerItem} />
|
||||
|
||||
<div class="detail-layout">
|
||||
<div class="detail-main">
|
||||
{
|
||||
markdownHtml ? (
|
||||
<section class="detail-section" aria-labelledby="doc-heading">
|
||||
<h2 id="doc-heading">Documentation</h2>
|
||||
<div class="article-content" set:html={markdownHtml} />
|
||||
</section>
|
||||
) : (
|
||||
<section class="detail-section">
|
||||
<h2>Overview</h2>
|
||||
<p>{item.description}</p>
|
||||
<p class="detail-empty">
|
||||
{isExternal
|
||||
? "This is a third-party plugin hosted outside this repository."
|
||||
: "No README is bundled with this plugin."}{" "}
|
||||
View it{" "}
|
||||
<a href={githubUrl} target="_blank" rel="noopener">
|
||||
on GitHub
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<IncludedItems
|
||||
items={item.items}
|
||||
pluginPath={item.path}
|
||||
githubBase={GITHUB_TREE}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Sidebar
|
||||
item={sidebarItem}
|
||||
lastUpdated={lastUpdated ?? undefined}
|
||||
frontmatterText={frontmatterText || undefined}
|
||||
githubUrl={githubUrl}
|
||||
chips={chips}
|
||||
>
|
||||
<PluginInstall
|
||||
slot="install"
|
||||
isExternal={isExternal}
|
||||
pluginId={item.id}
|
||||
externalSource={externalSourceRef || null}
|
||||
installCommand={installCommand}
|
||||
label={isExternal ? "Install this plugin" : "Install"}
|
||||
note={isExternal
|
||||
? "This plugin is maintained in an external repository. Installing it adds the third-party marketplace before installing."
|
||||
: undefined}
|
||||
/>
|
||||
</Sidebar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
|
||||
<script>
|
||||
import "../../scripts/pages/resource-detail";
|
||||
</script>
|
||||
</StarlightPage>
|
||||
@@ -5,7 +5,6 @@ import ContributeCTA from '../components/ContributeCTA.astro';
|
||||
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import BackToTop from '../components/BackToTop.astro';
|
||||
import Modal from '../components/Modal.astro';
|
||||
import { renderPluginsHtml, sortPlugins } from '../scripts/pages/plugins-render';
|
||||
|
||||
const initialItems = sortPlugins(pluginsData.items, 'title');
|
||||
@@ -57,7 +56,6 @@ const initialItems = sortPlugins(pluginsData.items, 'title');
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
<Modal />
|
||||
<EmbeddedPageData filename="plugins.json" data={pluginsData} />
|
||||
|
||||
<script>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
---
|
||||
import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
|
||||
import BackToTop from "../../components/BackToTop.astro";
|
||||
import skillsData from "../../../public/data/skills.json";
|
||||
import RawMarkdown from "../../components/pages/RawMarkdown.astro";
|
||||
import type { HeaderItem } from "../../components/pages/Header.astro";
|
||||
import DetailHeader from "../../components/pages/Header.astro";
|
||||
import Breadcrumb from "../../components/pages/Breadcrumb.astro";
|
||||
import Sidebar from "../../components/pages/Sidebar.astro";
|
||||
import FileBrowser from "../../components/pages/FileBrowser.astro";
|
||||
import { readResourceMarkdown, formatLastUpdated } from "../../lib/detail-page";
|
||||
|
||||
const GITHUB_BASE = "https://github.com/github/awesome-copilot/blob/main";
|
||||
const GITHUB_TREE_BASE = "https://github.com/github/awesome-copilot/tree/main";
|
||||
const REPO_IDENTIFIER = "github/awesome-copilot";
|
||||
|
||||
interface SkillFile {
|
||||
name: string;
|
||||
path: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
interface SkillItem extends HeaderItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
skillFile: string;
|
||||
files: SkillFile[];
|
||||
hasAssets?: boolean;
|
||||
assetCount?: number;
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
export function getStaticPaths() {
|
||||
return (skillsData.items as SkillItem[]).map((item) => ({
|
||||
params: { id: item.id },
|
||||
props: { item },
|
||||
}));
|
||||
}
|
||||
|
||||
const { item } = Astro.props as { item: SkillItem };
|
||||
|
||||
const { markdownHtml, frontmatterText, rawMarkdown } = readResourceMarkdown(
|
||||
item.skillFile
|
||||
);
|
||||
const lastUpdated = formatLastUpdated(item.lastUpdated);
|
||||
const githubUrl = `${GITHUB_TREE_BASE}/${item.path}`;
|
||||
const installCommand = `gh skills install ${REPO_IDENTIFIER} ${item.id}`;
|
||||
const skillFileName = item.skillFile.split("/").pop() ?? "SKILL.md";
|
||||
|
||||
const fileCount = item.files.length;
|
||||
const chips: { title: string; items: string[] }[] = [
|
||||
{ title: "Files", items: [`${fileCount} file${fileCount === 1 ? "" : "s"}`] },
|
||||
];
|
||||
if (item.hasAssets && item.assetCount) {
|
||||
chips.push({
|
||||
title: "Assets",
|
||||
items: [`${item.assetCount} asset${item.assetCount === 1 ? "" : "s"}`],
|
||||
});
|
||||
}
|
||||
---
|
||||
|
||||
<StarlightPage
|
||||
frontmatter={{
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
template: "splash",
|
||||
prev: false,
|
||||
next: false,
|
||||
editUrl: false,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
id="main-content"
|
||||
class="resource-detail-page skill-detail-page"
|
||||
data-file-browser-page
|
||||
data-bundle-id={item.id}
|
||||
data-path={item.skillFile}
|
||||
>
|
||||
<RawMarkdown markdown={rawMarkdown} />
|
||||
<div class="container">
|
||||
<Breadcrumb
|
||||
paths={[
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Skills", href: "/skills/" },
|
||||
]}
|
||||
title={item.title}
|
||||
/>
|
||||
|
||||
<DetailHeader item={item} />
|
||||
|
||||
<div class="detail-layout">
|
||||
<div class="detail-main">
|
||||
<FileBrowser
|
||||
files={item.files}
|
||||
primaryPath={item.skillFile}
|
||||
primaryName={skillFileName}
|
||||
primaryHtml={markdownHtml}
|
||||
githubBase={GITHUB_BASE}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Sidebar
|
||||
item={item}
|
||||
lastUpdated={lastUpdated ?? undefined}
|
||||
frontmatterText={frontmatterText || undefined}
|
||||
githubUrl={githubUrl}
|
||||
chips={chips}
|
||||
>
|
||||
<div class="skill-install" slot="install">
|
||||
<p class="skill-install-label">Install with the GitHub CLI</p>
|
||||
<div class="skill-install-command" data-install-command={installCommand}>
|
||||
<code tabindex="0">{installCommand}</code>
|
||||
<button
|
||||
type="button"
|
||||
class="skill-install-copy"
|
||||
data-action="copy-install"
|
||||
title="Copy install command"
|
||||
aria-label="Copy install command"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="15"
|
||||
height="15"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
><path
|
||||
d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"
|
||||
></path><path
|
||||
d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"
|
||||
></path></svg
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary skill-download-btn"
|
||||
data-action="download-zip"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
><path
|
||||
d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z"
|
||||
></path><path
|
||||
d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969Z"
|
||||
></path></svg
|
||||
>
|
||||
Download ZIP
|
||||
</button>
|
||||
</div>
|
||||
</Sidebar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
|
||||
<script>
|
||||
import "../../scripts/pages/file-browser";
|
||||
</script>
|
||||
</StarlightPage>
|
||||
@@ -4,7 +4,6 @@ import ContributeCTA from '../components/ContributeCTA.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||
import BackToTop from '../components/BackToTop.astro';
|
||||
import Modal from '../components/Modal.astro';
|
||||
import skillsData from '../../public/data/skills.json';
|
||||
import { renderSkillsHtml, sortSkills } from '../scripts/pages/skills-render';
|
||||
|
||||
@@ -43,7 +42,6 @@ const initialItems = sortSkills(skillsData.items, 'title');
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
<Modal />
|
||||
|
||||
<EmbeddedPageData filename="skills.json" data={skillsData} />
|
||||
<script>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
|
||||
import BackToTop from "../../components/BackToTop.astro";
|
||||
import workflowsData from "../../../public/data/workflows.json";
|
||||
import RawMarkdown from "../../components/pages/RawMarkdown.astro";
|
||||
import type { HeaderItem } from "../../components/pages/Header.astro";
|
||||
import DetailHeader from "../../components/pages/Header.astro";
|
||||
import Breadcrumb from "../../components/pages/Breadcrumb.astro";
|
||||
import Main from "../../components/pages/Main.astro";
|
||||
import Sidebar from "../../components/pages/Sidebar.astro";
|
||||
import { readResourceMarkdown, formatLastUpdated } from "../../lib/detail-page";
|
||||
|
||||
const GITHUB_BASE = "https://github.com/github/awesome-copilot/blob/main";
|
||||
const workflowGuideHref = "/learning-hub/agentic-workflows/";
|
||||
|
||||
interface WorkflowItem extends HeaderItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
triggers: string[];
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
export function getStaticPaths() {
|
||||
return (workflowsData.items as WorkflowItem[]).map((item) => ({
|
||||
params: { id: item.id },
|
||||
props: { item },
|
||||
}));
|
||||
}
|
||||
|
||||
const { item } = Astro.props as { item: WorkflowItem };
|
||||
|
||||
const { markdownHtml, frontmatterText, rawMarkdown } = readResourceMarkdown(
|
||||
item.path
|
||||
);
|
||||
const lastUpdated = formatLastUpdated(item.lastUpdated);
|
||||
const githubUrl = `${GITHUB_BASE}/${item.path}`;
|
||||
|
||||
const chips: { title: string; items: string[] }[] = [];
|
||||
if (item.triggers.length > 0) {
|
||||
chips.push({ title: "Triggers", items: item.triggers });
|
||||
}
|
||||
---
|
||||
|
||||
<StarlightPage
|
||||
frontmatter={{
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
template: "splash",
|
||||
prev: false,
|
||||
next: false,
|
||||
editUrl: false,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
id="main-content"
|
||||
class="resource-detail-page"
|
||||
data-resource-detail
|
||||
data-path={item.path}
|
||||
>
|
||||
<RawMarkdown markdown={rawMarkdown} />
|
||||
<div class="container">
|
||||
<Breadcrumb
|
||||
paths={[
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Agentic Workflows", href: "/workflows/" },
|
||||
]}
|
||||
title={item.title}
|
||||
/>
|
||||
|
||||
<DetailHeader item={item} />
|
||||
|
||||
<div class="detail-layout">
|
||||
<Main markdownHtml={markdownHtml} githubUrl={githubUrl} />
|
||||
|
||||
<Sidebar
|
||||
item={item}
|
||||
lastUpdated={lastUpdated ?? undefined}
|
||||
frontmatterText={frontmatterText || undefined}
|
||||
githubUrl={githubUrl}
|
||||
chips={chips}
|
||||
>
|
||||
<div class="skill-install" slot="install">
|
||||
<p class="skill-install-label">Install this workflow</p>
|
||||
<p class="skill-install-note">
|
||||
Install the <code>gh aw</code> CLI{" "}
|
||||
(<code>gh extension install github/gh-aw</code>), then copy this
|
||||
file into <code>.github/workflows/</code> and run{" "}
|
||||
<code>gh aw compile</code> to generate the lock file. See the{" "}
|
||||
<a href={workflowGuideHref}>Agentic Workflows guide</a>{" "}
|
||||
for full setup steps.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary skill-download-btn"
|
||||
data-action="download"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
><path
|
||||
d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z"
|
||||
></path><path
|
||||
d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969Z"
|
||||
></path></svg
|
||||
>
|
||||
Download workflow
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary skill-download-btn"
|
||||
data-action="copy-markdown"
|
||||
>
|
||||
Copy markdown
|
||||
</button>
|
||||
</div>
|
||||
</Sidebar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
|
||||
<script>
|
||||
import "../../scripts/pages/resource-detail";
|
||||
</script>
|
||||
</StarlightPage>
|
||||
@@ -5,7 +5,6 @@ import ContributeCTA from '../components/ContributeCTA.astro';
|
||||
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import BackToTop from '../components/BackToTop.astro';
|
||||
import Modal from '../components/Modal.astro';
|
||||
import { renderWorkflowsHtml, sortWorkflows } from '../scripts/pages/workflows-render';
|
||||
|
||||
const initialItems = sortWorkflows(workflowsData.items, 'title');
|
||||
@@ -48,7 +47,6 @@ const initialItems = sortWorkflows(workflowsData.items, 'title');
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
<Modal />
|
||||
<EmbeddedPageData filename="workflows.json" data={workflowsData} />
|
||||
|
||||
<script>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -2792,7 +2792,7 @@ body:has(#main-content) {
|
||||
|
||||
.article-content a {
|
||||
color: var(--color-link);
|
||||
text-decoration: none;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.article-content a:hover {
|
||||
@@ -2800,6 +2800,15 @@ body:has(#main-content) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Beat the global `#main-content a { text-decoration: none }` reset so links
|
||||
inside rendered docs and install notes stay distinguishable without relying
|
||||
on color alone (WCAG 1.4.1 / axe link-in-text-block). */
|
||||
#main-content .article-content a,
|
||||
#main-content .skill-install-note a,
|
||||
#main-content .detail-empty a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.article-content ul,
|
||||
.article-content ol {
|
||||
margin-bottom: 16px;
|
||||
@@ -2836,6 +2845,65 @@ body:has(#main-content) {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.article-content .code-block {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.code-copy-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.code-block:hover .code-copy-btn,
|
||||
.code-block:focus-within .code-copy-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.code-copy-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.code-copy-btn:hover {
|
||||
color: var(--color-link);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.code-copy-btn:focus-visible {
|
||||
opacity: 1;
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.code-copy-btn.copied {
|
||||
color: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.code-copy-btn {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.article-content blockquote {
|
||||
margin: 16px 0;
|
||||
padding: 12px 20px;
|
||||
@@ -3636,3 +3704,498 @@ header .theme-toggle-container {
|
||||
.choices__list[aria-expanded] .choices__item--selectable.is-highlighted {
|
||||
background-color: var(--color-bg-tertiary) !important;
|
||||
}
|
||||
|
||||
|
||||
/* ==========================================================================
|
||||
Resource detail pages (agents, instructions, ...)
|
||||
Shared layout for the dedicated per-item pages.
|
||||
========================================================================== */
|
||||
.resource-detail-page {
|
||||
padding-block: 2rem 3rem;
|
||||
}
|
||||
.resource-detail-page .detail-breadcrumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.resource-detail-page .detail-breadcrumbs a {
|
||||
color: var(--color-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
.resource-detail-page .detail-breadcrumbs a:hover {
|
||||
color: var(--color-link-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.resource-detail-page .detail-header {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.resource-detail-page .detail-header h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: clamp(1.75rem, 4vw, 2.5rem);
|
||||
color: var(--color-text-emphasis);
|
||||
}
|
||||
.resource-detail-page .detail-description {
|
||||
margin: 0;
|
||||
max-width: 60ch;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.resource-detail-page .detail-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 22rem);
|
||||
gap: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
.resource-detail-page .detail-sidebar {
|
||||
position: sticky;
|
||||
top: 1.5rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.resource-detail-page .detail-card {
|
||||
padding: 1.25rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.resource-detail-page .detail-card h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text-emphasis);
|
||||
}
|
||||
.resource-detail-page .detail-actions-card {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.resource-detail-page .detail-actions-card > * {
|
||||
min-width: 0;
|
||||
}
|
||||
.resource-detail-page .detail-actions-card .install-dropdown {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
.resource-detail-page .detail-actions-card .install-btn-main {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.resource-detail-page .detail-actions-card .install-dropdown-menu {
|
||||
left: 0;
|
||||
right: 0;
|
||||
min-width: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.resource-detail-page .detail-actions-secondary {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.resource-detail-page .detail-actions-secondary .btn {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.resource-detail-page .detail-section {
|
||||
min-width: 0;
|
||||
}
|
||||
.resource-detail-page .detail-section h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.35rem;
|
||||
color: var(--color-text-emphasis);
|
||||
}
|
||||
.resource-detail-page .included-items-hint {
|
||||
margin: -0.5rem 0 1.25rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.resource-detail-page .included-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.resource-detail-page .included-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.resource-detail-page .included-group-title {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.resource-detail-page .included-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.resource-detail-page .included-entry {
|
||||
margin: 0;
|
||||
}
|
||||
.resource-detail-page .included-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--color-bg-subtle, transparent);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
}
|
||||
.resource-detail-page .included-link:hover {
|
||||
border-color: var(--color-accent, var(--sl-color-accent));
|
||||
background: var(--sl-color-gray-6, rgba(0, 0, 0, 0.03));
|
||||
}
|
||||
.resource-detail-page .included-kind-badge {
|
||||
flex: none;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.15em 0.5em;
|
||||
border-radius: 999px;
|
||||
background: var(--sl-color-gray-6, rgba(0, 0, 0, 0.06));
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.resource-detail-page .included-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 0.92rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.resource-detail-page .included-external-hint {
|
||||
flex: none;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.resource-detail-page .detail-meta {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.resource-detail-page .detail-meta > div {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.resource-detail-page .detail-meta dt {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.resource-detail-page .detail-meta dd {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.resource-detail-page .detail-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.resource-detail-page .resource-tag-link {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
.resource-detail-page .resource-tag-link:hover,
|
||||
.resource-detail-page .resource-tag-link:focus-visible {
|
||||
color: var(--color-link);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.resource-detail-page .resource-tag-link {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
.resource-detail-page .detail-none {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.resource-detail-page .detail-path {
|
||||
font-size: 0.82rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.resource-detail-page .detail-frontmatter summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-emphasis);
|
||||
}
|
||||
.resource-detail-page .detail-frontmatter pre {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.resource-detail-page .detail-empty {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.resource-detail-page .detail-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.resource-detail-page .detail-sidebar {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Detail install slot (skill/hook/workflow): install command + downloads --- */
|
||||
.detail-actions-card .skill-install {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.detail-actions-card .skill-install-label {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.detail-actions-card .skill-install-command {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.detail-actions-card .skill-install-command code {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
font-size: 0.82rem;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
.detail-actions-card .skill-install-copy {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.35rem;
|
||||
color: var(--color-text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.detail-actions-card .skill-install-copy:hover {
|
||||
color: var(--color-text-emphasis);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.detail-actions-card .skill-download-btn {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.detail-actions-card .skill-install-note {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.detail-actions-card .skill-install-note a {
|
||||
color: var(--color-text-accent);
|
||||
}
|
||||
.detail-actions-card .skill-install-url-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* --- Extension detail preview gallery --- */
|
||||
.extension-gallery .extension-gallery-main {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
.extension-gallery .extension-gallery-main img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.extension-gallery .extension-gallery-thumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.extension-gallery .extension-gallery-thumb {
|
||||
padding: 0;
|
||||
width: 88px;
|
||||
height: 64px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.extension-gallery .extension-gallery-thumb img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.extension-gallery .extension-gallery-thumb:hover {
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
.extension-gallery .extension-gallery-thumb.active {
|
||||
border-color: var(--color-text-accent);
|
||||
box-shadow: 0 0 0 1px var(--color-text-accent);
|
||||
}
|
||||
|
||||
.skill-file-browser {
|
||||
display: block;
|
||||
}
|
||||
.skill-file-view {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
.skill-file-view-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
.skill-file-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.skill-file-picker-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.skill-file-select {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex: 0 1 auto;
|
||||
margin: 0;
|
||||
padding: 0.35rem 2rem 0.35rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%23656d76' stroke-width='1.5'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.5rem center;
|
||||
}
|
||||
.skill-file-select:hover {
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
.skill-file-select:focus-visible {
|
||||
outline: 2px solid var(--color-accent, #6b21c8);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.skill-file-current {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.82rem;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
.skill-file-view-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.skill-file-content {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.skill-file-content .skill-file-image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.skill-file-content.is-code {
|
||||
padding: 0;
|
||||
}
|
||||
.skill-file-content .shiki,
|
||||
.skill-file-content .skill-file-plain {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
:root[data-theme="dark"] .skill-file-content .shiki,
|
||||
:root[data-theme="dark"] .skill-file-content .shiki span {
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
color: var(--shiki-dark) !important;
|
||||
}
|
||||
.skill-file-content.is-code .shiki,
|
||||
.skill-file-content.is-code .skill-file-plain {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
.skill-file-status {
|
||||
padding: 0.85rem 1.25rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.skill-file-view-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.skill-file-picker {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
.skill-file-select {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user