chore: publish from main

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