mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-14 01:51:02 +00:00
chore: publish from main
This commit is contained in:
@@ -24,3 +24,4 @@ website/public/llms.txt
|
||||
*.sln
|
||||
obj/
|
||||
bin/
|
||||
.impeccable
|
||||
|
||||
+129
-15
@@ -251,6 +251,10 @@ function generateHooksData(gitDates) {
|
||||
.replace(/\\/g, "/");
|
||||
const readmeRelativePath = `${relativePath}/README.md`;
|
||||
|
||||
// Get all files in the hook folder recursively (for the file browser and
|
||||
// ZIP download on the detail page).
|
||||
const files = getFolderFiles(hookPath, relativePath);
|
||||
|
||||
// Track unique values
|
||||
(metadata.hooks || []).forEach((h) => allHookTypes.add(h));
|
||||
(metadata.tags || []).forEach((t) => allTags.add(t));
|
||||
@@ -262,8 +266,10 @@ function generateHooksData(gitDates) {
|
||||
hooks: metadata.hooks || [],
|
||||
tags: metadata.tags || [],
|
||||
assets: metadata.assets || [],
|
||||
files,
|
||||
path: relativePath,
|
||||
readmeFile: readmeRelativePath,
|
||||
readmeFileName: "README.md",
|
||||
lastUpdated: gitDates.get(readmeRelativePath) || null,
|
||||
});
|
||||
}
|
||||
@@ -465,7 +471,7 @@ function generateSkillsData(gitDates) {
|
||||
.replace(/\\/g, "/");
|
||||
|
||||
// Get all files in the skill folder recursively
|
||||
const files = getSkillFiles(skillPath, relativePath);
|
||||
const files = getFolderFiles(skillPath, relativePath);
|
||||
|
||||
// Get last updated from SKILL.md file
|
||||
const skillFilePath = `${relativePath}/SKILL.md`;
|
||||
@@ -511,9 +517,9 @@ function generateSkillsData(gitDates) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all files in a skill folder recursively
|
||||
* Get all files in a resource folder (skill or hook) recursively.
|
||||
*/
|
||||
function getSkillFiles(skillPath, relativePath) {
|
||||
function getFolderFiles(skillPath, relativePath) {
|
||||
const files = [];
|
||||
|
||||
function walkDir(dir, relDir) {
|
||||
@@ -555,10 +561,89 @@ function getAgentFiles(agentDir, pluginRootPath) {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a lookup index of resource id -> { title, url } for the kinds that have
|
||||
* dedicated detail pages, so plugin items can deep-link to them.
|
||||
*/
|
||||
function buildResourceIndex({ agents, skills, instructions, hooks, extensions }) {
|
||||
const toMap = (items, urlPrefix) => {
|
||||
const map = new Map();
|
||||
for (const item of items || []) {
|
||||
if (!item?.id) continue;
|
||||
map.set(item.id, {
|
||||
title: item.title || item.id,
|
||||
url: `/${urlPrefix}/${item.id}/`,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
};
|
||||
const extensionMap = new Map();
|
||||
for (const item of extensions || []) {
|
||||
if (!item?.id) continue;
|
||||
|
||||
const entry = {
|
||||
title: item.name || item.title || item.id,
|
||||
url: `/extension/${item.id}/`,
|
||||
};
|
||||
const keys = [
|
||||
item.id,
|
||||
item.extensionId,
|
||||
item.path ? pluginItemCandidateId(item.path) : null,
|
||||
].filter(Boolean);
|
||||
|
||||
for (const key of keys) {
|
||||
if (!extensionMap.has(key)) {
|
||||
extensionMap.set(key, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agent: toMap(agents, "agent"),
|
||||
skill: toMap(skills, "skill"),
|
||||
instruction: toMap(instructions, "instruction"),
|
||||
hook: toMap(hooks, "hook"),
|
||||
extension: extensionMap,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the candidate resource id for a plugin item path (basename without a
|
||||
* known resource extension), e.g. "./skills/foo/" -> "foo",
|
||||
* "plugins/x/agents/bar.md" -> "bar".
|
||||
*/
|
||||
function pluginItemCandidateId(itemPath) {
|
||||
const trimmed = String(itemPath || "")
|
||||
.replace(/^\.\/+/, "")
|
||||
.replace(/\/+$/, "");
|
||||
const base = trimmed.split("/").pop() || "";
|
||||
return base
|
||||
.replace(/\.agent\.md$/i, "")
|
||||
.replace(/\.prompt\.md$/i, "")
|
||||
.replace(/\.instructions\.md$/i, "")
|
||||
.replace(/\.md$/i, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich a plugin item ({ kind, path }) with a display title and, when the item
|
||||
* resolves to a known resource with a detail page, a detailUrl.
|
||||
*/
|
||||
function resolvePluginItem(item, resourceIndex) {
|
||||
const candidateId = pluginItemCandidateId(item.path);
|
||||
const lookup = resourceIndex?.[item.kind];
|
||||
const match = lookup?.get(candidateId);
|
||||
|
||||
return {
|
||||
...item,
|
||||
title: match?.title || candidateId || item.path,
|
||||
detailUrl: match?.url || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate plugins metadata
|
||||
*/
|
||||
function generatePluginsData(gitDates) {
|
||||
function generatePluginsData(gitDates, resourceIndex = {}) {
|
||||
const plugins = [];
|
||||
const extensionEntriesByName = new Map();
|
||||
|
||||
@@ -594,16 +679,25 @@ function generatePluginsData(gitDates) {
|
||||
? [...new Set(extensionPlugin.keywords.filter((keyword) => typeof keyword === "string").map((keyword) => keyword.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b))
|
||||
: [];
|
||||
const relPath = `extensions/${extensionDirName}`;
|
||||
const extensionItem = {
|
||||
kind: "extension",
|
||||
path: relPath,
|
||||
};
|
||||
const extensionItem = resolvePluginItem(
|
||||
{
|
||||
kind: "extension",
|
||||
path: relPath,
|
||||
},
|
||||
resourceIndex
|
||||
);
|
||||
const extReadmePath = path.join(extensionDir, "README.md");
|
||||
const extReadmeFile = fs.existsSync(extReadmePath)
|
||||
? `${relPath}/README.md`
|
||||
: null;
|
||||
|
||||
extensionEntriesByName.set(pluginName, {
|
||||
id: pluginName,
|
||||
name: pluginName,
|
||||
description: pluginDescription,
|
||||
path: relPath,
|
||||
readmeFile: extReadmeFile,
|
||||
version: normalizeText(extensionPlugin.version, null),
|
||||
tags: extensionKeywords,
|
||||
itemCount: 1,
|
||||
items: [extensionItem],
|
||||
@@ -661,16 +755,23 @@ function generatePluginsData(gitDates) {
|
||||
...(data.commands || []).map((p) => ({ kind: "prompt", path: p })),
|
||||
...(data.skills || []).map((p) => ({ kind: "skill", path: p })),
|
||||
...extensionItems,
|
||||
];
|
||||
].map((item) => resolvePluginItem(item, resourceIndex));
|
||||
|
||||
const tags = data.keywords || data.tags || [];
|
||||
const pluginName = data.name || dir.name;
|
||||
|
||||
const readmePath = path.join(pluginDir, "README.md");
|
||||
const readmeFile = fs.existsSync(readmePath)
|
||||
? `${relPath}/README.md`
|
||||
: null;
|
||||
|
||||
plugins.push({
|
||||
id: dir.name,
|
||||
name: pluginName,
|
||||
description: data.description || "",
|
||||
path: relPath,
|
||||
readmeFile,
|
||||
version: normalizeText(data.version, null),
|
||||
tags: tags,
|
||||
itemCount: items.length,
|
||||
items: items,
|
||||
@@ -720,6 +821,7 @@ function generatePluginsData(gitDates) {
|
||||
name: ext.name,
|
||||
description: ext.description || "",
|
||||
path: `plugins/${ext.name}`,
|
||||
version: normalizeText(ext.version, null),
|
||||
tags: tags,
|
||||
itemCount: 0,
|
||||
items: [],
|
||||
@@ -1196,6 +1298,9 @@ function generateCanvasManifest(gitDates, commitSha) {
|
||||
);
|
||||
const extensionName = normalizeText(pluginJson.name, normalizeText(packageJson.name, dir.name));
|
||||
const extensionVersion = normalizeText(pluginJson.version, normalizeText(packageJson.version, "1.0.0"));
|
||||
const readmeFile = fs.existsSync(path.join(extensionDir, "README.md"))
|
||||
? `${relPath}/README.md`
|
||||
: null;
|
||||
const screenshots = resolveExtensionScreenshots(pluginJson, extensionDir, relPath, commitSha);
|
||||
const canvasFiles = getExtensionCanvasFiles(extensionDir);
|
||||
const canvases = [];
|
||||
@@ -1224,6 +1329,7 @@ function generateCanvasManifest(gitDates, commitSha) {
|
||||
pluginName: extensionName,
|
||||
name: canvasName,
|
||||
version: extensionVersion,
|
||||
readmeFile,
|
||||
description: canvasDescription,
|
||||
path: relPath,
|
||||
ref: commitSha,
|
||||
@@ -1299,6 +1405,7 @@ function generateCanvasManifest(gitDates, commitSha) {
|
||||
pluginName: null,
|
||||
name,
|
||||
version: normalizeText(ext?.version, "1.0.0"),
|
||||
readmeFile: null,
|
||||
description: normalizeText(ext?.description, "External canvas extension"),
|
||||
path: null,
|
||||
ref: null,
|
||||
@@ -1686,12 +1793,6 @@ async function main() {
|
||||
const skills = skillsData.items;
|
||||
console.log(`✓ Generated ${skills.length} skills`);
|
||||
|
||||
const pluginsData = generatePluginsData(gitDates);
|
||||
const plugins = pluginsData.items;
|
||||
console.log(
|
||||
`✓ Generated ${plugins.length} plugins (${pluginsData.filters.tags.length} tags)`
|
||||
);
|
||||
|
||||
const extensionManifestData = generateCanvasManifest(gitDates, commitSha);
|
||||
const extensionsData = generateExtensionsData(extensionManifestData);
|
||||
const extensions = extensionsData.items;
|
||||
@@ -1699,6 +1800,19 @@ async function main() {
|
||||
`✓ Generated ${extensions.length} extensions (${extensionsData.filters.keywords.length} keywords)`
|
||||
);
|
||||
|
||||
const resourceIndex = buildResourceIndex({
|
||||
agents,
|
||||
skills,
|
||||
instructions,
|
||||
hooks,
|
||||
extensions,
|
||||
});
|
||||
const pluginsData = generatePluginsData(gitDates, resourceIndex);
|
||||
const plugins = pluginsData.items;
|
||||
console.log(
|
||||
`✓ Generated ${plugins.length} plugins (${pluginsData.filters.tags.length} tags)`
|
||||
);
|
||||
|
||||
const toolsData = generateToolsData();
|
||||
const tools = toolsData.items;
|
||||
console.log(
|
||||
|
||||
Generated
+549
@@ -15,6 +15,7 @@
|
||||
"choices.js": "^11.2.2",
|
||||
"front-matter": "^4.0.2",
|
||||
"gray-matter": "^4.0.3",
|
||||
"isomorphic-dompurify": "^2.36.0",
|
||||
"jszip": "^3.10.1",
|
||||
"marked": "^18.0.2",
|
||||
"shiki": "^4.0.2"
|
||||
@@ -24,6 +25,56 @@
|
||||
"playwright": "^1.49.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@acemir/cssom": {
|
||||
"version": "0.9.31",
|
||||
"resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
|
||||
"integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "5.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@csstools/css-calc": "^3.2.0",
|
||||
"@csstools/css-color-parser": "^4.1.0",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "6.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
|
||||
"integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.1.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/generational-cache": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
||||
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/nwsapi": {
|
||||
"version": "2.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
||||
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@astrojs/compiler-binding": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.2.3.tgz",
|
||||
@@ -415,6 +466,18 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-tree": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@bruits/satteri-darwin-arm64": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.9.4.tgz",
|
||||
@@ -568,6 +631,140 @@
|
||||
"node": ">= 20.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
|
||||
"integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
|
||||
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz",
|
||||
"integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.1.0",
|
||||
"@csstools/css-calc": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz",
|
||||
"integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"peerDependencies": {
|
||||
"css-tree": "^3.2.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"css-tree": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ctrl/tinycolor": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
|
||||
@@ -1024,6 +1221,23 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
|
||||
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@noble/hashes": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@expressive-code/core": {
|
||||
"version": "0.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@expressive-code/core/-/core-0.44.0.tgz",
|
||||
@@ -2181,6 +2395,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -2214,6 +2435,15 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/am-i-vibing": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.4.0.tgz",
|
||||
@@ -2445,6 +2675,15 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
@@ -2723,6 +2962,34 @@
|
||||
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz",
|
||||
"integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^5.0.1",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.0.28",
|
||||
"css-tree": "^3.1.0",
|
||||
"lru-cache": "^11.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2740,6 +3007,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decode-named-character-reference": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
||||
@@ -2877,6 +3150,15 @@
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.11",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
|
||||
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
@@ -3712,6 +3994,18 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
|
||||
@@ -3744,6 +4038,32 @@
|
||||
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.3",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.3.tgz",
|
||||
@@ -3912,6 +4232,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-wsl": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
|
||||
@@ -3933,6 +4259,19 @@
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isomorphic-dompurify": {
|
||||
"version": "2.36.0",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-2.36.0.tgz",
|
||||
"integrity": "sha512-E8YkGyPY3a/U5s0WOoc8Ok+3SWL/33yn2IHCoxCFLBUUPVy9WGa++akJZFxQCcJIhI+UvYhbrbnTIFQkHKZbgA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.3.1",
|
||||
"jsdom": "^28.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.5"
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
@@ -3955,6 +4294,70 @@
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "28.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
|
||||
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.31",
|
||||
"@asamuzakjp/dom-selector": "^6.8.1",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"cssstyle": "^6.0.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^6.0.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"parse5": "^8.0.0",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.0",
|
||||
"undici": "^7.21.0",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/entities": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonc-parser": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
|
||||
@@ -5821,6 +6224,15 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/radix3": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz",
|
||||
@@ -6156,6 +6568,15 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
@@ -6296,6 +6717,18 @@
|
||||
"node": ">=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/section-matter": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
||||
@@ -6543,6 +6976,12 @@
|
||||
"url": "https://opencollective.com/svgo"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
@@ -6583,6 +7022,48 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.4.7",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.7.tgz",
|
||||
"integrity": "sha512-56L0/9HELHSsG1bFCzay8UoLxzRL7kpFf7Wl5q/kSYwiSJGACvro61xnKzPNM+SadxllzdtXsKDSXE7HPeqIAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.4.7"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.4.7",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.7.tgz",
|
||||
"integrity": "sha512-rNlAI8fKn/JckBMUSbNL/ES2kmDiurWaE49l+ikwEc9A6lFR7gMx9AhgQMQKBK4H5w4pKLH64JzZfB99uRsGNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
|
||||
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/trim-lines": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||
@@ -6628,6 +7109,15 @@
|
||||
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
@@ -7052,6 +7542,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/web-namespaces": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
||||
@@ -7062,6 +7564,38 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which-pm-runs": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz",
|
||||
@@ -7071,6 +7605,21 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xxhash-wasm": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"choices.js": "^11.2.2",
|
||||
"front-matter": "^4.0.2",
|
||||
"gray-matter": "^4.0.3",
|
||||
"isomorphic-dompurify": "^2.36.0",
|
||||
"jszip": "^3.10.1",
|
||||
"marked": "^18.0.2",
|
||||
"shiki": "^4.0.2"
|
||||
|
||||
@@ -36,6 +36,15 @@ const routes = [
|
||||
'/contributors/',
|
||||
'/learning-hub/cookbook/',
|
||||
'/learning-hub/github-copilot-app/',
|
||||
// Representative dedicated detail pages (one per resource type) so the audit
|
||||
// covers the shared detail layout, sidebar, install buttons, and file browser.
|
||||
'/agent/accessibility/',
|
||||
'/instruction/a11y/',
|
||||
'/skill/acquire-codebase-knowledge/',
|
||||
'/hook/dependency-license-checker/',
|
||||
'/workflow/daily-issues-report/',
|
||||
'/plugin/accessibility-kanban/',
|
||||
'/extension/accessibility-kanban/',
|
||||
];
|
||||
|
||||
const themes = ['dark', 'light'];
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
export type Breadcrumb = {
|
||||
paths: { name: string; href?: string }[];
|
||||
title: string;
|
||||
};
|
||||
|
||||
const { paths, title } = Astro.props as Breadcrumb;
|
||||
---
|
||||
|
||||
<nav class="detail-breadcrumbs" aria-label="Breadcrumb">
|
||||
{
|
||||
paths.map((path, index) => (
|
||||
<>
|
||||
{path.href ? (
|
||||
<a href={path.href}>{path.name}</a>
|
||||
) : (
|
||||
<span>{path.name}</span>
|
||||
)}
|
||||
{index < paths.length - 1 && <span aria-hidden="true">/</span>}
|
||||
</>
|
||||
))
|
||||
}
|
||||
<span aria-hidden="true">/</span>
|
||||
<span aria-current="page">{title}</span>
|
||||
</nav>
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
import { getFileMeta } from "../../lib/file-types";
|
||||
|
||||
export interface BrowserFile {
|
||||
name: string;
|
||||
path: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface FileBrowserProps {
|
||||
files: BrowserFile[];
|
||||
/** Full repo path of the primary file, e.g. skills/foo/SKILL.md or hooks/foo/README.md */
|
||||
primaryPath: string;
|
||||
/** Display name of the primary file, e.g. SKILL.md or README.md */
|
||||
primaryName: string;
|
||||
/** Pre-rendered HTML for the primary file (shown by default) */
|
||||
primaryHtml: string;
|
||||
/** GitHub blob base, e.g. https://github.com/github/awesome-copilot/blob/main */
|
||||
githubBase: string;
|
||||
}
|
||||
|
||||
const { files, primaryPath, primaryName, primaryHtml, githubBase } =
|
||||
Astro.props as FileBrowserProps;
|
||||
|
||||
// Primary file first, then everything else alphabetically by name.
|
||||
const sortedFiles = [...files].sort((a, b) => {
|
||||
if (a.path === primaryPath) return -1;
|
||||
if (b.path === primaryPath) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const otherFiles = sortedFiles.filter((f) => f.path !== primaryPath);
|
||||
const rootFiles = otherFiles.filter((f) => !f.name.includes("/"));
|
||||
|
||||
// Group nested files by their top-level folder for optgroup rendering.
|
||||
const folderGroups = new Map<string, BrowserFile[]>();
|
||||
for (const file of otherFiles) {
|
||||
const slash = file.name.indexOf("/");
|
||||
if (slash === -1) continue;
|
||||
const folder = file.name.slice(0, slash);
|
||||
const bucket = folderGroups.get(folder) ?? [];
|
||||
bucket.push(file);
|
||||
folderGroups.set(folder, bucket);
|
||||
}
|
||||
const sortedFolders = [...folderGroups.keys()].sort((a, b) =>
|
||||
a.localeCompare(b)
|
||||
);
|
||||
|
||||
const hasMultipleFiles = sortedFiles.length > 1;
|
||||
|
||||
function optionAttrs(file: BrowserFile) {
|
||||
const meta = getFileMeta(file.name);
|
||||
return {
|
||||
value: file.path,
|
||||
"data-file-name": file.name,
|
||||
"data-file-lang": meta.lang,
|
||||
"data-file-kind": meta.kind,
|
||||
};
|
||||
}
|
||||
---
|
||||
|
||||
<section
|
||||
class="skill-file-browser"
|
||||
aria-label="Bundle files"
|
||||
data-file-browser
|
||||
data-github-base={githubBase}
|
||||
data-primary-file={primaryPath}
|
||||
>
|
||||
<div class="skill-file-view">
|
||||
<header class="skill-file-view-header">
|
||||
{
|
||||
hasMultipleFiles ? (
|
||||
<div class="skill-file-picker">
|
||||
<label class="skill-file-picker-label" for="file-browser-select">
|
||||
File
|
||||
</label>
|
||||
<select
|
||||
id="file-browser-select"
|
||||
class="skill-file-select"
|
||||
data-file-select
|
||||
aria-label="Select a file to view"
|
||||
>
|
||||
<option {...optionAttrs({ name: primaryName, path: primaryPath })} selected>
|
||||
{primaryName}
|
||||
</option>
|
||||
{rootFiles.map((file) => (
|
||||
<option {...optionAttrs(file)}>{file.name}</option>
|
||||
))}
|
||||
{sortedFolders.map((folder) => (
|
||||
<optgroup label={`${folder}/`}>
|
||||
{folderGroups.get(folder)!.map((file) => (
|
||||
<option {...optionAttrs(file)}>
|
||||
{file.name.slice(folder.length + 1)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<code class="skill-file-current" data-current-file-name>
|
||||
{primaryName}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
<div class="skill-file-view-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-small"
|
||||
data-action="copy-file"
|
||||
title="Copy file contents"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
><path
|
||||
d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"
|
||||
></path><path
|
||||
d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"
|
||||
></path></svg
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<a
|
||||
class="btn btn-secondary btn-small"
|
||||
data-file-github
|
||||
href={`${githubBase}/${primaryPath}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="View file on GitHub">GitHub</a
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="skill-file-content article-content"
|
||||
data-file-content
|
||||
set:html={primaryHtml}
|
||||
/>
|
||||
<div class="skill-file-status" data-file-status hidden></div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
export type HeaderItem = {
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const { item } = Astro.props as { item: HeaderItem };
|
||||
---
|
||||
|
||||
<header class="detail-header">
|
||||
<h1>{item.title}</h1>
|
||||
{item.description && <p class="detail-description">{item.description}</p>}
|
||||
</header>
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
export interface PluginIncludedItem {
|
||||
kind: string;
|
||||
path: string;
|
||||
title?: string;
|
||||
detailUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface IncludedItemsProps {
|
||||
items: PluginIncludedItem[];
|
||||
pluginPath: string;
|
||||
githubBase: string;
|
||||
}
|
||||
|
||||
const { items, pluginPath, githubBase } = Astro.props as IncludedItemsProps;
|
||||
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
agent: "Agents",
|
||||
skill: "Skills",
|
||||
instruction: "Instructions",
|
||||
hook: "Hooks",
|
||||
prompt: "Commands",
|
||||
extension: "Extensions",
|
||||
};
|
||||
|
||||
const KIND_ORDER = ["agent", "skill", "instruction", "hook", "prompt", "extension"];
|
||||
|
||||
function githubHref(itemPath: string): string {
|
||||
const normalized = itemPath.replace(/^\.\/+/, "").replace(/\/+$/, "");
|
||||
// Repo-root-relative paths (e.g. extensions/foo, plugins/foo) are used as-is;
|
||||
// plugin-relative paths are resolved against the plugin folder.
|
||||
const isRepoRelative = /^[a-z0-9._-]+\//i.test(normalized) &&
|
||||
(normalized.startsWith("extensions/") ||
|
||||
normalized.startsWith("plugins/") ||
|
||||
normalized.startsWith("agents/") ||
|
||||
normalized.startsWith("skills/") ||
|
||||
normalized.startsWith("instructions/") ||
|
||||
normalized.startsWith("hooks/"));
|
||||
const repoPath = isRepoRelative ? normalized : `${pluginPath}/${normalized}`;
|
||||
// GitHub uses /blob/ for files and /tree/ for directories; /tree/<ref>/<file>
|
||||
// returns 404. Detect files by a trailing extension on the last path segment.
|
||||
const lastSegment = repoPath.split("/").pop() ?? "";
|
||||
const isFile = /\.[a-z0-9]+$/i.test(lastSegment);
|
||||
const base = isFile ? githubBase.replace("/tree/", "/blob/") : githubBase;
|
||||
return `${base}/${repoPath}`;
|
||||
}
|
||||
|
||||
const grouped = KIND_ORDER.map((kind) => ({
|
||||
kind,
|
||||
label: KIND_LABELS[kind] ?? kind,
|
||||
entries: (items ?? []).filter((item) => item.kind === kind),
|
||||
})).filter((group) => group.entries.length > 0);
|
||||
---
|
||||
|
||||
{
|
||||
grouped.length > 0 && (
|
||||
<section class="detail-section included-items" aria-labelledby="included-heading">
|
||||
<h2 id="included-heading">Included items</h2>
|
||||
<p class="included-items-hint">
|
||||
This plugin bundles the following resources. Installing the plugin brings
|
||||
them all in together.
|
||||
</p>
|
||||
{grouped.map((group) => (
|
||||
<div class="included-group">
|
||||
<h3 class="included-group-title">{group.label}</h3>
|
||||
<ul class="included-list">
|
||||
{group.entries.map((item) => {
|
||||
const href = item.detailUrl ?? githubHref(item.path);
|
||||
const isExternal = !item.detailUrl;
|
||||
return (
|
||||
<li class="included-entry">
|
||||
<a
|
||||
class="included-link"
|
||||
href={href}
|
||||
{...(isExternal
|
||||
? { target: "_blank", rel: "noopener" }
|
||||
: {})}
|
||||
>
|
||||
<span class="included-kind-badge">{item.kind}</span>
|
||||
<span class="included-title">{item.title || item.path}</span>
|
||||
{isExternal && (
|
||||
<span class="included-external-hint">GitHub ↗</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
const { vscodeUrl, insidersUrl, rawMarkdown } = Astro.props as { vscodeUrl: string; insidersUrl: string; rawMarkdown?: string };
|
||||
---
|
||||
|
||||
<div class="install-dropdown" data-install-menu>
|
||||
<a
|
||||
class="btn btn-primary install-btn-main"
|
||||
href={vscodeUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"><path d="M12 5v14M5 12h14"></path></svg
|
||||
>
|
||||
Install
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary install-btn-toggle"
|
||||
aria-label="More install options"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
data-install-toggle
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="12"
|
||||
height="12"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
><path
|
||||
d="M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z"
|
||||
></path></svg
|
||||
>
|
||||
</button>
|
||||
<div class="install-dropdown-menu" role="menu">
|
||||
<a href={vscodeUrl} target="_blank" rel="noopener" role="menuitem"
|
||||
>Install in VS Code</a
|
||||
>
|
||||
<a href={insidersUrl} target="_blank" rel="noopener" role="menuitem"
|
||||
>Install in VS Code Insiders</a
|
||||
>
|
||||
<div class="dropdown-divider" role="separator" aria-hidden="true"></div>
|
||||
<a href="#" role="menuitem" data-action="download">Download file</a>
|
||||
{
|
||||
rawMarkdown && (
|
||||
<a href="#" role="menuitem" data-action="copy-markdown">
|
||||
Copy markdown
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
const githubUrl = Astro.props.githubUrl as string;
|
||||
const markdownHtml = Astro.props.markdownHtml as string;
|
||||
---
|
||||
|
||||
<div class="detail-main">
|
||||
{markdownHtml ? (
|
||||
<section class="detail-section" aria-labelledby="doc-heading">
|
||||
<h2 id="doc-heading">Documentation</h2>
|
||||
<div class="article-content" set:html={markdownHtml} />
|
||||
</section>
|
||||
) : (
|
||||
<section class="detail-section">
|
||||
<p class="detail-empty">Documentation preview is unavailable. View the file <a href={githubUrl} target="_blank" rel="noopener">on GitHub</a>.</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
@@ -0,0 +1,171 @@
|
||||
---
|
||||
/**
|
||||
* Install actions for plugins and canvas extensions.
|
||||
*
|
||||
* Renders a split-button dropdown that deep-links into VS Code, VS Code
|
||||
* Insiders, and the GitHub Copilot app, plus an optional CLI command block.
|
||||
* The dropdown open/close is wired by resource-detail.ts via `data-install-menu`.
|
||||
*/
|
||||
export interface PluginInstallProps {
|
||||
isExternal?: boolean;
|
||||
/** awesome-copilot plugin id (internal installs). */
|
||||
pluginId?: string | null;
|
||||
/** Upstream marketplace source (owner/repo or git URL) for external installs. */
|
||||
externalSource?: string | null;
|
||||
/** CLI install command (internal only). */
|
||||
installCommand?: string | null;
|
||||
/** Heading shown above the buttons. */
|
||||
label?: string;
|
||||
/** Note shown for external installs. */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
isExternal = false,
|
||||
pluginId = null,
|
||||
externalSource = null,
|
||||
installCommand = null,
|
||||
label,
|
||||
note,
|
||||
} = Astro.props as PluginInstallProps;
|
||||
|
||||
const MARKETPLACE = "awesome-copilot";
|
||||
|
||||
let vscodeUrl: string | null = null;
|
||||
let insidersUrl: string | null = null;
|
||||
let ghappUrl: string | null = null;
|
||||
|
||||
if (isExternal) {
|
||||
if (externalSource) {
|
||||
vscodeUrl = `vscode://chat-plugin/install?source=${encodeURIComponent(
|
||||
externalSource
|
||||
)}`;
|
||||
insidersUrl = `vscode-insiders://chat-plugin/install?source=${encodeURIComponent(
|
||||
externalSource
|
||||
)}`;
|
||||
ghappUrl = `ghapp://plugins/marketplace/add?source=${encodeURIComponent(
|
||||
externalSource
|
||||
)}`;
|
||||
}
|
||||
} else if (pluginId) {
|
||||
const plugin = encodeURIComponent(pluginId);
|
||||
vscodeUrl = `vscode://chat-plugin/install?source=github/awesome-copilot&plugin=${plugin}`;
|
||||
insidersUrl = `vscode-insiders://chat-plugin/install?source=github/awesome-copilot&plugin=${plugin}`;
|
||||
ghappUrl = `ghapp://plugins/install?source=${encodeURIComponent(
|
||||
`${pluginId}@${MARKETPLACE}`
|
||||
)}`;
|
||||
}
|
||||
|
||||
const ghappLabel = isExternal
|
||||
? "Open in GitHub Copilot app"
|
||||
: "Install in GitHub Copilot app";
|
||||
|
||||
const primaryUrl = ghappUrl ?? vscodeUrl ?? insidersUrl;
|
||||
const primaryLabel =
|
||||
primaryUrl === ghappUrl
|
||||
? ghappLabel
|
||||
: primaryUrl === vscodeUrl
|
||||
? "Install in VS Code"
|
||||
: "Install in VS Code Insiders";
|
||||
const primaryIsGhapp = primaryUrl === ghappUrl;
|
||||
const heading = label ?? (isExternal ? "Install this plugin" : "Install");
|
||||
---
|
||||
|
||||
<div class="skill-install">
|
||||
<p class="skill-install-label">{heading}</p>
|
||||
{note && <p class="skill-install-note">{note}</p>}
|
||||
|
||||
{
|
||||
primaryUrl && (
|
||||
<div class="install-dropdown" data-install-menu>
|
||||
<a
|
||||
class="btn btn-primary install-btn-main"
|
||||
href={primaryUrl}
|
||||
{...(primaryIsGhapp ? {} : { target: "_blank", rel: "noopener" })}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
{primaryLabel}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary install-btn-toggle"
|
||||
aria-label="More install options"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
data-install-toggle
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="12"
|
||||
height="12"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="install-dropdown-menu" role="menu">
|
||||
{ghappUrl && (
|
||||
<a href={ghappUrl} role="menuitem">
|
||||
{ghappLabel}
|
||||
</a>
|
||||
)}
|
||||
{ghappUrl && (vscodeUrl || insidersUrl) && (
|
||||
<div class="dropdown-divider" role="separator" aria-hidden="true" />
|
||||
)}
|
||||
{vscodeUrl && (
|
||||
<a href={vscodeUrl} target="_blank" rel="noopener" role="menuitem">
|
||||
Install in VS Code
|
||||
</a>
|
||||
)}
|
||||
{insidersUrl && (
|
||||
<a href={insidersUrl} target="_blank" rel="noopener" role="menuitem">
|
||||
Install in VS Code Insiders
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
installCommand && (
|
||||
<>
|
||||
<p class="skill-install-label">Or install with the CLI</p>
|
||||
<div class="skill-install-command" data-install-command={installCommand}>
|
||||
<code tabindex="0">{installCommand}</code>
|
||||
<button
|
||||
type="button"
|
||||
class="skill-install-copy"
|
||||
data-action="copy-install"
|
||||
title="Copy install command"
|
||||
aria-label="Copy install command"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="15"
|
||||
height="15"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z" />
|
||||
<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
const { markdown } = Astro.props as { markdown?: string };
|
||||
---
|
||||
|
||||
{
|
||||
markdown && (
|
||||
<textarea data-raw-markdown hidden aria-hidden="true" readonly set:text={markdown}></textarea>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
import type { SidebarChipsProps } from "./SidebarChips.astro";
|
||||
import SidebarChips from "./SidebarChips.astro";
|
||||
|
||||
export interface SidebarProps {
|
||||
item: any;
|
||||
lastUpdated?: string;
|
||||
frontmatterText?: string;
|
||||
githubUrl: string;
|
||||
chips?: SidebarChipsProps[];
|
||||
}
|
||||
const {
|
||||
item,
|
||||
lastUpdated,
|
||||
frontmatterText,
|
||||
githubUrl,
|
||||
chips,
|
||||
} = Astro.props as SidebarProps;
|
||||
---
|
||||
|
||||
<aside class="detail-sidebar" aria-label="Details">
|
||||
<section class="detail-card detail-actions-card" data-actions>
|
||||
<slot name="install" />
|
||||
|
||||
<div class="detail-actions-secondary">
|
||||
<button class="btn btn-secondary" type="button" data-action="share">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="15"
|
||||
height="15"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
><circle cx="18" cy="5" r="3"></circle><circle cx="6" cy="12" r="3"
|
||||
></circle><circle cx="18" cy="19" r="3"></circle><path
|
||||
d="M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98"></path></svg
|
||||
>
|
||||
Share
|
||||
</button>
|
||||
<a
|
||||
class="btn btn-secondary"
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="15"
|
||||
height="15"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
><path
|
||||
d="M12 2C6.48 2 2 6.48 2 12c0 4.42 2.87 8.17 6.84 9.5.5.09.68-.22.68-.48 0-.24-.01-.87-.01-1.71-2.78.6-3.37-1.34-3.37-1.34-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.89 1.53 2.34 1.09 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02.8-.22 1.65-.33 2.5-.33.85 0 1.7.11 2.5.33 1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.69-4.57 4.94.36.31.68.92.68 1.85 0 1.34-.01 2.42-.01 2.75 0 .27.18.58.69.48A10.01 10.01 0 0022 12c0-5.52-4.48-10-10-10z"
|
||||
></path></svg
|
||||
>
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-card">
|
||||
<h2>Details</h2>
|
||||
<dl class="detail-meta">
|
||||
{
|
||||
chips?.map((chip) => (
|
||||
<SidebarChips
|
||||
title={chip.title}
|
||||
items={chip.items}
|
||||
filterBase={chip.filterBase}
|
||||
filterParam={chip.filterParam}
|
||||
/>
|
||||
))
|
||||
}
|
||||
<div>
|
||||
<dt>Source</dt>
|
||||
<dd><code class="detail-path">{item.path}</code></dd>
|
||||
</div>
|
||||
{
|
||||
lastUpdated && (
|
||||
<div>
|
||||
<dt>Last updated</dt>
|
||||
<dd>{lastUpdated}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{
|
||||
frontmatterText && (
|
||||
<section class="detail-card">
|
||||
<details class="detail-frontmatter">
|
||||
<summary>Frontmatter</summary>
|
||||
<pre>
|
||||
<code>{frontmatterText}</code>
|
||||
</pre>
|
||||
</details>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
</aside>
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
export type SidebarChipsProps = {
|
||||
title: string;
|
||||
items: string[];
|
||||
/**
|
||||
* When both `filterBase` and `filterParam` are provided, each chip is
|
||||
* rendered as a link to the resource's list page pre-filtered by that value
|
||||
* (e.g. `/hooks/?tag=testing`), turning read-only metadata into navigation.
|
||||
*/
|
||||
filterBase?: string;
|
||||
filterParam?: string;
|
||||
};
|
||||
|
||||
const { title, items, filterBase, filterParam } =
|
||||
Astro.props as SidebarChipsProps;
|
||||
const slug = title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
const linkable = Boolean(filterBase && filterParam);
|
||||
const chipHref = (value: string) =>
|
||||
`${filterBase}?${filterParam}=${encodeURIComponent(value)}`;
|
||||
---
|
||||
|
||||
<div>
|
||||
<dt>{title}</dt>
|
||||
<dd>
|
||||
{
|
||||
items.length ? (
|
||||
<div class="detail-chips">
|
||||
{items.map((item) =>
|
||||
linkable ? (
|
||||
<a
|
||||
class={`resource-tag resource-tag-link tag-${slug}`}
|
||||
href={chipHref(item)}
|
||||
aria-label={`Filter by ${title.toLowerCase()}: ${item}`}
|
||||
>
|
||||
{item}
|
||||
</a>
|
||||
) : (
|
||||
<span class={`resource-tag tag-${slug}`}>{item}</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span class="detail-none">Not specified</span>
|
||||
)
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -41,6 +41,19 @@ const TYPE_PAGES: Record<string, string> = {
|
||||
tool: "/tools/",
|
||||
};
|
||||
|
||||
// Resource types that have a dedicated detail page at /<type>/<id>/. Search
|
||||
// results for these should deep-link to the canonical detail page rather than
|
||||
// the listing page with an inert #file= hash (listing pages no longer open a
|
||||
// modal from that hash).
|
||||
const DETAIL_ROUTE_TYPES = new Set([
|
||||
"agent",
|
||||
"instruction",
|
||||
"skill",
|
||||
"hook",
|
||||
"workflow",
|
||||
"plugin",
|
||||
]);
|
||||
|
||||
export default function pagefindResources(): AstroIntegration {
|
||||
let siteBase = "/";
|
||||
|
||||
@@ -97,12 +110,17 @@ export default function pagefindResources(): AstroIntegration {
|
||||
|
||||
let added = 0;
|
||||
for (const record of records) {
|
||||
const hasDetailPage =
|
||||
DETAIL_ROUTE_TYPES.has(record.type) && Boolean(record.id);
|
||||
const typePage = TYPE_PAGES[record.type];
|
||||
if (!typePage) continue;
|
||||
// Skip records we can neither deep-link nor point at a listing page.
|
||||
if (!hasDetailPage && !typePage) continue;
|
||||
|
||||
const url = `${base}${typePage.slice(1)}#file=${encodeURIComponent(
|
||||
record.path
|
||||
)}`;
|
||||
const url = hasDetailPage
|
||||
? `${base}${record.type}/${encodeURIComponent(record.id)}/`
|
||||
: `${base}${typePage.slice(1)}#file=${encodeURIComponent(
|
||||
record.path
|
||||
)}`;
|
||||
const typeLabel = TYPE_LABELS[record.type] || record.type;
|
||||
|
||||
const addResult = await index.addCustomRecord({
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { marked } from "marked";
|
||||
import matter from "gray-matter";
|
||||
import { enhanceMarkdownA11y } from "./markdown-a11y";
|
||||
import { sanitizeHtml } from "./sanitize-html";
|
||||
|
||||
/**
|
||||
* Build-time helpers shared by the resource detail pages
|
||||
* (src/pages/agent/[id].astro, src/pages/instruction/[id].astro, ...).
|
||||
*
|
||||
* This module is intentionally free of any DOM/browser dependencies so it can
|
||||
* run in Astro frontmatter at build time (unlike src/scripts/utils.ts, which is
|
||||
* client-side).
|
||||
*/
|
||||
|
||||
const RAW_BASE =
|
||||
"https://raw.githubusercontent.com/github/awesome-copilot/main";
|
||||
const GITHUB_BASE = "https://github.com/github/awesome-copilot/blob/main";
|
||||
|
||||
// Per resource-type VS Code install configuration.
|
||||
const INSTALL_CONFIG = {
|
||||
agent: {
|
||||
command: "chat-agent",
|
||||
baseUrl: "https://aka.ms/awesome-copilot/install/agent",
|
||||
},
|
||||
instruction: {
|
||||
command: "chat-instructions",
|
||||
baseUrl: "https://aka.ms/awesome-copilot/install/instructions",
|
||||
},
|
||||
} as const satisfies Record<string, { command: string; baseUrl: string }>;
|
||||
|
||||
export type ResourceType = keyof typeof INSTALL_CONFIG;
|
||||
|
||||
export interface DetailPageInput {
|
||||
path: string;
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
export interface DetailPageData {
|
||||
vscodeUrl: string;
|
||||
insidersUrl: string;
|
||||
githubUrl: string;
|
||||
markdownHtml: string;
|
||||
frontmatterText: string;
|
||||
rawMarkdown: string;
|
||||
lastUpdated: string | null;
|
||||
}
|
||||
|
||||
// Repo root. Astro runs (both `dev` and `build`) execute with the website/
|
||||
// directory as the working directory, so the repo root is its parent. This is
|
||||
// resolved from the working directory rather than `import.meta.url` because the
|
||||
// latter is unreliable once this module is bundled during a production build.
|
||||
const repoRoot = path.resolve(process.cwd(), "..");
|
||||
|
||||
function buildInstallUrl(
|
||||
filePath: string,
|
||||
type: ResourceType,
|
||||
insiders: boolean
|
||||
): string {
|
||||
const { command, baseUrl } = INSTALL_CONFIG[type];
|
||||
const rawUrl = `${RAW_BASE}/${filePath}`;
|
||||
const editor = insiders ? "vscode-insiders" : "vscode";
|
||||
const innerUrl = `${editor}:${command}/install?url=${encodeURIComponent(rawUrl)}`;
|
||||
return `${baseUrl}?url=${encodeURIComponent(innerUrl)}`;
|
||||
}
|
||||
|
||||
export function formatLastUpdated(
|
||||
lastUpdated?: string | null
|
||||
): string | null {
|
||||
return lastUpdated
|
||||
? new Date(lastUpdated).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a resource's markdown file at build time and return its rendered HTML,
|
||||
* trimmed frontmatter block, and the raw file contents.
|
||||
*/
|
||||
export function readResourceMarkdown(filePath: string): {
|
||||
markdownHtml: string;
|
||||
frontmatterText: string;
|
||||
rawMarkdown: string;
|
||||
} {
|
||||
let markdownHtml = "";
|
||||
let frontmatterText = "";
|
||||
let rawMarkdown = "";
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(repoRoot, filePath), "utf-8");
|
||||
rawMarkdown = raw;
|
||||
const parsed = matter(raw);
|
||||
frontmatterText = parsed.matter?.trim() ?? "";
|
||||
markdownHtml = enhanceMarkdownA11y(
|
||||
sanitizeHtml(marked.parse(parsed.content, { async: false }) as string)
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(
|
||||
`[detail-page] Failed to read or parse markdown for "${filePath}": ${message}`
|
||||
);
|
||||
markdownHtml = "";
|
||||
}
|
||||
return { markdownHtml, frontmatterText, rawMarkdown };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build every piece of shared data a resource detail page needs: install URLs,
|
||||
* the GitHub source URL, rendered/raw markdown, and the formatted update date.
|
||||
*/
|
||||
export function loadDetailPage(
|
||||
item: DetailPageInput,
|
||||
type: ResourceType
|
||||
): DetailPageData {
|
||||
return {
|
||||
vscodeUrl: buildInstallUrl(item.path, type, false),
|
||||
insidersUrl: buildInstallUrl(item.path, type, true),
|
||||
githubUrl: `${GITHUB_BASE}/${item.path}`,
|
||||
...readResourceMarkdown(item.path),
|
||||
lastUpdated: formatLastUpdated(item.lastUpdated),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Shared helpers for building safe GitHub links to external (third-party)
|
||||
* plugin/extension sources.
|
||||
*
|
||||
* This module is intentionally dependency-free (no DOM or node imports) so it
|
||||
* can run both at build time (in Astro frontmatter, e.g. plugin/[id].astro and
|
||||
* extension/[id].astro) and on the client (plugins-render.ts, modal.ts).
|
||||
*/
|
||||
|
||||
export interface ExternalSource {
|
||||
source?: string;
|
||||
repo?: string;
|
||||
path?: string;
|
||||
ref?: string;
|
||||
sha?: string;
|
||||
}
|
||||
|
||||
const GITHUB_REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
||||
// Tags, branch names, or a 40-char commit SHA. Deliberately excludes
|
||||
// whitespace, "..", and characters that could break out of the path segment.
|
||||
const GITHUB_REF_RE = /^(?!\/)(?!.*\/\/)(?!.*\.\.)(?!.*\/$)[A-Za-z0-9._/-]+$/;
|
||||
|
||||
/**
|
||||
* Allow only http(s) URLs; return "#" for anything else (mirrors the
|
||||
* client-side sanitizeUrl in scripts/utils.ts). Prevents javascript:/data:
|
||||
* schemes from reaching an href/src attribute.
|
||||
*/
|
||||
export function sanitizeHttpUrl(url: string | null | undefined): string {
|
||||
if (!url) return "#";
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
||||
return url;
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL
|
||||
}
|
||||
return "#";
|
||||
}
|
||||
|
||||
function encodeRepoPath(rawPath: string): string {
|
||||
return rawPath
|
||||
.replace(/\\/g, "/")
|
||||
.split("/")
|
||||
.filter((segment) => segment !== "")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a GitHub URL for an external plugin/extension source, pinned to the
|
||||
* most immutable revision available (sha, then ref, then the default branch).
|
||||
* Path segments are URL-encoded and a leading "/" (or a bare "/" path) is
|
||||
* treated as "no path".
|
||||
*
|
||||
* When the source is not a valid GitHub repo reference, falls back to the first
|
||||
* safe http(s) URL in `fallbackUrls`, or "#" if none is provided.
|
||||
*/
|
||||
export function externalRepoUrl(
|
||||
source: ExternalSource | null | undefined,
|
||||
fallbackUrls: Array<string | null | undefined> = []
|
||||
): string {
|
||||
if (
|
||||
source?.source === "github" &&
|
||||
typeof source.repo === "string" &&
|
||||
GITHUB_REPO_RE.test(source.repo)
|
||||
) {
|
||||
const base = `https://github.com/${source.repo}`;
|
||||
const candidateRef = source.sha || source.ref;
|
||||
const ref =
|
||||
candidateRef && GITHUB_REF_RE.test(candidateRef) ? candidateRef : "";
|
||||
const path =
|
||||
source.path && source.path !== "/" ? encodeRepoPath(source.path) : "";
|
||||
|
||||
if (path) {
|
||||
return `${base}/tree/${ref || "main"}/${path}`;
|
||||
}
|
||||
return ref ? `${base}/tree/${ref}` : base;
|
||||
}
|
||||
|
||||
for (const url of fallbackUrls) {
|
||||
const safe = sanitizeHttpUrl(url);
|
||||
if (safe !== "#") return safe;
|
||||
}
|
||||
return "#";
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Map a file name to a Shiki language id and a coarse "kind" used by the file
|
||||
* browser to decide how to render it (markdown prose, highlighted code, image,
|
||||
* or a plain fallback). Shared between the build-time FileBrowser component
|
||||
* and the client-side file-browser script, so both agree on languages.
|
||||
*/
|
||||
|
||||
export type FileKind = "markdown" | "code" | "image" | "other";
|
||||
|
||||
const EXT_LANG: Record<string, string> = {
|
||||
ts: "typescript",
|
||||
tsx: "tsx",
|
||||
js: "javascript",
|
||||
jsx: "jsx",
|
||||
mjs: "javascript",
|
||||
cjs: "javascript",
|
||||
py: "python",
|
||||
rb: "ruby",
|
||||
go: "go",
|
||||
rs: "rust",
|
||||
java: "java",
|
||||
cs: "csharp",
|
||||
c: "c",
|
||||
cpp: "cpp",
|
||||
h: "c",
|
||||
php: "php",
|
||||
swift: "swift",
|
||||
kt: "kotlin",
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
zsh: "bash",
|
||||
ps1: "powershell",
|
||||
json: "json",
|
||||
jsonc: "json",
|
||||
yml: "yaml",
|
||||
yaml: "yaml",
|
||||
toml: "toml",
|
||||
html: "html",
|
||||
css: "css",
|
||||
scss: "scss",
|
||||
xml: "xml",
|
||||
sql: "sql",
|
||||
dockerfile: "docker",
|
||||
md: "markdown",
|
||||
markdown: "markdown",
|
||||
txt: "text",
|
||||
};
|
||||
|
||||
const CODE_LANGS = new Set<string>([
|
||||
"typescript",
|
||||
"tsx",
|
||||
"javascript",
|
||||
"jsx",
|
||||
"python",
|
||||
"ruby",
|
||||
"go",
|
||||
"rust",
|
||||
"java",
|
||||
"csharp",
|
||||
"c",
|
||||
"cpp",
|
||||
"php",
|
||||
"swift",
|
||||
"kotlin",
|
||||
"bash",
|
||||
"powershell",
|
||||
"json",
|
||||
"yaml",
|
||||
"toml",
|
||||
"html",
|
||||
"css",
|
||||
"scss",
|
||||
"xml",
|
||||
"sql",
|
||||
"docker",
|
||||
]);
|
||||
|
||||
const IMAGE_EXTS = new Set<string>([
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"svg",
|
||||
"webp",
|
||||
"avif",
|
||||
"bmp",
|
||||
"ico",
|
||||
]);
|
||||
|
||||
export interface FileMeta {
|
||||
ext: string;
|
||||
lang: string;
|
||||
kind: FileKind;
|
||||
}
|
||||
|
||||
export function getFileMeta(name: string): FileMeta {
|
||||
const base = name.split("/").pop() ?? name;
|
||||
const ext =
|
||||
base.toLowerCase() === "dockerfile"
|
||||
? "dockerfile"
|
||||
: (base.split(".").pop() ?? "").toLowerCase();
|
||||
const lang = EXT_LANG[ext] ?? "text";
|
||||
let kind: FileKind = "other";
|
||||
if (IMAGE_EXTS.has(ext)) kind = "image";
|
||||
else if (ext === "md" || ext === "markdown") kind = "markdown";
|
||||
else if (CODE_LANGS.has(lang)) kind = "code";
|
||||
return { ext, lang, kind };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Accessibility post-processing for marked-generated markdown HTML.
|
||||
*
|
||||
* axe flags two issues on rendered markdown that we fix here rather than in the
|
||||
* source content:
|
||||
* - `scrollable-region-focusable`: <pre> and <table> blocks can overflow on the
|
||||
* x-axis, so they must be keyboard focusable to let keyboard users scroll them.
|
||||
* - `label`: GitHub-style task-list checkboxes (`- [ ]` / `- [x]`) render as bare
|
||||
* disabled <input type="checkbox"> elements with no accessible name.
|
||||
*
|
||||
* This module is pure (no DOM or node deps) so it can run both at build time
|
||||
* (src/lib/detail-page.ts) and on the client (src/scripts/pages/file-browser.ts).
|
||||
*/
|
||||
export function enhanceMarkdownA11y(html: string): string {
|
||||
if (!html) return html;
|
||||
let out = html;
|
||||
|
||||
// Make scrollable code/table blocks keyboard focusable.
|
||||
out = out.replace(/<pre(?![^>]*\btabindex=)/g, '<pre tabindex="0"');
|
||||
out = out.replace(/<table(?![^>]*\btabindex=)/g, '<table tabindex="0"');
|
||||
|
||||
// Give task-list checkboxes an accessible name based on their checked state.
|
||||
out = out.replace(
|
||||
/<input\b([^>]*\btype="checkbox"[^>]*)>/g,
|
||||
(match, attrs) => {
|
||||
if (/\baria-label=/.test(attrs)) return match;
|
||||
const label = /\bchecked\b/.test(attrs)
|
||||
? "Completed task"
|
||||
: "Incomplete task";
|
||||
return `<input${attrs} aria-label="${label}">`;
|
||||
}
|
||||
);
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Isomorphic HTML sanitizer for rendered markdown.
|
||||
*
|
||||
* `marked` allows raw HTML to pass through untouched, and the resulting string
|
||||
* is injected via `set:html` / `innerHTML` on the resource detail pages and in
|
||||
* the client-side file browser. Even though the markdown we render originates
|
||||
* from this repository, a compromised or malicious resource file could
|
||||
* otherwise introduce persistent XSS. Sanitizing the generated HTML gives us
|
||||
* defense-in-depth on both the server (build time) and the client.
|
||||
*
|
||||
* `isomorphic-dompurify` resolves to a jsdom-backed DOMPurify in Node (so it
|
||||
* works in Astro frontmatter during `astro build`) and to the native
|
||||
* browser DOMPurify when bundled for the client.
|
||||
*/
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
|
||||
let noopenerHookInstalled = false;
|
||||
|
||||
function ensureNoopenerHook(): void {
|
||||
if (noopenerHookInstalled) return;
|
||||
noopenerHookInstalled = true;
|
||||
|
||||
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
|
||||
const el = node as unknown as {
|
||||
tagName?: string;
|
||||
getAttribute?: (name: string) => string | null;
|
||||
setAttribute?: (name: string, value: string) => void;
|
||||
};
|
||||
|
||||
if (el?.tagName !== "A") return;
|
||||
if (el.getAttribute?.("target") !== "_blank") return;
|
||||
|
||||
const rel = el.getAttribute?.("rel") ?? "";
|
||||
const tokens = new Set(rel.split(/\s+/).filter(Boolean));
|
||||
tokens.add("noopener");
|
||||
tokens.add("noreferrer");
|
||||
el.setAttribute?.("rel", Array.from(tokens).join(" "));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a fragment of HTML produced from trusted-but-untrusted markdown,
|
||||
* stripping scripts, event handlers, and dangerous URL schemes while keeping
|
||||
* the formatting tags GitHub-flavored markdown commonly emits.
|
||||
*/
|
||||
export function sanitizeHtml(html: string): string {
|
||||
if (!html) return html;
|
||||
ensureNoopenerHook();
|
||||
return DOMPurify.sanitize(html, {
|
||||
// Keep links that open in a new tab (target/rel) which some resource docs
|
||||
// author directly as raw HTML.
|
||||
ADD_ATTR: ["target", "rel"],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
|
||||
import BackToTop from "../../components/BackToTop.astro";
|
||||
import agentsData from "../../../public/data/agents.json";
|
||||
import RawMarkdown from "../../components/pages/RawMarkdown.astro";
|
||||
import type { HeaderItem } from "../../components/pages/Header.astro";
|
||||
import DetailHeader from "../../components/pages/Header.astro";
|
||||
import Breadcrumb from "../../components/pages/Breadcrumb.astro";
|
||||
import Main from "../../components/pages/Main.astro";
|
||||
import InstallButtons from "../../components/pages/InstallButtons.astro";
|
||||
import Sidebar from "../../components/pages/Sidebar.astro";
|
||||
import { loadDetailPage } from "../../lib/detail-page";
|
||||
|
||||
interface AgentItem extends HeaderItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
model?: string | string[];
|
||||
tools?: string[];
|
||||
hasHandoffs?: boolean;
|
||||
handoffs?: string[];
|
||||
mcpServers?: string[];
|
||||
path: string;
|
||||
filename?: string;
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
export function getStaticPaths() {
|
||||
return (agentsData.items as AgentItem[]).map((item) => ({
|
||||
params: { id: item.id },
|
||||
props: { item },
|
||||
}));
|
||||
}
|
||||
|
||||
const { item } = Astro.props as { item: AgentItem };
|
||||
|
||||
const {
|
||||
vscodeUrl,
|
||||
insidersUrl,
|
||||
githubUrl,
|
||||
markdownHtml,
|
||||
frontmatterText,
|
||||
rawMarkdown,
|
||||
lastUpdated,
|
||||
} = loadDetailPage(item, "agent");
|
||||
|
||||
const models = Array.isArray(item.model)
|
||||
? item.model
|
||||
: item.model
|
||||
? [item.model]
|
||||
: [];
|
||||
const tools = item.tools ?? [];
|
||||
const handoffs = item.handoffs ?? [];
|
||||
const mcpServers = item.mcpServers ?? [];
|
||||
---
|
||||
|
||||
<StarlightPage
|
||||
frontmatter={{
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
template: "splash",
|
||||
prev: false,
|
||||
next: false,
|
||||
editUrl: false,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
id="main-content"
|
||||
class="resource-detail-page"
|
||||
data-resource-detail
|
||||
data-path={item.path}
|
||||
>
|
||||
<RawMarkdown markdown={rawMarkdown} />
|
||||
<div class="container">
|
||||
<Breadcrumb
|
||||
paths={[
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Agents", href: "/agents/" },
|
||||
]}
|
||||
title={item.title}
|
||||
/>
|
||||
|
||||
<DetailHeader item={item} />
|
||||
|
||||
<div class="detail-layout">
|
||||
<Main markdownHtml={markdownHtml} githubUrl={githubUrl} />
|
||||
|
||||
<Sidebar
|
||||
item={item}
|
||||
lastUpdated={lastUpdated ?? undefined}
|
||||
frontmatterText={frontmatterText || undefined}
|
||||
githubUrl={githubUrl}
|
||||
chips={[
|
||||
{ title: "Models", items: models },
|
||||
{ title: "Handoffs", items: handoffs },
|
||||
{ title: "MCP Servers", items: mcpServers },
|
||||
{ title: "Tools", items: tools },
|
||||
]}
|
||||
>
|
||||
<InstallButtons
|
||||
vscodeUrl={vscodeUrl}
|
||||
insidersUrl={insidersUrl}
|
||||
rawMarkdown={rawMarkdown}
|
||||
slot="install"
|
||||
/>
|
||||
</Sidebar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
|
||||
<script>
|
||||
import "../../scripts/pages/resource-detail";
|
||||
</script>
|
||||
</StarlightPage>
|
||||
@@ -5,7 +5,6 @@ import ContributeCTA from '../components/ContributeCTA.astro';
|
||||
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import BackToTop from '../components/BackToTop.astro';
|
||||
import Modal from '../components/Modal.astro';
|
||||
import { renderAgentsHtml, sortAgents } from '../scripts/pages/agents-render';
|
||||
|
||||
const initialItems = sortAgents(agentsData.items, 'title');
|
||||
@@ -43,7 +42,6 @@ const initialItems = sortAgents(agentsData.items, 'title');
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
<Modal />
|
||||
<EmbeddedPageData filename="agents.json" data={agentsData} />
|
||||
|
||||
<script>
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
---
|
||||
import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
|
||||
import BackToTop from "../../components/BackToTop.astro";
|
||||
import extensionsData from "../../../public/data/extensions.json";
|
||||
import RawMarkdown from "../../components/pages/RawMarkdown.astro";
|
||||
import type { HeaderItem } from "../../components/pages/Header.astro";
|
||||
import DetailHeader from "../../components/pages/Header.astro";
|
||||
import Breadcrumb from "../../components/pages/Breadcrumb.astro";
|
||||
import Sidebar from "../../components/pages/Sidebar.astro";
|
||||
import PluginInstall from "../../components/pages/PluginInstall.astro";
|
||||
import { readResourceMarkdown, formatLastUpdated } from "../../lib/detail-page";
|
||||
import { sanitizeHttpUrl } from "../../lib/external-source";
|
||||
|
||||
const GITHUB_TREE = "https://github.com/github/awesome-copilot/tree/main";
|
||||
|
||||
interface ExtensionScreenshot {
|
||||
path?: string | null;
|
||||
type?: string | null;
|
||||
}
|
||||
|
||||
interface ExtensionAuthor {
|
||||
name: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface ExtensionEntry extends HeaderItem {
|
||||
id: string;
|
||||
canvasId?: string;
|
||||
extensionId?: string;
|
||||
extensionName?: string;
|
||||
pluginName?: string | null;
|
||||
name: string;
|
||||
title?: string;
|
||||
version?: string | null;
|
||||
readmeFile?: string | null;
|
||||
description?: string;
|
||||
path?: string | null;
|
||||
ref?: string | null;
|
||||
lastUpdated?: string | null;
|
||||
screenshots?: {
|
||||
icon?: ExtensionScreenshot | null;
|
||||
gallery?: ExtensionScreenshot | ExtensionScreenshot[] | null;
|
||||
} | null;
|
||||
imageUrl?: string | null;
|
||||
assetPath?: string | null;
|
||||
installUrl?: string | null;
|
||||
installCommand?: string | null;
|
||||
sourceUrl?: string | null;
|
||||
external?: boolean;
|
||||
author?: ExtensionAuthor | null;
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
export function getStaticPaths() {
|
||||
return (extensionsData.items as ExtensionEntry[]).map((item) => ({
|
||||
params: { id: item.id },
|
||||
props: { item },
|
||||
}));
|
||||
}
|
||||
|
||||
const { item } = Astro.props as { item: ExtensionEntry };
|
||||
|
||||
const headerItem = { ...item, title: item.title ?? item.name };
|
||||
|
||||
const isExternal = item.external === true;
|
||||
|
||||
// Allow only http(s) URLs from external/generated data in href/src contexts;
|
||||
// unsafe values collapse to "" so downstream truthiness guards still hold.
|
||||
const safeUrl = (value?: string | null): string => {
|
||||
const sanitized = sanitizeHttpUrl(value);
|
||||
return sanitized === "#" ? "" : sanitized;
|
||||
};
|
||||
const readmePath = item.readmeFile ?? null;
|
||||
const { markdownHtml, frontmatterText, rawMarkdown } = readmePath
|
||||
? readResourceMarkdown(readmePath)
|
||||
: { markdownHtml: "", frontmatterText: "", rawMarkdown: "" };
|
||||
|
||||
const lastUpdated = formatLastUpdated(item.lastUpdated ?? null);
|
||||
|
||||
// Resolve preview images (icon + gallery), converting repo paths to raw URLs.
|
||||
function toRawAssetUrl(assetPath?: string | null): string {
|
||||
if (!assetPath) return "";
|
||||
if (/^https?:\/\//i.test(assetPath)) return assetPath;
|
||||
if (!item.ref) return "";
|
||||
return `https://raw.githubusercontent.com/github/awesome-copilot/${item.ref}/${assetPath.replace(
|
||||
/\\/g,
|
||||
"/"
|
||||
)}`;
|
||||
}
|
||||
|
||||
function normalizeScreenshotEntries(
|
||||
value: ExtensionScreenshot | ExtensionScreenshot[] | null | undefined
|
||||
): ExtensionScreenshot[] {
|
||||
if (!value) return [];
|
||||
return Array.isArray(value) ? value.filter(Boolean) : [value];
|
||||
}
|
||||
|
||||
const galleryImages: string[] = (() => {
|
||||
const images: string[] = [];
|
||||
if (item.imageUrl) images.push(item.imageUrl);
|
||||
const iconUrl = toRawAssetUrl(item.screenshots?.icon?.path);
|
||||
if (iconUrl) images.push(iconUrl);
|
||||
for (const entry of normalizeScreenshotEntries(item.screenshots?.gallery)) {
|
||||
const url = toRawAssetUrl(entry.path);
|
||||
if (url) images.push(url);
|
||||
}
|
||||
return Array.from(new Set(images.map(safeUrl).filter(Boolean)));
|
||||
})();
|
||||
|
||||
const installUrl = safeUrl(
|
||||
item.installUrl ||
|
||||
(item.path && item.ref
|
||||
? `https://github.com/github/awesome-copilot/tree/${item.ref}/${item.path.replace(
|
||||
/\\/g,
|
||||
"/"
|
||||
)}`
|
||||
: "")
|
||||
);
|
||||
const sourceUrl = safeUrl(item.sourceUrl);
|
||||
const installCommand =
|
||||
item.installCommand ||
|
||||
(item.pluginName
|
||||
? `copilot plugin install ${item.pluginName}@awesome-copilot`
|
||||
: "");
|
||||
|
||||
const githubUrl = isExternal
|
||||
? sourceUrl || installUrl || GITHUB_TREE
|
||||
: item.path
|
||||
? `${GITHUB_TREE}/${item.path}`
|
||||
: installUrl || GITHUB_TREE;
|
||||
|
||||
// Sidebar "Source" shows item.path; external extensions have no repo path.
|
||||
const sidebarItem =
|
||||
isExternal && (sourceUrl || installUrl)
|
||||
? { ...item, path: sourceUrl || installUrl }
|
||||
: item;
|
||||
|
||||
const shortHash = (value: string) =>
|
||||
/^[0-9a-f]{40}$/i.test(value) ? value.slice(0, 7) : value;
|
||||
|
||||
const chips: {
|
||||
title: string;
|
||||
items: string[];
|
||||
filterBase?: string;
|
||||
filterParam?: string;
|
||||
}[] = [];
|
||||
if (item.version) {
|
||||
chips.push({ title: "Version", items: [item.version] });
|
||||
}
|
||||
if (item.canvasId) {
|
||||
chips.push({ title: "Canvas ID", items: [item.canvasId] });
|
||||
}
|
||||
if (item.keywords && item.keywords.length > 0) {
|
||||
chips.push({
|
||||
title: "Keywords",
|
||||
items: item.keywords,
|
||||
filterBase: "/extensions/",
|
||||
filterParam: "keyword",
|
||||
});
|
||||
}
|
||||
if (item.author?.name) {
|
||||
chips.push({ title: "Author", items: [item.author.name] });
|
||||
}
|
||||
if (!isExternal && item.ref) {
|
||||
chips.push({ title: "Commit", items: [shortHash(item.ref)] });
|
||||
}
|
||||
---
|
||||
|
||||
<StarlightPage
|
||||
frontmatter={{
|
||||
title: item.title ?? item.name,
|
||||
description: item.description,
|
||||
template: "splash",
|
||||
prev: false,
|
||||
next: false,
|
||||
editUrl: false,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
id="main-content"
|
||||
class="resource-detail-page extension-detail-page"
|
||||
data-resource-detail
|
||||
data-path={item.readmeFile ?? item.path ?? ""}
|
||||
>
|
||||
<RawMarkdown markdown={rawMarkdown} />
|
||||
<div class="container">
|
||||
<Breadcrumb
|
||||
paths={[
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Canvas Extensions", href: "/extensions/" },
|
||||
]}
|
||||
title={item.title ?? item.name}
|
||||
/>
|
||||
|
||||
<DetailHeader item={headerItem} />
|
||||
|
||||
<div class="detail-layout">
|
||||
<div class="detail-main">
|
||||
{
|
||||
galleryImages.length > 0 && (
|
||||
<section class="detail-section extension-gallery">
|
||||
<h2>Preview</h2>
|
||||
<div class="extension-gallery-main">
|
||||
<img
|
||||
id="extension-gallery-image"
|
||||
src={galleryImages[0]}
|
||||
alt={`${item.name} preview`}
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
{galleryImages.length > 1 && (
|
||||
<div class="extension-gallery-thumbs" role="list">
|
||||
{galleryImages.map((url, index) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`extension-gallery-thumb${index === 0 ? " active" : ""}`}
|
||||
data-gallery-url={url}
|
||||
role="listitem"
|
||||
aria-label={`Show image ${index + 1}`}
|
||||
aria-current={index === 0 ? "true" : undefined}
|
||||
>
|
||||
<img src={url} alt={`Gallery image ${index + 1}`} loading="lazy" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
markdownHtml ? (
|
||||
<section class="detail-section" aria-labelledby="doc-heading">
|
||||
<h2 id="doc-heading">Documentation</h2>
|
||||
<div class="article-content" set:html={markdownHtml} />
|
||||
</section>
|
||||
) : (
|
||||
<section class="detail-section">
|
||||
<h2>About</h2>
|
||||
<p>{item.description}</p>
|
||||
<p class="detail-empty">
|
||||
{isExternal
|
||||
? "This is a third-party canvas extension hosted outside this repository."
|
||||
: "No README is bundled with this extension."}{" "}
|
||||
View it{" "}
|
||||
<a href={githubUrl} target="_blank" rel="noopener">
|
||||
on GitHub
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<Sidebar
|
||||
item={sidebarItem}
|
||||
lastUpdated={lastUpdated ?? undefined}
|
||||
frontmatterText={frontmatterText || undefined}
|
||||
githubUrl={githubUrl}
|
||||
chips={chips}
|
||||
>
|
||||
{
|
||||
isExternal ? (
|
||||
<div class="skill-install" slot="install">
|
||||
<p class="skill-install-label">Install this extension</p>
|
||||
<p class="skill-install-note">
|
||||
This extension is maintained in an external repository.
|
||||
</p>
|
||||
{installUrl && (
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary skill-install-url-btn"
|
||||
data-action="copy-install-url"
|
||||
data-install-url={installUrl}
|
||||
>
|
||||
Copy install URL
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<PluginInstall
|
||||
slot="install"
|
||||
pluginId={item.pluginName ?? item.id}
|
||||
installCommand={installCommand || null}
|
||||
label="Install"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Sidebar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
|
||||
<script>
|
||||
import "../../scripts/pages/resource-detail";
|
||||
import "../../scripts/pages/extension-gallery";
|
||||
</script>
|
||||
</StarlightPage>
|
||||
@@ -5,7 +5,6 @@ import ContributeCTA from '../components/ContributeCTA.astro';
|
||||
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import BackToTop from '../components/BackToTop.astro';
|
||||
import Modal from '../components/Modal.astro';
|
||||
import { renderExtensionsHtml, sortExtensions } from '../scripts/pages/extensions-render';
|
||||
|
||||
const initialItems = sortExtensions(extensionsData.items, 'title');
|
||||
@@ -53,7 +52,6 @@ const initialItems = sortExtensions(extensionsData.items, 'title');
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
<Modal />
|
||||
<EmbeddedPageData filename="extensions.json" data={extensionsData} />
|
||||
|
||||
<script>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
|
||||
import BackToTop from "../../components/BackToTop.astro";
|
||||
import hooksData from "../../../public/data/hooks.json";
|
||||
import RawMarkdown from "../../components/pages/RawMarkdown.astro";
|
||||
import type { HeaderItem } from "../../components/pages/Header.astro";
|
||||
import DetailHeader from "../../components/pages/Header.astro";
|
||||
import Breadcrumb from "../../components/pages/Breadcrumb.astro";
|
||||
import Sidebar from "../../components/pages/Sidebar.astro";
|
||||
import FileBrowser from "../../components/pages/FileBrowser.astro";
|
||||
import { readResourceMarkdown, formatLastUpdated } from "../../lib/detail-page";
|
||||
|
||||
const GITHUB_BASE = "https://github.com/github/awesome-copilot/blob/main";
|
||||
const GITHUB_TREE_BASE = "https://github.com/github/awesome-copilot/tree/main";
|
||||
|
||||
interface HookFile {
|
||||
name: string;
|
||||
path: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
interface HookItem extends HeaderItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
readmeFile: string;
|
||||
readmeFileName?: string;
|
||||
files: HookFile[];
|
||||
hooks: string[];
|
||||
tags: string[];
|
||||
assets: string[];
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
export function getStaticPaths() {
|
||||
return (hooksData.items as HookItem[]).map((item) => ({
|
||||
params: { id: item.id },
|
||||
props: { item },
|
||||
}));
|
||||
}
|
||||
|
||||
const { item } = Astro.props as { item: HookItem };
|
||||
|
||||
const { markdownHtml, frontmatterText, rawMarkdown } = readResourceMarkdown(
|
||||
item.readmeFile
|
||||
);
|
||||
const lastUpdated = formatLastUpdated(item.lastUpdated);
|
||||
const githubUrl = `${GITHUB_TREE_BASE}/${item.path}`;
|
||||
const readmeFileName = item.readmeFileName ?? "README.md";
|
||||
|
||||
const fileCount = item.files.length;
|
||||
const chips: {
|
||||
title: string;
|
||||
items: string[];
|
||||
filterBase?: string;
|
||||
filterParam?: string;
|
||||
}[] = [];
|
||||
if (item.hooks.length > 0) {
|
||||
chips.push({ title: "Hook events", items: item.hooks });
|
||||
}
|
||||
chips.push({
|
||||
title: "Files",
|
||||
items: [`${fileCount} file${fileCount === 1 ? "" : "s"}`],
|
||||
});
|
||||
if (item.tags.length > 0) {
|
||||
chips.push({
|
||||
title: "Tags",
|
||||
items: item.tags,
|
||||
filterBase: "/hooks/",
|
||||
filterParam: "tag",
|
||||
});
|
||||
}
|
||||
---
|
||||
|
||||
<StarlightPage
|
||||
frontmatter={{
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
template: "splash",
|
||||
prev: false,
|
||||
next: false,
|
||||
editUrl: false,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
id="main-content"
|
||||
class="resource-detail-page hook-detail-page"
|
||||
data-file-browser-page
|
||||
data-bundle-id={item.id}
|
||||
data-path={item.readmeFile}
|
||||
>
|
||||
<RawMarkdown markdown={rawMarkdown} />
|
||||
<div class="container">
|
||||
<Breadcrumb
|
||||
paths={[
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Hooks", href: "/hooks/" },
|
||||
]}
|
||||
title={item.title}
|
||||
/>
|
||||
|
||||
<DetailHeader item={item} />
|
||||
|
||||
<div class="detail-layout">
|
||||
<div class="detail-main">
|
||||
<FileBrowser
|
||||
files={item.files}
|
||||
primaryPath={item.readmeFile}
|
||||
primaryName={readmeFileName}
|
||||
primaryHtml={markdownHtml}
|
||||
githubBase={GITHUB_BASE}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Sidebar
|
||||
item={item}
|
||||
lastUpdated={lastUpdated ?? undefined}
|
||||
frontmatterText={frontmatterText || undefined}
|
||||
githubUrl={githubUrl}
|
||||
chips={chips}
|
||||
>
|
||||
<div class="skill-install" slot="install">
|
||||
<p class="skill-install-label">Install this hook</p>
|
||||
<p class="skill-install-note">
|
||||
Hooks are multi-file bundles with no CLI installer. Download the
|
||||
ZIP and copy the files into your project's hooks directory.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary skill-download-btn"
|
||||
data-action="download-zip"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
><path
|
||||
d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z"
|
||||
></path><path
|
||||
d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969Z"
|
||||
></path></svg
|
||||
>
|
||||
Download ZIP
|
||||
</button>
|
||||
</div>
|
||||
</Sidebar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
|
||||
<script>
|
||||
import "../../scripts/pages/file-browser";
|
||||
</script>
|
||||
</StarlightPage>
|
||||
@@ -4,7 +4,6 @@ import ContributeCTA from '../components/ContributeCTA.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||
import BackToTop from '../components/BackToTop.astro';
|
||||
import Modal from '../components/Modal.astro';
|
||||
import hooksData from '../../public/data/hooks.json';
|
||||
import { renderHooksHtml, sortHooks } from '../scripts/pages/hooks-render';
|
||||
|
||||
@@ -48,7 +47,6 @@ const initialItems = sortHooks(hooksData.items, 'title');
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
<Modal />
|
||||
|
||||
<EmbeddedPageData filename="hooks.json" data={hooksData} />
|
||||
<script>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
|
||||
import BackToTop from "../../components/BackToTop.astro";
|
||||
import instructionsData from "../../../public/data/instructions.json";
|
||||
import RawMarkdown from "../../components/pages/RawMarkdown.astro";
|
||||
import type { HeaderItem } from "../../components/pages/Header.astro";
|
||||
import DetailHeader from "../../components/pages/Header.astro";
|
||||
import Breadcrumb from "../../components/pages/Breadcrumb.astro";
|
||||
import Main from "../../components/pages/Main.astro";
|
||||
import InstallButtons from "../../components/pages/InstallButtons.astro";
|
||||
import Sidebar from "../../components/pages/Sidebar.astro";
|
||||
import { loadDetailPage } from "../../lib/detail-page";
|
||||
|
||||
interface InstructionItem extends HeaderItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
applyTo?: string | string[] | null;
|
||||
applyToPatterns?: string[];
|
||||
extensions?: string[];
|
||||
path: string;
|
||||
filename?: string;
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
export function getStaticPaths() {
|
||||
return (instructionsData.items as InstructionItem[]).map((item) => ({
|
||||
params: { id: item.id },
|
||||
props: { item },
|
||||
}));
|
||||
}
|
||||
|
||||
const { item } = Astro.props as { item: InstructionItem };
|
||||
|
||||
const {
|
||||
vscodeUrl,
|
||||
insidersUrl,
|
||||
githubUrl,
|
||||
markdownHtml,
|
||||
frontmatterText,
|
||||
rawMarkdown,
|
||||
lastUpdated,
|
||||
} = loadDetailPage(item, "instruction");
|
||||
|
||||
const applyToList = Array.isArray(item.applyTo)
|
||||
? item.applyTo
|
||||
: item.applyTo
|
||||
? [item.applyTo]
|
||||
: (item.applyToPatterns ?? []);
|
||||
const extensions = item.extensions ?? [];
|
||||
---
|
||||
|
||||
<StarlightPage
|
||||
frontmatter={{
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
template: "splash",
|
||||
prev: false,
|
||||
next: false,
|
||||
editUrl: false,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
id="main-content"
|
||||
class="resource-detail-page"
|
||||
data-resource-detail
|
||||
data-path={item.path}
|
||||
>
|
||||
<RawMarkdown markdown={rawMarkdown} />
|
||||
<div class="container">
|
||||
<Breadcrumb
|
||||
paths={[
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Instructions", href: "/instructions/" },
|
||||
]}
|
||||
title={item.title}
|
||||
/>
|
||||
|
||||
<DetailHeader item={item} />
|
||||
|
||||
<div class="detail-layout">
|
||||
<Main markdownHtml={markdownHtml} githubUrl={githubUrl} />
|
||||
|
||||
<Sidebar
|
||||
item={item}
|
||||
lastUpdated={lastUpdated ?? undefined}
|
||||
frontmatterText={frontmatterText || undefined}
|
||||
githubUrl={githubUrl}
|
||||
chips={[
|
||||
{ title: "Applies to", items: applyToList },
|
||||
{
|
||||
title: "Extensions",
|
||||
items: extensions,
|
||||
filterBase: "/instructions/",
|
||||
filterParam: "extension",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<InstallButtons
|
||||
vscodeUrl={vscodeUrl}
|
||||
insidersUrl={insidersUrl}
|
||||
rawMarkdown={rawMarkdown}
|
||||
slot="install"
|
||||
/>
|
||||
</Sidebar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
|
||||
<script>
|
||||
import "../../scripts/pages/resource-detail";
|
||||
</script>
|
||||
</StarlightPage>
|
||||
@@ -5,7 +5,6 @@ import ContributeCTA from '../components/ContributeCTA.astro';
|
||||
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import BackToTop from '../components/BackToTop.astro';
|
||||
import Modal from '../components/Modal.astro';
|
||||
import { renderInstructionsHtml, sortInstructions } from '../scripts/pages/instructions-render';
|
||||
|
||||
const initialItems = sortInstructions(instructionsData.items, 'title');
|
||||
@@ -48,7 +47,6 @@ const initialItems = sortInstructions(instructionsData.items, 'title');
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
<Modal />
|
||||
<EmbeddedPageData filename="instructions.json" data={instructionsData} />
|
||||
|
||||
<script>
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
---
|
||||
import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
|
||||
import BackToTop from "../../components/BackToTop.astro";
|
||||
import pluginsData from "../../../public/data/plugins.json";
|
||||
import RawMarkdown from "../../components/pages/RawMarkdown.astro";
|
||||
import type { HeaderItem } from "../../components/pages/Header.astro";
|
||||
import DetailHeader from "../../components/pages/Header.astro";
|
||||
import Breadcrumb from "../../components/pages/Breadcrumb.astro";
|
||||
import Sidebar from "../../components/pages/Sidebar.astro";
|
||||
import IncludedItems from "../../components/pages/IncludedItems.astro";
|
||||
import PluginInstall from "../../components/pages/PluginInstall.astro";
|
||||
import { readResourceMarkdown, formatLastUpdated } from "../../lib/detail-page";
|
||||
import { externalRepoUrl } from "../../lib/external-source";
|
||||
|
||||
const GITHUB_TREE = "https://github.com/github/awesome-copilot/tree/main";
|
||||
|
||||
interface PluginItemRef {
|
||||
kind: string;
|
||||
path: string;
|
||||
title?: string;
|
||||
detailUrl?: string | null;
|
||||
}
|
||||
|
||||
interface PluginAuthor {
|
||||
name: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface PluginSource {
|
||||
source: string;
|
||||
repo?: string;
|
||||
path?: string;
|
||||
ref?: string;
|
||||
sha?: string;
|
||||
}
|
||||
|
||||
interface PluginEntry extends HeaderItem {
|
||||
id: string;
|
||||
name: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
readmeFile?: string | null;
|
||||
version?: string | null;
|
||||
tags?: string[];
|
||||
itemCount: number;
|
||||
items: PluginItemRef[];
|
||||
external?: boolean;
|
||||
generatedFromExtension?: boolean;
|
||||
repository?: string | null;
|
||||
homepage?: string | null;
|
||||
author?: PluginAuthor | null;
|
||||
source?: PluginSource | null;
|
||||
license?: string | null;
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
export function getStaticPaths() {
|
||||
return (pluginsData.items as PluginEntry[]).map((item) => ({
|
||||
params: { id: item.id },
|
||||
props: { item },
|
||||
}));
|
||||
}
|
||||
|
||||
const { item } = Astro.props as { item: PluginEntry };
|
||||
|
||||
// DetailHeader reads item.title; plugins only have a name.
|
||||
const headerItem = { ...item, title: item.title ?? item.name };
|
||||
|
||||
const isExternal = item.external === true;
|
||||
const readmePath = item.readmeFile ?? null;
|
||||
const { markdownHtml, frontmatterText, rawMarkdown } = readmePath
|
||||
? readResourceMarkdown(readmePath)
|
||||
: { markdownHtml: "", frontmatterText: "", rawMarkdown: "" };
|
||||
|
||||
const lastUpdated = formatLastUpdated(item.lastUpdated);
|
||||
|
||||
function pluginRepoUrl(): string {
|
||||
return externalRepoUrl(item.source, [
|
||||
item.repository,
|
||||
item.homepage,
|
||||
GITHUB_TREE,
|
||||
]);
|
||||
}
|
||||
|
||||
const githubUrl = isExternal
|
||||
? pluginRepoUrl()
|
||||
: `${GITHUB_TREE}/${item.path}`;
|
||||
|
||||
// Install wiring
|
||||
const externalSourceRef = item.source?.repo || item.repository || "";
|
||||
const installCommand = isExternal
|
||||
? null
|
||||
: `copilot plugin install ${item.id}@awesome-copilot`;
|
||||
|
||||
// The sidebar "Source" shows item.path; for external plugins that repo-relative
|
||||
// path doesn't exist here, so surface the upstream source reference instead.
|
||||
const sidebarItem =
|
||||
isExternal && externalSourceRef
|
||||
? { ...item, path: externalSourceRef }
|
||||
: item;
|
||||
|
||||
const chips: {
|
||||
title: string;
|
||||
items: string[];
|
||||
filterBase?: string;
|
||||
filterParam?: string;
|
||||
}[] = [];
|
||||
if (item.version) {
|
||||
chips.push({ title: "Version", items: [item.version] });
|
||||
}
|
||||
if (item.tags && item.tags.length > 0) {
|
||||
chips.push({
|
||||
title: "Tags",
|
||||
items: item.tags,
|
||||
filterBase: "/plugins/",
|
||||
filterParam: "tag",
|
||||
});
|
||||
}
|
||||
if (!isExternal && item.itemCount > 0) {
|
||||
chips.push({
|
||||
title: "Bundled items",
|
||||
items: [`${item.itemCount} item${item.itemCount === 1 ? "" : "s"}`],
|
||||
});
|
||||
}
|
||||
if (isExternal && item.author?.name) {
|
||||
chips.push({ title: "Author", items: [item.author.name] });
|
||||
}
|
||||
if (isExternal && item.license) {
|
||||
chips.push({ title: "License", items: [item.license] });
|
||||
}
|
||||
if (isExternal) {
|
||||
const shortHash = (value: string) =>
|
||||
/^[0-9a-f]{40}$/i.test(value) ? value.slice(0, 7) : value;
|
||||
const ref = item.source?.ref;
|
||||
const sha = item.source?.sha;
|
||||
if (ref) {
|
||||
chips.push({ title: "Ref", items: [shortHash(ref)] });
|
||||
}
|
||||
if (sha) {
|
||||
chips.push({ title: "Commit", items: [shortHash(sha)] });
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
<StarlightPage
|
||||
frontmatter={{
|
||||
title: item.title ?? item.name,
|
||||
description: item.description,
|
||||
template: "splash",
|
||||
prev: false,
|
||||
next: false,
|
||||
editUrl: false,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
id="main-content"
|
||||
class="resource-detail-page plugin-detail-page"
|
||||
data-resource-detail
|
||||
data-path={item.readmeFile ?? item.path}
|
||||
>
|
||||
<RawMarkdown markdown={rawMarkdown} />
|
||||
<div class="container">
|
||||
<Breadcrumb
|
||||
paths={[
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Plugins", href: "/plugins/" },
|
||||
]}
|
||||
title={item.title ?? item.name}
|
||||
/>
|
||||
|
||||
<DetailHeader item={headerItem} />
|
||||
|
||||
<div class="detail-layout">
|
||||
<div class="detail-main">
|
||||
{
|
||||
markdownHtml ? (
|
||||
<section class="detail-section" aria-labelledby="doc-heading">
|
||||
<h2 id="doc-heading">Documentation</h2>
|
||||
<div class="article-content" set:html={markdownHtml} />
|
||||
</section>
|
||||
) : (
|
||||
<section class="detail-section">
|
||||
<h2>Overview</h2>
|
||||
<p>{item.description}</p>
|
||||
<p class="detail-empty">
|
||||
{isExternal
|
||||
? "This is a third-party plugin hosted outside this repository."
|
||||
: "No README is bundled with this plugin."}{" "}
|
||||
View it{" "}
|
||||
<a href={githubUrl} target="_blank" rel="noopener">
|
||||
on GitHub
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<IncludedItems
|
||||
items={item.items}
|
||||
pluginPath={item.path}
|
||||
githubBase={GITHUB_TREE}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Sidebar
|
||||
item={sidebarItem}
|
||||
lastUpdated={lastUpdated ?? undefined}
|
||||
frontmatterText={frontmatterText || undefined}
|
||||
githubUrl={githubUrl}
|
||||
chips={chips}
|
||||
>
|
||||
<PluginInstall
|
||||
slot="install"
|
||||
isExternal={isExternal}
|
||||
pluginId={item.id}
|
||||
externalSource={externalSourceRef || null}
|
||||
installCommand={installCommand}
|
||||
label={isExternal ? "Install this plugin" : "Install"}
|
||||
note={isExternal
|
||||
? "This plugin is maintained in an external repository. Installing it adds the third-party marketplace before installing."
|
||||
: undefined}
|
||||
/>
|
||||
</Sidebar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
|
||||
<script>
|
||||
import "../../scripts/pages/resource-detail";
|
||||
</script>
|
||||
</StarlightPage>
|
||||
@@ -5,7 +5,6 @@ import ContributeCTA from '../components/ContributeCTA.astro';
|
||||
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import BackToTop from '../components/BackToTop.astro';
|
||||
import Modal from '../components/Modal.astro';
|
||||
import { renderPluginsHtml, sortPlugins } from '../scripts/pages/plugins-render';
|
||||
|
||||
const initialItems = sortPlugins(pluginsData.items, 'title');
|
||||
@@ -57,7 +56,6 @@ const initialItems = sortPlugins(pluginsData.items, 'title');
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
<Modal />
|
||||
<EmbeddedPageData filename="plugins.json" data={pluginsData} />
|
||||
|
||||
<script>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
---
|
||||
import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
|
||||
import BackToTop from "../../components/BackToTop.astro";
|
||||
import skillsData from "../../../public/data/skills.json";
|
||||
import RawMarkdown from "../../components/pages/RawMarkdown.astro";
|
||||
import type { HeaderItem } from "../../components/pages/Header.astro";
|
||||
import DetailHeader from "../../components/pages/Header.astro";
|
||||
import Breadcrumb from "../../components/pages/Breadcrumb.astro";
|
||||
import Sidebar from "../../components/pages/Sidebar.astro";
|
||||
import FileBrowser from "../../components/pages/FileBrowser.astro";
|
||||
import { readResourceMarkdown, formatLastUpdated } from "../../lib/detail-page";
|
||||
|
||||
const GITHUB_BASE = "https://github.com/github/awesome-copilot/blob/main";
|
||||
const GITHUB_TREE_BASE = "https://github.com/github/awesome-copilot/tree/main";
|
||||
const REPO_IDENTIFIER = "github/awesome-copilot";
|
||||
|
||||
interface SkillFile {
|
||||
name: string;
|
||||
path: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
interface SkillItem extends HeaderItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
skillFile: string;
|
||||
files: SkillFile[];
|
||||
hasAssets?: boolean;
|
||||
assetCount?: number;
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
export function getStaticPaths() {
|
||||
return (skillsData.items as SkillItem[]).map((item) => ({
|
||||
params: { id: item.id },
|
||||
props: { item },
|
||||
}));
|
||||
}
|
||||
|
||||
const { item } = Astro.props as { item: SkillItem };
|
||||
|
||||
const { markdownHtml, frontmatterText, rawMarkdown } = readResourceMarkdown(
|
||||
item.skillFile
|
||||
);
|
||||
const lastUpdated = formatLastUpdated(item.lastUpdated);
|
||||
const githubUrl = `${GITHUB_TREE_BASE}/${item.path}`;
|
||||
const installCommand = `gh skills install ${REPO_IDENTIFIER} ${item.id}`;
|
||||
const skillFileName = item.skillFile.split("/").pop() ?? "SKILL.md";
|
||||
|
||||
const fileCount = item.files.length;
|
||||
const chips: { title: string; items: string[] }[] = [
|
||||
{ title: "Files", items: [`${fileCount} file${fileCount === 1 ? "" : "s"}`] },
|
||||
];
|
||||
if (item.hasAssets && item.assetCount) {
|
||||
chips.push({
|
||||
title: "Assets",
|
||||
items: [`${item.assetCount} asset${item.assetCount === 1 ? "" : "s"}`],
|
||||
});
|
||||
}
|
||||
---
|
||||
|
||||
<StarlightPage
|
||||
frontmatter={{
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
template: "splash",
|
||||
prev: false,
|
||||
next: false,
|
||||
editUrl: false,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
id="main-content"
|
||||
class="resource-detail-page skill-detail-page"
|
||||
data-file-browser-page
|
||||
data-bundle-id={item.id}
|
||||
data-path={item.skillFile}
|
||||
>
|
||||
<RawMarkdown markdown={rawMarkdown} />
|
||||
<div class="container">
|
||||
<Breadcrumb
|
||||
paths={[
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Skills", href: "/skills/" },
|
||||
]}
|
||||
title={item.title}
|
||||
/>
|
||||
|
||||
<DetailHeader item={item} />
|
||||
|
||||
<div class="detail-layout">
|
||||
<div class="detail-main">
|
||||
<FileBrowser
|
||||
files={item.files}
|
||||
primaryPath={item.skillFile}
|
||||
primaryName={skillFileName}
|
||||
primaryHtml={markdownHtml}
|
||||
githubBase={GITHUB_BASE}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Sidebar
|
||||
item={item}
|
||||
lastUpdated={lastUpdated ?? undefined}
|
||||
frontmatterText={frontmatterText || undefined}
|
||||
githubUrl={githubUrl}
|
||||
chips={chips}
|
||||
>
|
||||
<div class="skill-install" slot="install">
|
||||
<p class="skill-install-label">Install with the GitHub CLI</p>
|
||||
<div class="skill-install-command" data-install-command={installCommand}>
|
||||
<code tabindex="0">{installCommand}</code>
|
||||
<button
|
||||
type="button"
|
||||
class="skill-install-copy"
|
||||
data-action="copy-install"
|
||||
title="Copy install command"
|
||||
aria-label="Copy install command"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="15"
|
||||
height="15"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
><path
|
||||
d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"
|
||||
></path><path
|
||||
d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"
|
||||
></path></svg
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary skill-download-btn"
|
||||
data-action="download-zip"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
><path
|
||||
d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z"
|
||||
></path><path
|
||||
d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969Z"
|
||||
></path></svg
|
||||
>
|
||||
Download ZIP
|
||||
</button>
|
||||
</div>
|
||||
</Sidebar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
|
||||
<script>
|
||||
import "../../scripts/pages/file-browser";
|
||||
</script>
|
||||
</StarlightPage>
|
||||
@@ -4,7 +4,6 @@ import ContributeCTA from '../components/ContributeCTA.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||
import BackToTop from '../components/BackToTop.astro';
|
||||
import Modal from '../components/Modal.astro';
|
||||
import skillsData from '../../public/data/skills.json';
|
||||
import { renderSkillsHtml, sortSkills } from '../scripts/pages/skills-render';
|
||||
|
||||
@@ -43,7 +42,6 @@ const initialItems = sortSkills(skillsData.items, 'title');
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
<Modal />
|
||||
|
||||
<EmbeddedPageData filename="skills.json" data={skillsData} />
|
||||
<script>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
|
||||
import BackToTop from "../../components/BackToTop.astro";
|
||||
import workflowsData from "../../../public/data/workflows.json";
|
||||
import RawMarkdown from "../../components/pages/RawMarkdown.astro";
|
||||
import type { HeaderItem } from "../../components/pages/Header.astro";
|
||||
import DetailHeader from "../../components/pages/Header.astro";
|
||||
import Breadcrumb from "../../components/pages/Breadcrumb.astro";
|
||||
import Main from "../../components/pages/Main.astro";
|
||||
import Sidebar from "../../components/pages/Sidebar.astro";
|
||||
import { readResourceMarkdown, formatLastUpdated } from "../../lib/detail-page";
|
||||
|
||||
const GITHUB_BASE = "https://github.com/github/awesome-copilot/blob/main";
|
||||
const workflowGuideHref = "/learning-hub/agentic-workflows/";
|
||||
|
||||
interface WorkflowItem extends HeaderItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
triggers: string[];
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
export function getStaticPaths() {
|
||||
return (workflowsData.items as WorkflowItem[]).map((item) => ({
|
||||
params: { id: item.id },
|
||||
props: { item },
|
||||
}));
|
||||
}
|
||||
|
||||
const { item } = Astro.props as { item: WorkflowItem };
|
||||
|
||||
const { markdownHtml, frontmatterText, rawMarkdown } = readResourceMarkdown(
|
||||
item.path
|
||||
);
|
||||
const lastUpdated = formatLastUpdated(item.lastUpdated);
|
||||
const githubUrl = `${GITHUB_BASE}/${item.path}`;
|
||||
|
||||
const chips: { title: string; items: string[] }[] = [];
|
||||
if (item.triggers.length > 0) {
|
||||
chips.push({ title: "Triggers", items: item.triggers });
|
||||
}
|
||||
---
|
||||
|
||||
<StarlightPage
|
||||
frontmatter={{
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
template: "splash",
|
||||
prev: false,
|
||||
next: false,
|
||||
editUrl: false,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
id="main-content"
|
||||
class="resource-detail-page"
|
||||
data-resource-detail
|
||||
data-path={item.path}
|
||||
>
|
||||
<RawMarkdown markdown={rawMarkdown} />
|
||||
<div class="container">
|
||||
<Breadcrumb
|
||||
paths={[
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Agentic Workflows", href: "/workflows/" },
|
||||
]}
|
||||
title={item.title}
|
||||
/>
|
||||
|
||||
<DetailHeader item={item} />
|
||||
|
||||
<div class="detail-layout">
|
||||
<Main markdownHtml={markdownHtml} githubUrl={githubUrl} />
|
||||
|
||||
<Sidebar
|
||||
item={item}
|
||||
lastUpdated={lastUpdated ?? undefined}
|
||||
frontmatterText={frontmatterText || undefined}
|
||||
githubUrl={githubUrl}
|
||||
chips={chips}
|
||||
>
|
||||
<div class="skill-install" slot="install">
|
||||
<p class="skill-install-label">Install this workflow</p>
|
||||
<p class="skill-install-note">
|
||||
Install the <code>gh aw</code> CLI{" "}
|
||||
(<code>gh extension install github/gh-aw</code>), then copy this
|
||||
file into <code>.github/workflows/</code> and run{" "}
|
||||
<code>gh aw compile</code> to generate the lock file. See the{" "}
|
||||
<a href={workflowGuideHref}>Agentic Workflows guide</a>{" "}
|
||||
for full setup steps.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary skill-download-btn"
|
||||
data-action="download"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
><path
|
||||
d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z"
|
||||
></path><path
|
||||
d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969Z"
|
||||
></path></svg
|
||||
>
|
||||
Download workflow
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary skill-download-btn"
|
||||
data-action="copy-markdown"
|
||||
>
|
||||
Copy markdown
|
||||
</button>
|
||||
</div>
|
||||
</Sidebar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
|
||||
<script>
|
||||
import "../../scripts/pages/resource-detail";
|
||||
</script>
|
||||
</StarlightPage>
|
||||
@@ -5,7 +5,6 @@ import ContributeCTA from '../components/ContributeCTA.astro';
|
||||
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import BackToTop from '../components/BackToTop.astro';
|
||||
import Modal from '../components/Modal.astro';
|
||||
import { renderWorkflowsHtml, sortWorkflows } from '../scripts/pages/workflows-render';
|
||||
|
||||
const initialItems = sortWorkflows(workflowsData.items, 'title');
|
||||
@@ -48,7 +47,6 @@ const initialItems = sortWorkflows(workflowsData.items, 'title');
|
||||
</div>
|
||||
|
||||
<BackToTop />
|
||||
<Modal />
|
||||
<EmbeddedPageData filename="workflows.json" data={workflowsData} />
|
||||
|
||||
<script>
|
||||
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
escapeHtml,
|
||||
getResourceIconSvg,
|
||||
sanitizeUrl,
|
||||
isSafeRepoFilePath,
|
||||
REPO_IDENTIFIER,
|
||||
} from "./utils";
|
||||
import { externalRepoUrl } from "../lib/external-source";
|
||||
|
||||
type ModalViewMode = "rendered" | "raw";
|
||||
|
||||
@@ -431,6 +433,8 @@ interface PluginSource {
|
||||
source: string;
|
||||
repo?: string;
|
||||
path?: string;
|
||||
ref?: string;
|
||||
sha?: string;
|
||||
}
|
||||
|
||||
interface Plugin {
|
||||
@@ -770,8 +774,19 @@ function handleHashChange(): void {
|
||||
const hash = window.location.hash;
|
||||
|
||||
if (hash && hash.startsWith("#file=")) {
|
||||
const filePath = decodeURIComponent(hash.slice(6));
|
||||
if (filePath && filePath !== currentFilePath) {
|
||||
let filePath: string | null = null;
|
||||
try {
|
||||
filePath = decodeURIComponent(hash.slice(6));
|
||||
} catch {
|
||||
filePath = null;
|
||||
}
|
||||
// Ignore malformed or traversal (`../`) paths to prevent client-side path
|
||||
// traversal from loading arbitrary content into the modal.
|
||||
if (
|
||||
filePath &&
|
||||
isSafeRepoFilePath(filePath) &&
|
||||
filePath !== currentFilePath
|
||||
) {
|
||||
const type = getResourceType(filePath);
|
||||
openFileModal(filePath, type, false); // Don't update hash since we're responding to it
|
||||
}
|
||||
@@ -1162,14 +1177,9 @@ async function openPluginModal(
|
||||
* Get the best URL for an external plugin, preferring the deep path within the repo
|
||||
*/
|
||||
function getExternalPluginUrl(plugin: Plugin): string {
|
||||
if (plugin.source?.source === "github" && plugin.source.repo) {
|
||||
const base = `https://github.com/${plugin.source.repo}`;
|
||||
return plugin.source.path && plugin.source.path !== "/"
|
||||
? `${base}/tree/main/${plugin.source.path}`
|
||||
: base;
|
||||
}
|
||||
// Sanitize URLs from JSON to prevent XSS via javascript:/data: schemes
|
||||
return sanitizeUrl(plugin.repository || plugin.homepage);
|
||||
// Sanitize URLs from JSON to prevent XSS via javascript:/data: schemes and
|
||||
// pin GitHub links to the source's ref/sha when available.
|
||||
return externalRepoUrl(plugin.source, [plugin.repository, plugin.homepage]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
escapeHtml,
|
||||
getActionButtonsHtml,
|
||||
getGitHubUrl,
|
||||
getInstallDropdownHtml,
|
||||
getLastUpdatedHtml,
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render";
|
||||
|
||||
export interface RenderableAgent {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
@@ -21,6 +21,13 @@ export type AgentSortOption = "title" | "lastUpdated";
|
||||
|
||||
const resourceType = "agent";
|
||||
|
||||
/**
|
||||
* Build the URL of an agent's dedicated detail page.
|
||||
*/
|
||||
export function getAgentDetailUrl(id: string): string {
|
||||
return `/agent/${id}/`;
|
||||
}
|
||||
|
||||
export function sortAgents<T extends RenderableAgent>(
|
||||
items: T[],
|
||||
sort: AgentSortOption
|
||||
@@ -72,7 +79,13 @@ export function renderAgentsHtml(items: RenderableAgent[]): string {
|
||||
|
||||
const actionsHtml = `
|
||||
${getInstallDropdownHtml(resourceType, item.path, true)}
|
||||
${getActionButtonsHtml(item.path, true)}
|
||||
<button class="btn btn-secondary btn-small action-download" data-path="${escapeHtml(
|
||||
item.path
|
||||
)}" title="Download file">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||||
<path d="M7.47 10.78a.75.75 0 0 0 1.06 0l3.75-3.75a.75.75 0 0 0-1.06-1.06L8.75 8.44V1.75a.75.75 0 0 0-1.5 0v6.69L4.78 5.97a.75.75 0 0 0-1.06 1.06l3.75 3.75ZM3.75 13a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-8.5Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="${getGitHubUrl(item.path)}" class="btn btn-secondary btn-small" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">
|
||||
GitHub
|
||||
</a>
|
||||
@@ -81,6 +94,7 @@ export function renderAgentsHtml(items: RenderableAgent[]): string {
|
||||
return renderSharedCardHtml({
|
||||
title: item.title,
|
||||
description: item.description || "No description",
|
||||
href: getAgentDetailUrl(item.id),
|
||||
articleAttributes: {
|
||||
"data-path": item.path,
|
||||
},
|
||||
|
||||
@@ -2,16 +2,12 @@
|
||||
* Agents page functionality
|
||||
*/
|
||||
import {
|
||||
escapeHtml,
|
||||
fetchData,
|
||||
formatRelativeTime,
|
||||
getQueryParam,
|
||||
getVSCodeInstallUrl,
|
||||
setupActionHandlers,
|
||||
setupDropdownCloseHandlers,
|
||||
updateQueryParams,
|
||||
} from '../utils';
|
||||
import { openCardDetailsModal, setupModal } from '../modal';
|
||||
import {
|
||||
renderAgentsHtml,
|
||||
sortAgents,
|
||||
@@ -20,7 +16,6 @@ import {
|
||||
} from './agents-render';
|
||||
|
||||
interface Agent extends RenderableAgent {
|
||||
id?: string;
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
@@ -29,10 +24,7 @@ interface AgentsData {
|
||||
}
|
||||
|
||||
let allItems: Agent[] = [];
|
||||
let agentByPath = new Map<string, Agent>();
|
||||
let currentSort: AgentSortOption = 'title';
|
||||
let resourceListHandlersReady = false;
|
||||
let modalReady = false;
|
||||
|
||||
function applyFiltersAndRender(): void {
|
||||
const countEl = document.getElementById('results-count');
|
||||
@@ -51,90 +43,6 @@ function renderItems(items: Agent[]): void {
|
||||
list.innerHTML = renderAgentsHtml(items);
|
||||
}
|
||||
|
||||
function openAgentDetailsModal(path: string, trigger?: HTMLElement): void {
|
||||
const item = agentByPath.get(path);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metaParts: string[] = [];
|
||||
if (item.model) {
|
||||
metaParts.push(
|
||||
`<span class="resource-tag tag-model">${escapeHtml(
|
||||
Array.isArray(item.model) ? item.model.join(', ') : item.model
|
||||
)}</span>`
|
||||
);
|
||||
}
|
||||
|
||||
if (item.hasHandoffs) {
|
||||
metaParts.push('<span class="resource-tag tag-handoffs">handoffs</span>');
|
||||
}
|
||||
|
||||
if (item.lastUpdated) {
|
||||
metaParts.push(
|
||||
`<span class="last-updated">Updated ${escapeHtml(
|
||||
formatRelativeTime(item.lastUpdated)
|
||||
)}</span>`
|
||||
);
|
||||
}
|
||||
|
||||
const toolItems = item.tools || [];
|
||||
const displayTools = toolItems.slice(0, 24);
|
||||
const tagParts = displayTools.map(
|
||||
(tool) => `<span class="resource-tag">${escapeHtml(tool)}</span>`
|
||||
);
|
||||
if (toolItems.length > displayTools.length) {
|
||||
tagParts.push(
|
||||
`<span class="resource-tag">+${toolItems.length - displayTools.length} more</span>`
|
||||
);
|
||||
}
|
||||
|
||||
const vscodeUrl = getVSCodeInstallUrl('agent', path, false);
|
||||
const insidersUrl = getVSCodeInstallUrl('agent', path, true);
|
||||
const actions = [
|
||||
vscodeUrl
|
||||
? `<a class="btn btn-primary btn-small" href="${escapeHtml(vscodeUrl)}" target="_blank" rel="noopener noreferrer">Install (VS Code)</a>`
|
||||
: '',
|
||||
insidersUrl
|
||||
? `<a class="btn btn-secondary btn-small" href="${escapeHtml(insidersUrl)}" target="_blank" rel="noopener noreferrer">Install (Insiders)</a>`
|
||||
: '',
|
||||
`<button class="btn btn-secondary btn-small" type="button" data-open-file-path="${escapeHtml(
|
||||
path
|
||||
)}" data-open-file-type="agent">Source</button>`,
|
||||
].filter(Boolean);
|
||||
|
||||
openCardDetailsModal({
|
||||
title: item.title,
|
||||
description: item.description || 'No description',
|
||||
previewIcon: '🤖',
|
||||
previewText: 'Agent metadata and install options',
|
||||
metaHtml: metaParts.join(''),
|
||||
tagsHtml: tagParts.join(''),
|
||||
actionsHtml: actions.join(''),
|
||||
trigger,
|
||||
});
|
||||
}
|
||||
|
||||
function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
if (!list || resourceListHandlersReady) return;
|
||||
|
||||
list.addEventListener('click', (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('.resource-actions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = target.closest('.resource-item') as HTMLElement | null;
|
||||
const button = item?.querySelector('.resource-preview') as HTMLElement | undefined;
|
||||
const path = item?.dataset.path;
|
||||
if (path) {
|
||||
openAgentDetailsModal(path, button);
|
||||
}
|
||||
});
|
||||
|
||||
resourceListHandlersReady = true;
|
||||
}
|
||||
|
||||
function syncUrlState(): void {
|
||||
updateQueryParams({
|
||||
q: '',
|
||||
@@ -149,13 +57,6 @@ export async function initAgentsPage(): Promise<void> {
|
||||
const list = document.getElementById('resource-list');
|
||||
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
|
||||
|
||||
if (!modalReady) {
|
||||
setupModal();
|
||||
modalReady = true;
|
||||
}
|
||||
|
||||
setupResourceListHandlers(list as HTMLElement | null);
|
||||
|
||||
const data = await fetchData<AgentsData>('agents.json');
|
||||
if (!data || !data.items) {
|
||||
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
|
||||
@@ -163,7 +64,6 @@ export async function initAgentsPage(): Promise<void> {
|
||||
}
|
||||
|
||||
allItems = data.items;
|
||||
agentByPath = new Map(allItems.map((item) => [item.path, item]));
|
||||
|
||||
const initialSort = getQueryParam('sort');
|
||||
if (initialSort === 'lastUpdated') {
|
||||
|
||||
@@ -11,6 +11,12 @@ export interface SharedCardRenderItem {
|
||||
infoExtraHtml?: string;
|
||||
metaHtml?: string;
|
||||
actionsHtml?: string;
|
||||
/**
|
||||
* When provided, the card preview renders as a link to a dedicated detail
|
||||
* page instead of a button that opens a modal. This enables real URL deep
|
||||
* linking and native open-in-new-tab behaviour.
|
||||
*/
|
||||
href?: string;
|
||||
}
|
||||
|
||||
function renderAttributes(attributes?: Record<string, string>): string {
|
||||
@@ -35,9 +41,7 @@ export function renderSharedCardHtml(item: SharedCardRenderItem): string {
|
||||
? `resource-item ${item.articleClassName}`
|
||||
: "resource-item";
|
||||
|
||||
return `
|
||||
<div class="${articleClass}" role="${escapeHtml(role)}"${item.tabIndex !== undefined ? ` tabindex="${String(item.tabIndex)}"` : ""}${renderAttributes(item.articleAttributes)}>
|
||||
<button type="button" class="resource-preview">
|
||||
const previewInner = `
|
||||
${item.previewMediaHtml || ""}
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${escapeHtml(item.title)}</div>
|
||||
@@ -46,8 +50,17 @@ export function renderSharedCardHtml(item: SharedCardRenderItem): string {
|
||||
<div class="resource-meta">
|
||||
${item.metaHtml || ""}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
const preview = item.href
|
||||
? `<a class="resource-preview" href="${escapeHtml(item.href)}">${previewInner}
|
||||
</a>`
|
||||
: `<button type="button" class="resource-preview">${previewInner}
|
||||
</button>`;
|
||||
|
||||
return `
|
||||
<div class="${articleClass}" role="${escapeHtml(role)}"${item.tabIndex !== undefined ? ` tabindex="${String(item.tabIndex)}"` : ""}${renderAttributes(item.articleAttributes)}>
|
||||
${preview}
|
||||
${item.actionsHtml ? `<div class="resource-actions">${item.actionsHtml}</div>` : ""}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Extension detail page image gallery.
|
||||
* Switches the main preview image when a thumbnail is selected.
|
||||
*/
|
||||
function initExtensionGallery(): void {
|
||||
const mainImage = document.getElementById(
|
||||
"extension-gallery-image"
|
||||
) as HTMLImageElement | null;
|
||||
const thumbs = document.querySelectorAll<HTMLButtonElement>(
|
||||
".extension-gallery-thumb"
|
||||
);
|
||||
|
||||
if (!mainImage || thumbs.length === 0) return;
|
||||
|
||||
thumbs.forEach((thumb) => {
|
||||
thumb.addEventListener("click", () => {
|
||||
const url = thumb.dataset.galleryUrl;
|
||||
if (!url) return;
|
||||
|
||||
mainImage.src = url;
|
||||
|
||||
thumbs.forEach((other) => {
|
||||
const isActive = other === thumb;
|
||||
other.classList.toggle("active", isActive);
|
||||
if (isActive) {
|
||||
other.setAttribute("aria-current", "true");
|
||||
} else {
|
||||
other.removeAttribute("aria-current");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initExtensionGallery, {
|
||||
once: true,
|
||||
});
|
||||
} else {
|
||||
initExtensionGallery();
|
||||
}
|
||||
@@ -4,8 +4,16 @@ import {
|
||||
getGitHubUrl,
|
||||
getLastUpdatedHtml,
|
||||
} from "../utils";
|
||||
import { sanitizeHttpUrl } from "../../lib/external-source";
|
||||
import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render";
|
||||
|
||||
// Allow only http(s) URLs from external/generated data; unsafe values collapse
|
||||
// to "" so downstream truthiness guards (disabled buttons, omitted links) hold.
|
||||
function safeUrl(value?: string | null): string {
|
||||
const sanitized = sanitizeHttpUrl(value);
|
||||
return sanitized === "#" ? "" : sanitized;
|
||||
}
|
||||
|
||||
export interface RenderableExtension {
|
||||
id: string;
|
||||
canvasId?: string;
|
||||
@@ -46,6 +54,10 @@ export interface RenderableExtension {
|
||||
|
||||
export type ExtensionSortOption = "title" | "lastUpdated";
|
||||
|
||||
export function getExtensionDetailUrl(id: string): string {
|
||||
return `/extension/${id}/`;
|
||||
}
|
||||
|
||||
export function sortExtensions<T extends RenderableExtension>(
|
||||
items: T[],
|
||||
sort: ExtensionSortOption
|
||||
@@ -71,23 +83,30 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string {
|
||||
|
||||
return items
|
||||
.map((item) => {
|
||||
const installUrl =
|
||||
const installUrl = safeUrl(
|
||||
item.installUrl ||
|
||||
(item.path && item.ref
|
||||
? `https://github.com/github/awesome-copilot/tree/${item.ref}/${item.path.replace(
|
||||
/\\/g,
|
||||
"/"
|
||||
)}`
|
||||
: "");
|
||||
const installCommand =
|
||||
item.installCommand ||
|
||||
(item.pluginName ? `copilot plugin install ${item.pluginName}@awesome-copilot` : "");
|
||||
const sourceUrl =
|
||||
item.sourceUrl || (item.path ? getGitHubUrl(item.path) : "");
|
||||
(item.path && item.ref
|
||||
? `https://github.com/github/awesome-copilot/tree/${item.ref}/${item.path.replace(
|
||||
/\\/g,
|
||||
"/"
|
||||
)}`
|
||||
: "")
|
||||
);
|
||||
const sourceUrl = safeUrl(
|
||||
item.sourceUrl || (item.path ? getGitHubUrl(item.path) : "")
|
||||
);
|
||||
const pluginId = item.pluginName || item.id;
|
||||
const ghappInstallUrl =
|
||||
!item.external && pluginId
|
||||
? `ghapp://plugins/install?source=${encodeURIComponent(
|
||||
`${pluginId}@awesome-copilot`
|
||||
)}`
|
||||
: "";
|
||||
|
||||
const previewMediaHtml = item.imageUrl
|
||||
? `<div class="resource-thumbnail-btn" data-extension-id="${escapeHtml(item.id)}" aria-hidden="true">
|
||||
<img class="resource-thumbnail" src="${escapeHtml(item.imageUrl)}" alt="${escapeHtml(item.name)} preview" loading="lazy" />
|
||||
const previewImageUrl = safeUrl(item.imageUrl);
|
||||
const previewMediaHtml = previewImageUrl
|
||||
? `<div class="resource-thumbnail-btn" aria-hidden="true">
|
||||
<img class="resource-thumbnail" src="${escapeHtml(previewImageUrl)}" alt="${escapeHtml(item.name)} preview" loading="lazy" />
|
||||
</div>`
|
||||
: `<div class="resource-thumbnail resource-thumbnail-placeholder" aria-hidden="true">Canvas</div>`;
|
||||
|
||||
@@ -122,14 +141,17 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string {
|
||||
`;
|
||||
|
||||
const actionsHtml = `
|
||||
<button
|
||||
class="btn btn-primary btn-small copy-install-url-btn"
|
||||
data-install-command="${escapeHtml(installCommand)}"
|
||||
title="Copy plugin install command"
|
||||
${installCommand ? "" : "disabled"}
|
||||
${
|
||||
ghappInstallUrl
|
||||
? `<a
|
||||
class="btn btn-primary btn-small"
|
||||
href="${escapeHtml(ghappInstallUrl)}"
|
||||
title="Install in the GitHub Copilot app"
|
||||
>
|
||||
Copy Install
|
||||
</button>
|
||||
Install in Copilot app
|
||||
</a>`
|
||||
: ""
|
||||
}
|
||||
<button
|
||||
class="btn btn-secondary btn-small copy-install-url-btn"
|
||||
data-install-url="${escapeHtml(installUrl)}"
|
||||
@@ -150,6 +172,7 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string {
|
||||
return renderSharedCardHtml({
|
||||
title: item.name,
|
||||
description: item.description || "Canvas extension",
|
||||
href: getExtensionDetailUrl(item.id),
|
||||
previewMediaHtml,
|
||||
infoExtraHtml,
|
||||
metaHtml,
|
||||
|
||||
@@ -8,19 +8,13 @@ import {
|
||||
type Choices,
|
||||
} from "../choices";
|
||||
import {
|
||||
escapeHtml,
|
||||
copyToClipboard,
|
||||
fetchData,
|
||||
formatRelativeTime,
|
||||
getGitHubHandle,
|
||||
getGitHubUrl,
|
||||
getQueryParam,
|
||||
getQueryParamValues,
|
||||
sanitizeUrl,
|
||||
showToast,
|
||||
updateQueryParams,
|
||||
} from "../utils";
|
||||
import { openCardDetailsModal, setupModal } from "../modal";
|
||||
import {
|
||||
renderExtensionsHtml,
|
||||
sortExtensions,
|
||||
@@ -40,229 +34,13 @@ interface ExtensionsData {
|
||||
};
|
||||
}
|
||||
|
||||
interface ExtensionScreenshot {
|
||||
path?: string | null;
|
||||
type?: string | null;
|
||||
}
|
||||
|
||||
let allItems: Extension[] = [];
|
||||
let extensionById = new Map<string, Extension>();
|
||||
let currentSort: ExtensionSortOption = "title";
|
||||
let keywordSelect: Choices;
|
||||
let currentFilters = {
|
||||
keywords: [] as string[],
|
||||
};
|
||||
let actionHandlersReady = false;
|
||||
let modalReady = false;
|
||||
|
||||
function normalizeScreenshotEntries(value: unknown): ExtensionScreenshot[] {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((entry): entry is ExtensionScreenshot => Boolean(entry));
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return [value as ExtensionScreenshot];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getInstallUrl(item: Extension): string {
|
||||
return (
|
||||
item.installUrl ||
|
||||
(item.path && item.ref
|
||||
? `https://github.com/github/awesome-copilot/tree/${item.ref}/${item.path.replace(
|
||||
/\\/g,
|
||||
"/"
|
||||
)}`
|
||||
: "")
|
||||
);
|
||||
}
|
||||
|
||||
function getInstallCommand(item: Extension): string {
|
||||
return (
|
||||
item.installCommand ||
|
||||
(item.pluginName ? `copilot plugin install ${item.pluginName}@awesome-copilot` : "")
|
||||
);
|
||||
}
|
||||
|
||||
function getSourceUrl(item: Extension): string {
|
||||
return item.sourceUrl || (item.path ? getGitHubUrl(item.path) : "");
|
||||
}
|
||||
|
||||
function toRawAssetUrl(item: Extension, assetPath: string | null | undefined): string {
|
||||
if (!assetPath || !item.ref) return "";
|
||||
if (/^https?:\/\//i.test(assetPath)) return assetPath;
|
||||
return `https://raw.githubusercontent.com/github/awesome-copilot/${item.ref}/${assetPath.replace(
|
||||
/\\/g,
|
||||
"/"
|
||||
)}`;
|
||||
}
|
||||
|
||||
function getGalleryImages(item: Extension): string[] {
|
||||
const images: string[] = [];
|
||||
|
||||
if (item.imageUrl) {
|
||||
images.push(item.imageUrl);
|
||||
}
|
||||
|
||||
const iconPath = item.screenshots?.icon?.path;
|
||||
if (iconPath) {
|
||||
const url = toRawAssetUrl(item, iconPath);
|
||||
if (url) images.push(url);
|
||||
}
|
||||
|
||||
const galleryPaths = normalizeScreenshotEntries(item.screenshots?.gallery);
|
||||
for (const entry of galleryPaths) {
|
||||
const url = toRawAssetUrl(item, entry.path);
|
||||
if (url) images.push(url);
|
||||
}
|
||||
|
||||
return Array.from(new Set(images));
|
||||
}
|
||||
|
||||
function renderGalleryThumbnails(images: string[], selectedUrl: string): void {
|
||||
const gallery = document.getElementById("extension-details-gallery");
|
||||
if (!gallery) return;
|
||||
|
||||
gallery.innerHTML = "";
|
||||
|
||||
images.forEach((url, index) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "extension-details-thumbnail-btn";
|
||||
button.dataset.galleryImageUrl = url;
|
||||
button.setAttribute("aria-label", `Show image ${index + 1}`);
|
||||
button.setAttribute("role", "listitem");
|
||||
if (url === selectedUrl) {
|
||||
button.classList.add("active");
|
||||
button.setAttribute("aria-current", "true");
|
||||
}
|
||||
|
||||
const image = document.createElement("img");
|
||||
image.src = url;
|
||||
image.alt = `Gallery image ${index + 1}`;
|
||||
image.className = "extension-details-thumbnail";
|
||||
image.loading = "lazy";
|
||||
|
||||
button.appendChild(image);
|
||||
gallery.appendChild(button);
|
||||
});
|
||||
}
|
||||
|
||||
function setSelectedGalleryImage(url: string, extensionName: string): void {
|
||||
const image = document.getElementById(
|
||||
"extension-details-image"
|
||||
) as HTMLImageElement | null;
|
||||
const gallery = document.getElementById("extension-details-gallery");
|
||||
if (!image) return;
|
||||
|
||||
image.src = url;
|
||||
image.alt = `${extensionName} screenshot`;
|
||||
|
||||
gallery?.querySelectorAll<HTMLButtonElement>(".extension-details-thumbnail-btn").forEach((button) => {
|
||||
const isActive = button.dataset.galleryImageUrl === url;
|
||||
button.classList.toggle("active", isActive);
|
||||
if (isActive) {
|
||||
button.setAttribute("aria-current", "true");
|
||||
} else {
|
||||
button.removeAttribute("aria-current");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openDetailsModal(
|
||||
extensionId: string,
|
||||
preferredImageUrl?: string,
|
||||
trigger?: HTMLElement
|
||||
): void {
|
||||
const item = extensionById.get(extensionId);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keywordHtml = (item.keywords || [])
|
||||
.map((keyword) => `<span class="keyword-tag">${escapeHtml(keyword)}</span>`)
|
||||
.join("");
|
||||
const metaParts: string[] = [];
|
||||
if (item.external) {
|
||||
metaParts.push('<span class="resource-tag">External</span>');
|
||||
}
|
||||
if (item.author?.name) {
|
||||
const authorName = item.author.name;
|
||||
const authorUrl = item.author.url;
|
||||
const authorHandle = authorUrl
|
||||
? getGitHubHandle(authorUrl, authorName)
|
||||
: authorName;
|
||||
metaParts.push(
|
||||
authorUrl
|
||||
? `<span class="resource-author">by <a href="${escapeHtml(
|
||||
sanitizeUrl(authorUrl)
|
||||
)}" target="_blank" rel="noopener noreferrer" title="${escapeHtml(
|
||||
authorName
|
||||
)}">${escapeHtml(authorHandle)}</a></span>`
|
||||
: `<span class="resource-author">by ${escapeHtml(
|
||||
authorName
|
||||
)}</span>`
|
||||
);
|
||||
}
|
||||
if (item.lastUpdated) {
|
||||
metaParts.push(
|
||||
`<span class="last-updated">Updated ${escapeHtml(
|
||||
formatRelativeTime(item.lastUpdated)
|
||||
)}</span>`
|
||||
);
|
||||
}
|
||||
|
||||
const installUrl = getInstallUrl(item);
|
||||
const installCommand = getInstallCommand(item);
|
||||
const sourceUrl = getSourceUrl(item);
|
||||
const detailsHtml = `
|
||||
<div class="extension-details-body">
|
||||
<div class="extension-details-main">
|
||||
<img id="extension-details-image" class="extension-preview-image extension-details-image" src="" alt="" />
|
||||
<div id="extension-details-gallery" class="extension-details-gallery" role="list"></div>
|
||||
</div>
|
||||
<div class="extension-details-content">
|
||||
<p id="extension-details-description" class="extension-details-description">${escapeHtml(
|
||||
item.description || "Canvas extension"
|
||||
)}</p>
|
||||
<div id="extension-details-keywords" class="resource-keywords extension-details-keywords">${keywordHtml}</div>
|
||||
<div id="extension-details-meta" class="resource-meta extension-details-meta">${metaParts.join(
|
||||
""
|
||||
)}</div>
|
||||
<div class="resource-actions extension-details-actions">
|
||||
<button id="extension-details-install" class="btn btn-primary btn-small" type="button" data-install-command="${escapeHtml(
|
||||
installCommand
|
||||
)}" ${installCommand ? "" : "disabled"}>Copy Install</button>
|
||||
<button id="extension-details-copy-url" class="btn btn-secondary btn-small" type="button" data-install-url="${escapeHtml(
|
||||
installUrl
|
||||
)}" ${installUrl ? "" : "disabled"}>Copy URL</button>
|
||||
${sourceUrl
|
||||
? `<a id="extension-details-source" class="btn btn-secondary btn-small" href="${escapeHtml(
|
||||
sourceUrl
|
||||
)}" target="_blank" rel="noopener noreferrer">Source</a>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
openCardDetailsModal({
|
||||
title: item.name,
|
||||
description: item.description || "Canvas extension",
|
||||
detailsHtml,
|
||||
contentClassName: "modal-card-details modal-card-details-extension",
|
||||
trigger,
|
||||
});
|
||||
|
||||
const galleryImages = getGalleryImages(item);
|
||||
const initialImage = preferredImageUrl || galleryImages[0] || "";
|
||||
renderGalleryThumbnails(galleryImages, initialImage);
|
||||
if (initialImage) {
|
||||
setSelectedGalleryImage(initialImage, item.name);
|
||||
}
|
||||
}
|
||||
|
||||
function sortItems(items: Extension[]): Extension[] {
|
||||
return sortExtensions(items, currentSort);
|
||||
@@ -313,6 +91,7 @@ function setupActionHandlers(list: HTMLElement | null): void {
|
||||
|
||||
if (!installButton) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const installCommand = installButton.dataset.installCommand || "";
|
||||
const installUrl = installButton.dataset.installUrl || "";
|
||||
@@ -330,104 +109,6 @@ function setupActionHandlers(list: HTMLElement | null): void {
|
||||
: "Failed to copy install target",
|
||||
success ? "success" : "error"
|
||||
);
|
||||
return;
|
||||
});
|
||||
|
||||
list.addEventListener("click", (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
const thumbnailButton = target.closest(
|
||||
".resource-thumbnail-btn"
|
||||
) as HTMLElement | null;
|
||||
if (thumbnailButton) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const extensionId = thumbnailButton.dataset.extensionId;
|
||||
if (!extensionId) return;
|
||||
const previewButton = thumbnailButton.closest(".resource-preview") as HTMLElement | null;
|
||||
openDetailsModal(extensionId, undefined, previewButton || undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
target.closest(".resource-actions") ||
|
||||
target.closest(".extension-details-thumbnail-btn")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const card = target.closest(".resource-item") as HTMLElement | null;
|
||||
const previewButton = card?.querySelector(".resource-preview") as HTMLElement | null;
|
||||
const extensionId = card?.dataset.extensionId;
|
||||
if (extensionId) {
|
||||
openDetailsModal(extensionId, undefined, previewButton || undefined);
|
||||
}
|
||||
});
|
||||
|
||||
list.addEventListener("keydown", (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const card = target.closest(".resource-item") as HTMLElement | null;
|
||||
if (!card) return;
|
||||
|
||||
if (target.closest("a, button, select, input, textarea")) return;
|
||||
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
const extensionId = card.dataset.extensionId;
|
||||
const previewButton = card.querySelector(".resource-preview") as HTMLElement | null;
|
||||
if (extensionId) {
|
||||
openDetailsModal(extensionId, undefined, previewButton || undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", async (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const detailsInstallButton = target.closest(
|
||||
"#extension-details-install"
|
||||
) as HTMLButtonElement | null;
|
||||
if (detailsInstallButton) {
|
||||
const installCommand = detailsInstallButton.dataset.installCommand || "";
|
||||
if (!installCommand) {
|
||||
showToast("No plugin install command available for this extension", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await copyToClipboard(installCommand);
|
||||
showToast(
|
||||
success ? "Install command copied!" : "Failed to copy install command",
|
||||
success ? "success" : "error"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const detailsCopyUrlButton = target.closest(
|
||||
"#extension-details-copy-url"
|
||||
) as HTMLButtonElement | null;
|
||||
if (detailsCopyUrlButton) {
|
||||
const installUrl = detailsCopyUrlButton.dataset.installUrl || "";
|
||||
if (!installUrl) {
|
||||
showToast("No install URL available for this extension", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await copyToClipboard(installUrl);
|
||||
showToast(
|
||||
success ? "Install URL copied!" : "Failed to copy install URL",
|
||||
success ? "success" : "error"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const button = target.closest(
|
||||
".extension-details-thumbnail-btn"
|
||||
) as HTMLButtonElement | null;
|
||||
if (!button) return;
|
||||
|
||||
const imageUrl = button.dataset.galleryImageUrl;
|
||||
const titleText = document.getElementById("modal-title")?.textContent;
|
||||
if (!imageUrl || !titleText) return;
|
||||
setSelectedGalleryImage(imageUrl, titleText);
|
||||
});
|
||||
|
||||
actionHandlersReady = true;
|
||||
@@ -448,11 +129,6 @@ export async function initExtensionsPage(): Promise<void> {
|
||||
"sort-select"
|
||||
) as HTMLSelectElement;
|
||||
|
||||
if (!modalReady) {
|
||||
setupModal();
|
||||
modalReady = true;
|
||||
}
|
||||
|
||||
setupActionHandlers(list as HTMLElement | null);
|
||||
|
||||
const data = await fetchData<ExtensionsData>("extensions.json");
|
||||
@@ -464,7 +140,6 @@ export async function initExtensionsPage(): Promise<void> {
|
||||
}
|
||||
|
||||
allItems = data.items;
|
||||
extensionById = new Map(allItems.map((item) => [item.id, item]));
|
||||
|
||||
const availableKeywords = (
|
||||
data.filters?.keywords ||
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* Client behaviour for multi-file bundle detail pages (skills and hooks).
|
||||
*
|
||||
* Skills and hooks are multi-file bundles: there is no single-command CLI
|
||||
* install for hooks, and skills install with the GitHub CLI. This script wires
|
||||
* up the shared file browser (the primary file — SKILL.md or README.md — is
|
||||
* embedded at build time; other files are lazily fetched and rendered — markdown
|
||||
* via `marked`, code via a lazily-imported Shiki, images via their raw GitHub
|
||||
* URL, everything else as plain text), plus the "copy install command",
|
||||
* "Download ZIP", copy-file and Share actions. Deep links use the existing
|
||||
* `#file=<path>` hash convention.
|
||||
*/
|
||||
import { marked } from "marked";
|
||||
import { enhanceMarkdownA11y } from "../../lib/markdown-a11y";
|
||||
import { sanitizeHtml } from "../../lib/sanitize-html";
|
||||
import {
|
||||
copyToClipboard,
|
||||
downloadZipBundle,
|
||||
escapeHtml,
|
||||
getRawGitHubUrl,
|
||||
isSafeRepoFilePath,
|
||||
showToast,
|
||||
type ZipDownloadFile,
|
||||
} from "../utils";
|
||||
|
||||
interface CachedFile {
|
||||
html?: string;
|
||||
rawText?: string;
|
||||
}
|
||||
|
||||
type RenderedFile = CachedFile & { html: string };
|
||||
|
||||
interface FileDescriptor {
|
||||
path: string;
|
||||
name: string;
|
||||
lang: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
function isImageKind(kind: string): boolean {
|
||||
return kind === "image";
|
||||
}
|
||||
|
||||
function encodeRepoPath(filePath: string): string {
|
||||
return filePath.split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
function getSafeGitHubFileUrl(
|
||||
githubBase: string,
|
||||
filePath: string
|
||||
): string | null {
|
||||
if (!isSafeRepoFilePath(filePath)) return null;
|
||||
|
||||
try {
|
||||
const base = new URL(githubBase);
|
||||
if (base.protocol !== "https:" || base.hostname !== "github.com") return null;
|
||||
|
||||
base.pathname = `${base.pathname.replace(/\/+$/, "")}/${encodeRepoPath(
|
||||
filePath
|
||||
)}`;
|
||||
base.search = "";
|
||||
base.hash = "";
|
||||
return base.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let highlighterPromise: Promise<
|
||||
(code: string, lang: string) => Promise<string>
|
||||
> | null = null;
|
||||
|
||||
/**
|
||||
* Lazily load Shiki and return a highlight helper. Falls back to plain,
|
||||
* escaped `<pre>` output if Shiki (or the requested language) is unavailable.
|
||||
*/
|
||||
function loadHighlighter() {
|
||||
highlighterPromise ??= import("shiki").then(({ codeToHtml }) => {
|
||||
return async (code: string, lang: string) => {
|
||||
try {
|
||||
const highlighted = await codeToHtml(code, {
|
||||
lang,
|
||||
themes: { light: "github-light", dark: "github-dark" },
|
||||
});
|
||||
// Shiki emits a scrollable <pre>; make it keyboard focusable.
|
||||
return highlighted.replace(
|
||||
/<pre(?![^>]*\btabindex=)/,
|
||||
'<pre tabindex="0"'
|
||||
);
|
||||
} catch {
|
||||
return `<pre tabindex="0" class="skill-file-plain"><code>${escapeHtml(code)}</code></pre>`;
|
||||
}
|
||||
};
|
||||
});
|
||||
return highlighterPromise;
|
||||
}
|
||||
|
||||
function initFileBrowser(): void {
|
||||
const root = document.querySelector<HTMLElement>("[data-file-browser-page]");
|
||||
if (!root) return;
|
||||
|
||||
const browser = root.querySelector<HTMLElement>("[data-file-browser]");
|
||||
const contentEl = root.querySelector<HTMLElement>("[data-file-content]");
|
||||
const statusEl = root.querySelector<HTMLElement>("[data-file-status]");
|
||||
const currentNameEl = root.querySelector<HTMLElement>(
|
||||
"[data-current-file-name]"
|
||||
);
|
||||
const fileSelect = root.querySelector<HTMLSelectElement>("[data-file-select]");
|
||||
const githubLink = root.querySelector<HTMLAnchorElement>("[data-file-github]");
|
||||
const primaryFilePath = browser?.dataset.primaryFile ?? "";
|
||||
const githubBase = browser?.dataset.githubBase ?? "";
|
||||
|
||||
const cache = new Map<string, CachedFile>();
|
||||
let activePath = primaryFilePath;
|
||||
|
||||
// Seed the cache with the primary file's raw source and its already-rendered
|
||||
// HTML. The server-rendered primary view has frontmatter stripped (gray-matter),
|
||||
// so caching the rendered HTML avoids re-parsing the raw (frontmatter-including)
|
||||
// source if the user navigates away and back to the primary file.
|
||||
if (contentEl) {
|
||||
const rawPrimary =
|
||||
root.querySelector<HTMLTextAreaElement>("[data-raw-markdown]")?.value ??
|
||||
"";
|
||||
cache.set(primaryFilePath, {
|
||||
rawText: rawPrimary,
|
||||
html: contentEl.innerHTML,
|
||||
});
|
||||
}
|
||||
|
||||
// Build the canonical file list from the <select> options. Single-file
|
||||
// bundles have no select, so fall back to just the embedded primary file.
|
||||
const fileDescriptors: FileDescriptor[] = fileSelect
|
||||
? Array.from(fileSelect.options).map((opt) => ({
|
||||
path: opt.value,
|
||||
name: opt.dataset.fileName ?? opt.value,
|
||||
lang: opt.dataset.fileLang ?? "text",
|
||||
kind: opt.dataset.fileKind ?? "other",
|
||||
}))
|
||||
: [
|
||||
{
|
||||
path: primaryFilePath,
|
||||
name: currentNameEl?.textContent?.trim() || primaryFilePath,
|
||||
lang: "markdown",
|
||||
kind: "markdown",
|
||||
},
|
||||
];
|
||||
|
||||
const setStatus = (message: string | null): void => {
|
||||
if (!statusEl) return;
|
||||
if (!message) {
|
||||
statusEl.hidden = true;
|
||||
statusEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
statusEl.hidden = false;
|
||||
statusEl.textContent = message;
|
||||
};
|
||||
|
||||
const setActive = (path: string): void => {
|
||||
if (fileSelect && fileSelect.value !== path) fileSelect.value = path;
|
||||
};
|
||||
|
||||
const showLoadError = (fileUrl: string | null): void => {
|
||||
contentEl?.replaceChildren();
|
||||
contentEl?.classList.remove("is-code");
|
||||
contentEl?.classList.remove("is-image");
|
||||
setStatus(null);
|
||||
if (!contentEl) return;
|
||||
|
||||
const message = document.createElement("p");
|
||||
message.className = "detail-empty";
|
||||
message.append("Couldn't load this file.");
|
||||
|
||||
if (fileUrl) {
|
||||
message.append(" ");
|
||||
const link = document.createElement("a");
|
||||
link.href = fileUrl;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener";
|
||||
link.textContent = "View it on GitHub";
|
||||
message.append(link, ".");
|
||||
}
|
||||
|
||||
contentEl.append(message);
|
||||
};
|
||||
|
||||
async function renderFile(
|
||||
path: string,
|
||||
name: string,
|
||||
lang: string,
|
||||
kind: string
|
||||
): Promise<RenderedFile> {
|
||||
const cached = cache.get(path);
|
||||
if (cached?.html !== undefined) return cached as RenderedFile;
|
||||
|
||||
if (isImageKind(kind)) {
|
||||
const imageUrl = getRawGitHubUrl(path);
|
||||
const entry: RenderedFile = {
|
||||
html: `<img class="skill-file-image" src="${escapeHtml(imageUrl)}" alt="${escapeHtml(name)}" loading="lazy" decoding="async">`,
|
||||
};
|
||||
cache.set(path, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
let rawText = cached?.rawText;
|
||||
if (rawText === undefined) {
|
||||
setStatus("Loading…");
|
||||
const response = await fetch(getRawGitHubUrl(path));
|
||||
if (!response.ok) throw new Error(`Failed to load ${name}`);
|
||||
rawText = await response.text();
|
||||
}
|
||||
|
||||
let html: string;
|
||||
if (kind === "markdown") {
|
||||
html = enhanceMarkdownA11y(
|
||||
sanitizeHtml(marked.parse(rawText, { async: false }) as string)
|
||||
);
|
||||
} else if (kind === "code") {
|
||||
const highlight = await loadHighlighter();
|
||||
html = await highlight(rawText, lang);
|
||||
} else {
|
||||
html = `<pre tabindex="0" class="skill-file-plain"><code>${escapeHtml(rawText)}</code></pre>`;
|
||||
}
|
||||
|
||||
const entry: RenderedFile = { html, rawText };
|
||||
cache.set(path, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function selectFile(
|
||||
path: string,
|
||||
name: string,
|
||||
lang: string,
|
||||
kind: string,
|
||||
updateHash = true
|
||||
): Promise<void> {
|
||||
if (!contentEl) return;
|
||||
const fileUrl = githubBase ? getSafeGitHubFileUrl(githubBase, path) : null;
|
||||
if (!isSafeRepoFilePath(path)) {
|
||||
showLoadError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
activePath = path;
|
||||
setActive(path);
|
||||
if (currentNameEl) currentNameEl.textContent = name;
|
||||
if (githubLink) githubLink.href = fileUrl ?? "#";
|
||||
|
||||
try {
|
||||
const entry = await renderFile(path, name, lang, kind);
|
||||
contentEl.innerHTML = entry.html;
|
||||
contentEl.classList.toggle("is-code", kind === "code");
|
||||
contentEl.classList.toggle("is-image", isImageKind(kind));
|
||||
setStatus(null);
|
||||
} catch {
|
||||
showLoadError(fileUrl);
|
||||
}
|
||||
|
||||
if (updateHash) {
|
||||
const newHash = `#file=${encodeURIComponent(path)}`;
|
||||
if (window.location.hash !== newHash) {
|
||||
history.replaceState(null, "", newHash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- File selection ---
|
||||
fileSelect?.addEventListener("change", () => {
|
||||
const opt = fileSelect.selectedOptions[0];
|
||||
if (!opt) return;
|
||||
const path = opt.value;
|
||||
if (!path || path === activePath) return;
|
||||
void selectFile(
|
||||
path,
|
||||
opt.dataset.fileName ?? path,
|
||||
opt.dataset.fileLang ?? "text",
|
||||
opt.dataset.fileKind ?? "other"
|
||||
);
|
||||
});
|
||||
|
||||
// --- Copy current file contents ---
|
||||
root
|
||||
.querySelector<HTMLButtonElement>("[data-action='copy-file']")
|
||||
?.addEventListener("click", async () => {
|
||||
const activeDescriptor = fileDescriptors.find((d) => d.path === activePath);
|
||||
if (activeDescriptor && isImageKind(activeDescriptor.kind)) {
|
||||
showToast("Images can't be copied as text", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
let entry = cache.get(activePath);
|
||||
if (entry?.rawText === undefined) {
|
||||
try {
|
||||
const response = await fetch(getRawGitHubUrl(activePath));
|
||||
if (response.ok) {
|
||||
const rawText = await response.text();
|
||||
entry = { ...entry, rawText };
|
||||
cache.set(activePath, entry);
|
||||
}
|
||||
} catch {
|
||||
/* handled below */
|
||||
}
|
||||
}
|
||||
if (entry?.rawText === undefined) {
|
||||
showToast("Nothing to copy", "error");
|
||||
return;
|
||||
}
|
||||
const success = await copyToClipboard(entry.rawText);
|
||||
showToast(
|
||||
success ? "File copied!" : "Failed to copy file",
|
||||
success ? "success" : "error"
|
||||
);
|
||||
});
|
||||
|
||||
// --- Copy install command ---
|
||||
const installBlock = root.querySelector<HTMLElement>("[data-install-command]");
|
||||
root
|
||||
.querySelector<HTMLButtonElement>("[data-action='copy-install']")
|
||||
?.addEventListener("click", async () => {
|
||||
const command = installBlock?.dataset.installCommand ?? "";
|
||||
if (!command) return;
|
||||
const success = await copyToClipboard(command);
|
||||
showToast(
|
||||
success ? "Install command copied!" : "Failed to copy",
|
||||
success ? "success" : "error"
|
||||
);
|
||||
});
|
||||
|
||||
// --- Download ZIP bundle ---
|
||||
root
|
||||
.querySelector<HTMLButtonElement>("[data-action='download-zip']")
|
||||
?.addEventListener("click", async (event) => {
|
||||
const btn = event.currentTarget as HTMLButtonElement;
|
||||
const bundleId = root.dataset.bundleId ?? "bundle";
|
||||
const files: ZipDownloadFile[] = fileDescriptors.map((d) => ({
|
||||
name: d.name,
|
||||
path: d.path,
|
||||
}));
|
||||
if (files.length === 0) {
|
||||
showToast("No files found for this item.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const originalContent = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Preparing…";
|
||||
try {
|
||||
await downloadZipBundle(bundleId, files);
|
||||
showToast("Download started!", "success");
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Download failed.";
|
||||
showToast(message, "error");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalContent;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Share (deep link to the active file) ---
|
||||
root
|
||||
.querySelector<HTMLButtonElement>("[data-action='share']")
|
||||
?.addEventListener("click", async () => {
|
||||
const url = `${window.location.origin}${window.location.pathname}#file=${encodeURIComponent(
|
||||
activePath
|
||||
)}`;
|
||||
const success = await copyToClipboard(url);
|
||||
showToast(
|
||||
success ? "Link copied!" : "Failed to copy link",
|
||||
success ? "success" : "error"
|
||||
);
|
||||
});
|
||||
|
||||
// --- Honour a #file= deep link (on load and on later hash navigation) ---
|
||||
const selectFromHash = (updateHash: boolean): void => {
|
||||
const hashMatch = window.location.hash.match(/^#file=(.+)$/);
|
||||
if (!hashMatch) return;
|
||||
let wanted: string | undefined;
|
||||
try {
|
||||
wanted = decodeURIComponent(hashMatch[1]);
|
||||
} catch {
|
||||
wanted = undefined;
|
||||
}
|
||||
const desc = wanted
|
||||
? fileDescriptors.find((d) => d.path === wanted)
|
||||
: undefined;
|
||||
if (desc && wanted !== activePath) {
|
||||
void selectFile(desc.path, desc.name, desc.lang, desc.kind, updateHash);
|
||||
}
|
||||
};
|
||||
|
||||
selectFromHash(false);
|
||||
|
||||
// React to same-page hash changes (address-bar edits, in-page anchor links,
|
||||
// and browser back/forward navigation between shared #file= links).
|
||||
window.addEventListener("hashchange", () => {
|
||||
selectFromHash(false);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initFileBrowser, {
|
||||
once: true,
|
||||
});
|
||||
} else {
|
||||
initFileBrowser();
|
||||
}
|
||||
@@ -5,6 +5,11 @@ import {
|
||||
} from "../utils";
|
||||
import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render";
|
||||
|
||||
export interface RenderableHookFile {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface RenderableHook {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -14,11 +19,19 @@ export interface RenderableHook {
|
||||
hooks: string[];
|
||||
tags: string[];
|
||||
assets: string[];
|
||||
files: RenderableHookFile[];
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
export type HookSortOption = "title" | "lastUpdated";
|
||||
|
||||
/**
|
||||
* Build the URL of a hook's dedicated detail page.
|
||||
*/
|
||||
export function getHookDetailUrl(id: string): string {
|
||||
return `/hook/${id}/`;
|
||||
}
|
||||
|
||||
export function sortHooks<T extends RenderableHook>(
|
||||
items: T[],
|
||||
sort: HookSortOption
|
||||
@@ -78,6 +91,7 @@ export function renderHooksHtml(items: RenderableHook[]): string {
|
||||
return renderSharedCardHtml({
|
||||
title: item.title,
|
||||
description: item.description || "No description",
|
||||
href: getHookDetailUrl(item.id),
|
||||
articleAttributes: {
|
||||
"data-path": item.readmeFile,
|
||||
"data-hook-id": item.id,
|
||||
|
||||
@@ -2,16 +2,13 @@
|
||||
* Hooks page functionality
|
||||
*/
|
||||
import {
|
||||
escapeHtml,
|
||||
fetchData,
|
||||
formatRelativeTime,
|
||||
getQueryParam,
|
||||
getQueryParamValues,
|
||||
showToast,
|
||||
downloadZipBundle,
|
||||
updateQueryParams,
|
||||
} from '../utils';
|
||||
import { openCardDetailsModal, setupModal } from '../modal';
|
||||
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
|
||||
import {
|
||||
renderHooksHtml,
|
||||
@@ -30,14 +27,12 @@ interface HooksData {
|
||||
}
|
||||
|
||||
let allItems: Hook[] = [];
|
||||
let hookById = new Map<string, Hook>();
|
||||
let tagSelectEl: HTMLSelectElement | null = null;
|
||||
let currentFilters = {
|
||||
tags: [] as string[],
|
||||
};
|
||||
let currentSort: HookSortOption = 'title';
|
||||
let resourceListHandlersReady = false;
|
||||
let modalReady = false;
|
||||
|
||||
function sortItems(items: Hook[]): Hook[] {
|
||||
return sortHooks(items, currentSort);
|
||||
@@ -111,57 +106,6 @@ async function downloadHook(hookId: string, btn: HTMLButtonElement): Promise<voi
|
||||
}
|
||||
}
|
||||
|
||||
function openHookDetailsModal(hookId: string, trigger?: HTMLElement): void {
|
||||
const item = hookById.get(hookId);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metaParts = item.hooks.map(
|
||||
(hookName) => `<span class="resource-tag tag-hook">${escapeHtml(hookName)}</span>`
|
||||
);
|
||||
|
||||
if (item.assets.length > 0) {
|
||||
metaParts.push(
|
||||
`<span class="resource-tag tag-assets">${item.assets.length} asset${
|
||||
item.assets.length === 1 ? '' : 's'
|
||||
}</span>`
|
||||
);
|
||||
}
|
||||
|
||||
if (item.lastUpdated) {
|
||||
metaParts.push(
|
||||
`<span class="last-updated">Updated ${escapeHtml(
|
||||
formatRelativeTime(item.lastUpdated)
|
||||
)}</span>`
|
||||
);
|
||||
}
|
||||
|
||||
const tagHtml = item.tags
|
||||
.map((tagText) => `<span class="resource-tag tag-tag">${escapeHtml(tagText)}</span>`)
|
||||
.join('');
|
||||
|
||||
const actionsHtml = `
|
||||
<button id="hook-details-download" class="btn btn-primary" type="button" data-hook-id="${escapeHtml(
|
||||
item.id
|
||||
)}">Download</button>
|
||||
<button class="btn btn-secondary" type="button" data-open-file-path="${escapeHtml(
|
||||
item.readmeFile
|
||||
)}" data-open-file-type="hook">Source</button>
|
||||
`;
|
||||
|
||||
openCardDetailsModal({
|
||||
title: item.title,
|
||||
description: item.description || 'No description',
|
||||
previewIcon: '🪝',
|
||||
previewText: 'Hook events and download options',
|
||||
metaHtml: metaParts.join(''),
|
||||
tagsHtml: tagHtml,
|
||||
actionsHtml,
|
||||
trigger,
|
||||
});
|
||||
}
|
||||
|
||||
function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
if (!list || resourceListHandlersReady) return;
|
||||
|
||||
@@ -169,32 +113,12 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
const target = event.target as HTMLElement;
|
||||
const downloadButton = target.closest('.download-hook-btn') as HTMLButtonElement | null;
|
||||
if (downloadButton) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const hookId = downloadButton.dataset.hookId;
|
||||
if (hookId) downloadHook(hookId, downloadButton);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.closest('.resource-actions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = target.closest('.resource-item') as HTMLElement | null;
|
||||
const button = item?.querySelector('.resource-preview') as HTMLElement | undefined;
|
||||
const hookId = item?.dataset.hookId;
|
||||
if (hookId) {
|
||||
openHookDetailsModal(hookId, button);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const modalDownloadButton = target.closest(
|
||||
'#hook-details-download'
|
||||
) as HTMLButtonElement | null;
|
||||
if (!modalDownloadButton) return;
|
||||
const hookId = modalDownloadButton.dataset.hookId;
|
||||
if (hookId) downloadHook(hookId, modalDownloadButton);
|
||||
});
|
||||
|
||||
resourceListHandlersReady = true;
|
||||
@@ -214,11 +138,6 @@ export async function initHooksPage(): Promise<void> {
|
||||
const clearFiltersBtn = document.getElementById('clear-filters');
|
||||
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
|
||||
|
||||
if (!modalReady) {
|
||||
setupModal();
|
||||
modalReady = true;
|
||||
}
|
||||
|
||||
setupResourceListHandlers(list as HTMLElement | null);
|
||||
|
||||
const data = await fetchData<HooksData>('hooks.json');
|
||||
@@ -228,7 +147,6 @@ export async function initHooksPage(): Promise<void> {
|
||||
}
|
||||
|
||||
allItems = data.items;
|
||||
hookById = new Map(allItems.map((item) => [item.id, item]));
|
||||
|
||||
tagSelectEl = document.getElementById('filter-tag') as HTMLSelectElement | null;
|
||||
if (tagSelectEl) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
escapeHtml,
|
||||
getActionButtonsHtml,
|
||||
getGitHubUrl,
|
||||
getInstallDropdownHtml,
|
||||
getLastUpdatedHtml,
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
import { renderEmptyStateHtml, renderSharedCardHtml } from './card-render';
|
||||
|
||||
export interface RenderableInstruction {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
@@ -18,6 +18,13 @@ export interface RenderableInstruction {
|
||||
|
||||
export type InstructionSortOption = 'title' | 'lastUpdated';
|
||||
|
||||
/**
|
||||
* Build the URL of an instruction's dedicated detail page.
|
||||
*/
|
||||
export function getInstructionDetailUrl(id: string): string {
|
||||
return `/instruction/${id}/`;
|
||||
}
|
||||
|
||||
export function sortInstructions<T extends RenderableInstruction>(
|
||||
items: T[],
|
||||
sort: InstructionSortOption
|
||||
@@ -55,7 +62,13 @@ export function renderInstructionsHtml(
|
||||
|
||||
const actionsHtml = `
|
||||
${getInstallDropdownHtml('instructions', item.path, true)}
|
||||
${getActionButtonsHtml(item.path, true)}
|
||||
<button class="btn btn-secondary btn-small action-download" data-path="${escapeHtml(
|
||||
item.path
|
||||
)}" title="Download file">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||||
<path d="M7.47 10.78a.75.75 0 0 0 1.06 0l3.75-3.75a.75.75 0 0 0-1.06-1.06L8.75 8.44V1.75a.75.75 0 0 0-1.5 0v6.69L4.78 5.97a.75.75 0 0 0-1.06 1.06l3.75 3.75ZM3.75 13a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-8.5Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="${getGitHubUrl(item.path)}" class="btn btn-secondary btn-small" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">
|
||||
GitHub
|
||||
</a>
|
||||
@@ -64,6 +77,7 @@ export function renderInstructionsHtml(
|
||||
return renderSharedCardHtml({
|
||||
title: item.title,
|
||||
description: item.description || 'No description',
|
||||
href: getInstructionDetailUrl(item.id),
|
||||
articleAttributes: {
|
||||
'data-path': item.path,
|
||||
},
|
||||
|
||||
@@ -2,17 +2,13 @@
|
||||
* Instructions page functionality
|
||||
*/
|
||||
import {
|
||||
escapeHtml,
|
||||
fetchData,
|
||||
formatRelativeTime,
|
||||
getQueryParam,
|
||||
getQueryParamValues,
|
||||
getVSCodeInstallUrl,
|
||||
setupActionHandlers,
|
||||
setupDropdownCloseHandlers,
|
||||
updateQueryParams,
|
||||
} from '../utils';
|
||||
import { openCardDetailsModal, setupModal } from '../modal';
|
||||
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
|
||||
import {
|
||||
renderInstructionsHtml,
|
||||
@@ -36,12 +32,9 @@ interface InstructionsData {
|
||||
}
|
||||
|
||||
let allItems: Instruction[] = [];
|
||||
let instructionByPath = new Map<string, Instruction>();
|
||||
let extensionSelectEl: HTMLSelectElement | null = null;
|
||||
let currentFilters = { extensions: [] as string[] };
|
||||
let currentSort: InstructionSortOption = 'title';
|
||||
let resourceListHandlersReady = false;
|
||||
let modalReady = false;
|
||||
|
||||
function sortItems(items: Instruction[]): Instruction[] {
|
||||
return sortInstructions(items, currentSort);
|
||||
@@ -80,73 +73,6 @@ function renderItems(items: Instruction[]): void {
|
||||
list.innerHTML = renderInstructionsHtml(items);
|
||||
}
|
||||
|
||||
function openInstructionDetailsModal(path: string, trigger?: HTMLElement): void {
|
||||
const item = instructionByPath.get(path);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metaParts: string[] = [];
|
||||
const applyToText = Array.isArray(item.applyTo) ? item.applyTo.join(', ') : item.applyTo;
|
||||
if (applyToText) {
|
||||
metaParts.push(`<span class="resource-tag">applies to: ${escapeHtml(applyToText)}</span>`);
|
||||
}
|
||||
|
||||
metaParts.push(
|
||||
...(item.extensions || []).map(
|
||||
(extension) => `<span class="resource-tag tag-extension">${escapeHtml(extension)}</span>`
|
||||
)
|
||||
);
|
||||
|
||||
if (item.lastUpdated) {
|
||||
metaParts.push(`<span class="last-updated">Updated ${escapeHtml(formatRelativeTime(item.lastUpdated))}</span>`);
|
||||
}
|
||||
|
||||
const vscodeUrl = getVSCodeInstallUrl('instructions', path, false);
|
||||
const insidersUrl = getVSCodeInstallUrl('instructions', path, true);
|
||||
const actions = [
|
||||
vscodeUrl
|
||||
? `<a class="btn btn-primary btn-small" href="${escapeHtml(vscodeUrl)}" target="_blank" rel="noopener noreferrer">Install (VS Code)</a>`
|
||||
: '',
|
||||
insidersUrl
|
||||
? `<a class="btn btn-secondary btn-small" href="${escapeHtml(insidersUrl)}" target="_blank" rel="noopener noreferrer">Install (Insiders)</a>`
|
||||
: '',
|
||||
`<button class="btn btn-secondary btn-small" type="button" data-open-file-path="${escapeHtml(
|
||||
path
|
||||
)}" data-open-file-type="instruction">Source</button>`,
|
||||
].filter(Boolean);
|
||||
|
||||
openCardDetailsModal({
|
||||
title: item.title,
|
||||
description: item.description || 'No description',
|
||||
previewIcon: '📋',
|
||||
previewText: 'Instruction metadata and install options',
|
||||
metaHtml: metaParts.join(''),
|
||||
actionsHtml: actions.join(''),
|
||||
trigger,
|
||||
});
|
||||
}
|
||||
|
||||
function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
if (!list || resourceListHandlersReady) return;
|
||||
|
||||
list.addEventListener('click', (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('.resource-actions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = target.closest('.resource-item') as HTMLElement | null;
|
||||
const button = item?.querySelector('.resource-preview') as HTMLElement | undefined;
|
||||
const path = item?.dataset.path;
|
||||
if (path) {
|
||||
openInstructionDetailsModal(path, button);
|
||||
}
|
||||
});
|
||||
|
||||
resourceListHandlersReady = true;
|
||||
}
|
||||
|
||||
function syncUrlState(): void {
|
||||
updateQueryParams({
|
||||
q: '',
|
||||
@@ -160,13 +86,6 @@ export async function initInstructionsPage(): Promise<void> {
|
||||
const clearFiltersBtn = document.getElementById('clear-filters');
|
||||
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
|
||||
|
||||
if (!modalReady) {
|
||||
setupModal();
|
||||
modalReady = true;
|
||||
}
|
||||
|
||||
setupResourceListHandlers(list as HTMLElement | null);
|
||||
|
||||
const data = await fetchData<InstructionsData>('instructions.json');
|
||||
if (!data || !data.items) {
|
||||
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
|
||||
@@ -174,7 +93,6 @@ export async function initInstructionsPage(): Promise<void> {
|
||||
}
|
||||
|
||||
allItems = data.items;
|
||||
instructionByPath = new Map(allItems.map((item) => [item.path, item]));
|
||||
|
||||
extensionSelectEl = document.getElementById('filter-extension') as HTMLSelectElement | null;
|
||||
if (extensionSelectEl) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
escapeHtml,
|
||||
getGitHubUrl,
|
||||
sanitizeUrl,
|
||||
} from '../utils';
|
||||
import { externalRepoUrl } from '../../lib/external-source';
|
||||
import { renderEmptyStateHtml, renderSharedCardHtml } from './card-render';
|
||||
|
||||
interface PluginAuthor {
|
||||
@@ -14,9 +14,12 @@ interface PluginSource {
|
||||
source: string;
|
||||
repo?: string;
|
||||
path?: string;
|
||||
ref?: string;
|
||||
sha?: string;
|
||||
}
|
||||
|
||||
export interface RenderablePlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
@@ -30,6 +33,10 @@ export interface RenderablePlugin {
|
||||
source?: PluginSource | null;
|
||||
}
|
||||
|
||||
export function getPluginDetailUrl(id: string): string {
|
||||
return `/plugin/${id}/`;
|
||||
}
|
||||
|
||||
export type PluginSortOption = 'title' | 'lastUpdated';
|
||||
|
||||
export function sortPlugins<T extends RenderablePlugin>(
|
||||
@@ -48,12 +55,7 @@ export function sortPlugins<T extends RenderablePlugin>(
|
||||
}
|
||||
|
||||
function getExternalPluginUrl(plugin: RenderablePlugin): string {
|
||||
if (plugin.source?.source === 'github' && plugin.source.repo) {
|
||||
const base = `https://github.com/${plugin.source.repo}`;
|
||||
return plugin.source.path && plugin.source.path !== '/' ? `${base}/tree/main/${plugin.source.path}` : base;
|
||||
}
|
||||
|
||||
return sanitizeUrl(plugin.repository || plugin.homepage);
|
||||
return externalRepoUrl(plugin.source, [plugin.repository, plugin.homepage]);
|
||||
}
|
||||
|
||||
export function renderPluginsHtml(items: RenderablePlugin[]): string {
|
||||
@@ -88,6 +90,7 @@ export function renderPluginsHtml(items: RenderablePlugin[]): string {
|
||||
return renderSharedCardHtml({
|
||||
title: item.name,
|
||||
description: item.description || 'No description',
|
||||
href: getPluginDetailUrl(item.id),
|
||||
articleClassName: isExternal ? 'resource-item-external' : '',
|
||||
articleAttributes: {
|
||||
'data-path': item.path,
|
||||
|
||||
@@ -2,17 +2,11 @@
|
||||
* Plugins page functionality
|
||||
*/
|
||||
import {
|
||||
copyToClipboard,
|
||||
escapeHtml,
|
||||
fetchData,
|
||||
formatRelativeTime,
|
||||
getGitHubUrl,
|
||||
getQueryParam,
|
||||
getQueryParamValues,
|
||||
showToast,
|
||||
updateQueryParams,
|
||||
} from '../utils';
|
||||
import { openCardDetailsModal, setupModal } from '../modal';
|
||||
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
|
||||
import {
|
||||
renderPluginsHtml,
|
||||
@@ -60,14 +54,11 @@ interface PluginsData {
|
||||
}
|
||||
|
||||
let allItems: Plugin[] = [];
|
||||
let pluginByPath = new Map<string, Plugin>();
|
||||
let tagSelectEl: HTMLSelectElement | null = null;
|
||||
let currentSort: PluginSortOption = 'title';
|
||||
let currentFilters = {
|
||||
tags: [] as string[],
|
||||
};
|
||||
let resourceListHandlersReady = false;
|
||||
let modalReady = false;
|
||||
|
||||
function sortItems(items: Plugin[]): Plugin[] {
|
||||
return sortPlugins(items, currentSort);
|
||||
@@ -102,120 +93,6 @@ function renderItems(items: Plugin[]): void {
|
||||
list.innerHTML = renderPluginsHtml(items);
|
||||
}
|
||||
|
||||
function getPluginRepositoryUrl(item: Plugin): string {
|
||||
if (item.external && item.repository) return item.repository;
|
||||
if (item.homepage) return item.homepage;
|
||||
if (item.repository) return item.repository;
|
||||
return getGitHubUrl(item.path);
|
||||
}
|
||||
|
||||
function getPluginItemLabel(item: PluginItem): string {
|
||||
const normalizedPath = item.path.replace(/^\.\//, '');
|
||||
return `${item.kind}: ${normalizedPath}`;
|
||||
}
|
||||
|
||||
function openPluginDetailsModal(path: string, trigger?: HTMLElement): void {
|
||||
const item = pluginByPath.get(path);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metaParts: string[] = [];
|
||||
metaParts.push(
|
||||
`<span class="resource-tag">${
|
||||
item.external ? 'External plugin' : `${item.itemCount} items`
|
||||
}</span>`
|
||||
);
|
||||
|
||||
if (item.author?.name) {
|
||||
metaParts.push(`<span class="resource-tag">by ${escapeHtml(item.author.name)}</span>`);
|
||||
}
|
||||
|
||||
if (item.lastUpdated) {
|
||||
metaParts.push(
|
||||
`<span class="last-updated">Updated ${escapeHtml(
|
||||
formatRelativeTime(item.lastUpdated)
|
||||
)}</span>`
|
||||
);
|
||||
}
|
||||
|
||||
const tagHtml = (item.tags || [])
|
||||
.map((tagText) => `<span class="resource-tag">${escapeHtml(tagText)}</span>`)
|
||||
.join('');
|
||||
|
||||
const includedItems = item.items || [];
|
||||
const includedItemHtml = includedItems
|
||||
.slice(0, 24)
|
||||
.map(
|
||||
(pluginItem) =>
|
||||
`<span class="resource-tag tag-plugin-item">${escapeHtml(getPluginItemLabel(pluginItem))}</span>`
|
||||
)
|
||||
.join('');
|
||||
const includedMoreHtml =
|
||||
includedItems.length > 24
|
||||
? `<span class="resource-tag">+${includedItems.length - 24} more</span>`
|
||||
: '';
|
||||
|
||||
const actions = [
|
||||
item.external
|
||||
? ''
|
||||
: `<button id="plugin-details-install" class="btn btn-primary" type="button" data-plugin-name="${escapeHtml(
|
||||
item.name
|
||||
)}">Copy Install</button>`,
|
||||
item.external
|
||||
? `<a class="btn btn-secondary" href="${escapeHtml(
|
||||
getPluginRepositoryUrl(item)
|
||||
)}" target="_blank" rel="noopener noreferrer">Repository</a>`
|
||||
: `<button class="btn btn-secondary" type="button" data-open-file-path="${escapeHtml(
|
||||
item.path
|
||||
)}" data-open-file-type="plugin">Source</button>`,
|
||||
].filter(Boolean);
|
||||
|
||||
openCardDetailsModal({
|
||||
title: item.name,
|
||||
description: item.description || 'No description',
|
||||
previewIcon: '🔌',
|
||||
previewText: 'Plugin metadata and install options',
|
||||
metaHtml: metaParts.join(''),
|
||||
tagsHtml: [tagHtml, includedItemHtml, includedMoreHtml].filter(Boolean).join(''),
|
||||
actionsHtml: actions.join(''),
|
||||
trigger,
|
||||
});
|
||||
}
|
||||
|
||||
function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
if (!list || resourceListHandlersReady) return;
|
||||
|
||||
list.addEventListener('click', (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('.resource-actions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = target.closest('.resource-item') as HTMLElement | null;
|
||||
const button = item?.querySelector('.resource-preview') as HTMLElement | undefined;
|
||||
const path = item?.dataset.path;
|
||||
if (path) {
|
||||
openPluginDetailsModal(path, button);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', async (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const installButton = target.closest(
|
||||
'#plugin-details-install'
|
||||
) as HTMLButtonElement | null;
|
||||
if (!installButton) return;
|
||||
const pluginName = installButton.dataset.pluginName || '';
|
||||
if (!pluginName) return;
|
||||
const command = `copilot plugin install ${pluginName}@awesome-copilot`;
|
||||
const success = await copyToClipboard(command);
|
||||
showToast(success ? 'Install command copied!' : 'Failed to copy', success ? 'success' : 'error');
|
||||
});
|
||||
|
||||
resourceListHandlersReady = true;
|
||||
}
|
||||
|
||||
function syncUrlState(): void {
|
||||
updateQueryParams({
|
||||
q: '',
|
||||
@@ -229,13 +106,6 @@ export async function initPluginsPage(): Promise<void> {
|
||||
const clearFiltersBtn = document.getElementById('clear-filters');
|
||||
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
|
||||
|
||||
if (!modalReady) {
|
||||
setupModal();
|
||||
modalReady = true;
|
||||
}
|
||||
|
||||
setupResourceListHandlers(list as HTMLElement | null);
|
||||
|
||||
const data = await fetchData<PluginsData>('plugins.json');
|
||||
if (!data || !data.items) {
|
||||
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
|
||||
@@ -243,7 +113,6 @@ export async function initPluginsPage(): Promise<void> {
|
||||
}
|
||||
|
||||
allItems = data.items;
|
||||
pluginByPath = new Map(allItems.map((item) => [item.path, item]));
|
||||
|
||||
tagSelectEl = document.getElementById('filter-tag') as HTMLSelectElement | null;
|
||||
if (tagSelectEl) {
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Shared client behaviour for resource detail pages (agents, instructions, ...).
|
||||
*
|
||||
* The heavy lifting (metadata, rendered documentation) is done at build time,
|
||||
* so this only wires up the install split-button dropdown plus the Download,
|
||||
* Copy markdown, and Share actions in the sidebar Actions card. Any detail page
|
||||
* that renders a root element with `data-resource-detail` gets this behaviour.
|
||||
*/
|
||||
import { copyToClipboard, downloadFile, showToast } from "../utils";
|
||||
|
||||
function initResourceDetail(): void {
|
||||
const root = document.querySelector<HTMLElement>("[data-resource-detail]");
|
||||
if (!root) return;
|
||||
|
||||
const filePath = root.dataset.path;
|
||||
|
||||
// --- Install split-button dropdown ---
|
||||
const dropdown = root.querySelector<HTMLElement>("[data-install-menu]");
|
||||
const toggle = dropdown?.querySelector<HTMLButtonElement>(
|
||||
"[data-install-toggle]"
|
||||
);
|
||||
const menuItems = dropdown
|
||||
? Array.from(
|
||||
dropdown.querySelectorAll<HTMLAnchorElement>(
|
||||
".install-dropdown-menu a[role='menuitem']"
|
||||
)
|
||||
)
|
||||
: [];
|
||||
|
||||
const closeMenu = (returnFocus = false) => {
|
||||
if (!dropdown) return;
|
||||
dropdown.classList.remove("open");
|
||||
toggle?.setAttribute("aria-expanded", "false");
|
||||
if (returnFocus) {
|
||||
toggle?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const openMenu = () => {
|
||||
if (!dropdown) return;
|
||||
dropdown.classList.add("open");
|
||||
toggle?.setAttribute("aria-expanded", "true");
|
||||
menuItems[0]?.focus();
|
||||
};
|
||||
|
||||
toggle?.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const isOpen = dropdown!.classList.toggle("open");
|
||||
toggle.setAttribute("aria-expanded", String(isOpen));
|
||||
if (isOpen) {
|
||||
menuItems[0]?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
toggle?.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
openMenu();
|
||||
}
|
||||
});
|
||||
|
||||
menuItems.forEach((item, index) => {
|
||||
item.addEventListener("keydown", (e) => {
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
menuItems[(index + 1) % menuItems.length]?.focus();
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
menuItems[
|
||||
(index - 1 + menuItems.length) % menuItems.length
|
||||
]?.focus();
|
||||
break;
|
||||
case "Home":
|
||||
e.preventDefault();
|
||||
menuItems[0]?.focus();
|
||||
break;
|
||||
case "End":
|
||||
e.preventDefault();
|
||||
menuItems[menuItems.length - 1]?.focus();
|
||||
break;
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
closeMenu(true);
|
||||
break;
|
||||
case "Tab":
|
||||
closeMenu();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
item.addEventListener("click", () => {
|
||||
closeMenu();
|
||||
});
|
||||
});
|
||||
|
||||
// Close the menu on outside click / Escape.
|
||||
document.addEventListener("click", (e) => {
|
||||
if (dropdown && !dropdown.contains(e.target as Node)) closeMenu();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && dropdown?.classList.contains("open")) {
|
||||
closeMenu(true);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Download (also available as a menu item) ---
|
||||
root
|
||||
.querySelectorAll<HTMLElement>("[data-action='download']")
|
||||
.forEach((el) => {
|
||||
el.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
closeMenu();
|
||||
if (!filePath) return;
|
||||
const success = await downloadFile(filePath);
|
||||
showToast(
|
||||
success ? "Download started!" : "Download failed",
|
||||
success ? "success" : "error"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Copy raw markdown (embedded at build time) ---
|
||||
const rawMarkdown =
|
||||
root.querySelector<HTMLTextAreaElement>("[data-raw-markdown]")?.value ?? "";
|
||||
root
|
||||
.querySelectorAll<HTMLElement>("[data-action='copy-markdown']")
|
||||
.forEach((el) => {
|
||||
el.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
closeMenu();
|
||||
if (!rawMarkdown) return;
|
||||
const success = await copyToClipboard(rawMarkdown);
|
||||
showToast(
|
||||
success ? "Markdown copied!" : "Failed to copy markdown",
|
||||
success ? "success" : "error"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Copy install command (embedded at build time) ---
|
||||
const installBlock = root.querySelector<HTMLElement>(
|
||||
"[data-install-command]"
|
||||
);
|
||||
root
|
||||
.querySelectorAll<HTMLElement>("[data-action='copy-install']")
|
||||
.forEach((el) => {
|
||||
el.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
closeMenu();
|
||||
const command = installBlock?.dataset.installCommand ?? "";
|
||||
if (!command) return;
|
||||
const success = await copyToClipboard(command);
|
||||
showToast(
|
||||
success ? "Install command copied!" : "Failed to copy command",
|
||||
success ? "success" : "error"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Copy install URL (fallback install target) ---
|
||||
root
|
||||
.querySelectorAll<HTMLElement>("[data-action='copy-install-url']")
|
||||
.forEach((el) => {
|
||||
el.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
closeMenu();
|
||||
const url = el.dataset.installUrl ?? "";
|
||||
if (!url) return;
|
||||
const success = await copyToClipboard(url);
|
||||
showToast(
|
||||
success ? "Install URL copied!" : "Failed to copy URL",
|
||||
success ? "success" : "error"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Share ---
|
||||
const shareBtn = root.querySelector<HTMLButtonElement>(
|
||||
"[data-action='share']"
|
||||
);
|
||||
shareBtn?.addEventListener("click", async () => {
|
||||
const success = await copyToClipboard(window.location.href);
|
||||
showToast(
|
||||
success ? "Link copied!" : "Failed to copy link",
|
||||
success ? "success" : "error"
|
||||
);
|
||||
});
|
||||
|
||||
// --- Copy buttons on documentation code blocks ---
|
||||
enhanceCodeBlocks(root);
|
||||
}
|
||||
|
||||
const COPY_ICON = `<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"/><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/></svg>`;
|
||||
const CHECK_ICON = `<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/></svg>`;
|
||||
|
||||
/**
|
||||
* Adds a "Copy" button to every fenced code block in the rendered
|
||||
* documentation. The markdown is turned into plain `<pre><code>` at build
|
||||
* time (no highlighter), so there is no copy affordance otherwise — a real
|
||||
* pain on instruction/prompt pages that are mostly config and code snippets.
|
||||
*/
|
||||
function enhanceCodeBlocks(root: HTMLElement): void {
|
||||
const blocks = root.querySelectorAll<HTMLPreElement>(".article-content pre");
|
||||
blocks.forEach((pre) => {
|
||||
const code = pre.querySelector("code");
|
||||
// Skip blocks with no code or that were already enhanced.
|
||||
if (!code || pre.parentElement?.classList.contains("code-block")) return;
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "code-block";
|
||||
pre.parentNode?.insertBefore(wrapper, pre);
|
||||
wrapper.appendChild(pre);
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "code-copy-btn";
|
||||
btn.setAttribute("aria-label", "Copy code to clipboard");
|
||||
btn.innerHTML = COPY_ICON;
|
||||
|
||||
let resetTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
btn.addEventListener("click", async () => {
|
||||
const success = await copyToClipboard(code.textContent ?? "");
|
||||
showToast(
|
||||
success ? "Code copied!" : "Failed to copy code",
|
||||
success ? "success" : "error"
|
||||
);
|
||||
if (!success) return;
|
||||
btn.classList.add("copied");
|
||||
btn.innerHTML = CHECK_ICON;
|
||||
window.clearTimeout(resetTimer);
|
||||
resetTimer = setTimeout(() => {
|
||||
btn.classList.remove("copied");
|
||||
btn.innerHTML = COPY_ICON;
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
wrapper.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initResourceDetail, {
|
||||
once: true,
|
||||
});
|
||||
} else {
|
||||
initResourceDetail();
|
||||
}
|
||||
@@ -25,6 +25,13 @@ export interface RenderableSkill {
|
||||
|
||||
export type SkillSortOption = "title" | "lastUpdated";
|
||||
|
||||
/**
|
||||
* Build the URL of a skill's dedicated detail page.
|
||||
*/
|
||||
export function getSkillDetailUrl(id: string): string {
|
||||
return `/skill/${id}/`;
|
||||
}
|
||||
|
||||
export function sortSkills<T extends RenderableSkill>(
|
||||
items: T[],
|
||||
sort: SkillSortOption
|
||||
@@ -88,6 +95,7 @@ export function renderSkillsHtml(items: RenderableSkill[]): string {
|
||||
return renderSharedCardHtml({
|
||||
title: item.title,
|
||||
description: item.description || "No description",
|
||||
href: getSkillDetailUrl(item.id),
|
||||
articleAttributes: {
|
||||
"data-path": item.skillFile,
|
||||
"data-skill-id": item.id,
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
* Skills page functionality
|
||||
*/
|
||||
import {
|
||||
escapeHtml,
|
||||
fetchData,
|
||||
formatRelativeTime,
|
||||
getQueryParam,
|
||||
showToast,
|
||||
downloadZipBundle,
|
||||
@@ -12,7 +10,6 @@ import {
|
||||
copyToClipboard,
|
||||
REPO_IDENTIFIER,
|
||||
} from '../utils';
|
||||
import { openCardDetailsModal, setupModal } from '../modal';
|
||||
import {
|
||||
renderSkillsHtml,
|
||||
sortSkills,
|
||||
@@ -34,10 +31,8 @@ interface SkillsData {
|
||||
}
|
||||
|
||||
let allItems: Skill[] = [];
|
||||
let skillById = new Map<string, Skill>();
|
||||
let currentSort: SkillSortOption = 'title';
|
||||
let resourceListHandlersReady = false;
|
||||
let modalReady = false;
|
||||
|
||||
function applyFiltersAndRender(): void {
|
||||
const countEl = document.getElementById('results-count');
|
||||
@@ -99,66 +94,6 @@ async function downloadSkill(skillId: string, btn: HTMLButtonElement): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
function openSkillDetailsModal(skillId: string, trigger?: HTMLElement): void {
|
||||
const item = skillById.get(skillId);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metaParts: string[] = [];
|
||||
if (item.hasAssets) {
|
||||
metaParts.push(
|
||||
`<span class="resource-tag tag-assets">${item.assetCount} asset${
|
||||
item.assetCount === 1 ? '' : 's'
|
||||
}</span>`
|
||||
);
|
||||
}
|
||||
|
||||
metaParts.push(
|
||||
`<span class="resource-tag">${item.files.length} file${
|
||||
item.files.length === 1 ? '' : 's'
|
||||
}</span>`
|
||||
);
|
||||
|
||||
if (item.lastUpdated) {
|
||||
metaParts.push(
|
||||
`<span class="last-updated">Updated ${escapeHtml(
|
||||
formatRelativeTime(item.lastUpdated)
|
||||
)}</span>`
|
||||
);
|
||||
}
|
||||
|
||||
const fileTagParts = item.files
|
||||
.slice(0, 24)
|
||||
.map((file) => `<span class="resource-tag">${escapeHtml(file.name)}</span>`);
|
||||
if (item.files.length > 24) {
|
||||
fileTagParts.push(`<span class="resource-tag">+${item.files.length - 24} more</span>`);
|
||||
}
|
||||
|
||||
const actionsHtml = `
|
||||
<button id="skill-details-install" class="btn btn-secondary" type="button" data-skill-id="${escapeHtml(
|
||||
item.id
|
||||
)}">Copy Install</button>
|
||||
<button id="skill-details-download" class="btn btn-primary" type="button" data-skill-id="${escapeHtml(
|
||||
item.id
|
||||
)}">Download</button>
|
||||
<button class="btn btn-secondary" type="button" data-open-file-path="${escapeHtml(
|
||||
item.skillFile
|
||||
)}" data-open-file-type="skill">Source</button>
|
||||
`;
|
||||
|
||||
openCardDetailsModal({
|
||||
title: item.title,
|
||||
description: item.description || 'No description',
|
||||
previewIcon: '⚡',
|
||||
previewText: 'Skill files and install options',
|
||||
metaHtml: metaParts.join(''),
|
||||
tagsHtml: fileTagParts.join(''),
|
||||
actionsHtml,
|
||||
trigger,
|
||||
});
|
||||
}
|
||||
|
||||
function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
if (!list || resourceListHandlersReady) return;
|
||||
|
||||
@@ -167,6 +102,7 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
|
||||
const copyInstallButton = target.closest('.copy-install-btn') as HTMLButtonElement | null;
|
||||
if (copyInstallButton) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const skillId = copyInstallButton.dataset.skillId;
|
||||
if (skillId) copyInstallCommand(skillId, copyInstallButton);
|
||||
@@ -175,38 +111,12 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
|
||||
const downloadButton = target.closest('.download-skill-btn') as HTMLButtonElement | null;
|
||||
if (downloadButton) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const skillId = downloadButton.dataset.skillId;
|
||||
if (skillId) downloadSkill(skillId, downloadButton);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.closest('.resource-actions')) return;
|
||||
|
||||
const item = target.closest('.resource-item') as HTMLElement | null;
|
||||
const button = item?.querySelector('.resource-preview') as HTMLElement | undefined;
|
||||
const skillId = item?.dataset.skillId;
|
||||
if (skillId) openSkillDetailsModal(skillId, button);
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const modalInstallButton = target.closest(
|
||||
'#skill-details-install'
|
||||
) as HTMLButtonElement | null;
|
||||
if (modalInstallButton) {
|
||||
const skillId = modalInstallButton.dataset.skillId;
|
||||
if (skillId) copyInstallCommand(skillId, modalInstallButton);
|
||||
return;
|
||||
}
|
||||
|
||||
const modalDownloadButton = target.closest(
|
||||
'#skill-details-download'
|
||||
) as HTMLButtonElement | null;
|
||||
if (modalDownloadButton) {
|
||||
const skillId = modalDownloadButton.dataset.skillId;
|
||||
if (skillId) downloadSkill(skillId, modalDownloadButton);
|
||||
}
|
||||
});
|
||||
|
||||
resourceListHandlersReady = true;
|
||||
@@ -225,11 +135,6 @@ export async function initSkillsPage(): Promise<void> {
|
||||
const list = document.getElementById('resource-list');
|
||||
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
|
||||
|
||||
if (!modalReady) {
|
||||
setupModal();
|
||||
modalReady = true;
|
||||
}
|
||||
|
||||
setupResourceListHandlers(list as HTMLElement | null);
|
||||
|
||||
const data = await fetchData<SkillsData>('skills.json');
|
||||
@@ -239,7 +144,6 @@ export async function initSkillsPage(): Promise<void> {
|
||||
}
|
||||
|
||||
allItems = data.items;
|
||||
skillById = new Map(allItems.map((item) => [item.id, item]));
|
||||
|
||||
const initialSort = getQueryParam('sort');
|
||||
if (initialSort === 'lastUpdated') {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { renderEmptyStateHtml, renderSharedCardHtml } from './card-render';
|
||||
|
||||
export interface RenderableWorkflow {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
@@ -16,6 +17,13 @@ export interface RenderableWorkflow {
|
||||
|
||||
export type WorkflowSortOption = 'title' | 'lastUpdated';
|
||||
|
||||
/**
|
||||
* Build the URL of a workflow's dedicated detail page.
|
||||
*/
|
||||
export function getWorkflowDetailUrl(id: string): string {
|
||||
return `/workflow/${id}/`;
|
||||
}
|
||||
|
||||
export function sortWorkflows<T extends RenderableWorkflow>(
|
||||
items: T[],
|
||||
sort: WorkflowSortOption
|
||||
@@ -55,6 +63,7 @@ export function renderWorkflowsHtml(
|
||||
return renderSharedCardHtml({
|
||||
title: item.title,
|
||||
description: item.description || 'No description',
|
||||
href: getWorkflowDetailUrl(item.id),
|
||||
articleAttributes: {
|
||||
'data-path': item.path,
|
||||
},
|
||||
|
||||
@@ -2,17 +2,12 @@
|
||||
* Workflows page functionality
|
||||
*/
|
||||
import {
|
||||
copyToClipboard,
|
||||
escapeHtml,
|
||||
fetchData,
|
||||
formatRelativeTime,
|
||||
getQueryParam,
|
||||
getQueryParamValues,
|
||||
showToast,
|
||||
setupActionHandlers,
|
||||
updateQueryParams,
|
||||
} from '../utils';
|
||||
import { openCardDetailsModal, setupModal } from '../modal';
|
||||
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
|
||||
import {
|
||||
renderWorkflowsHtml,
|
||||
@@ -36,14 +31,11 @@ interface WorkflowsData {
|
||||
}
|
||||
|
||||
let allItems: Workflow[] = [];
|
||||
let workflowByPath = new Map<string, Workflow>();
|
||||
let triggerSelectEl: HTMLSelectElement | null = null;
|
||||
let currentFilters = {
|
||||
triggers: [] as string[],
|
||||
};
|
||||
let currentSort: WorkflowSortOption = 'title';
|
||||
let resourceListHandlersReady = false;
|
||||
let modalReady = false;
|
||||
|
||||
function sortItems(items: Workflow[]): Workflow[] {
|
||||
return sortWorkflows(items, currentSort);
|
||||
@@ -74,77 +66,6 @@ function renderItems(items: Workflow[]): void {
|
||||
list.innerHTML = renderWorkflowsHtml(items);
|
||||
}
|
||||
|
||||
function openWorkflowDetailsModal(path: string, trigger?: HTMLElement): void {
|
||||
const item = workflowByPath.get(path);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metaParts: string[] = [];
|
||||
if (item.lastUpdated) {
|
||||
metaParts.push(
|
||||
`<span class="last-updated">Updated ${escapeHtml(
|
||||
formatRelativeTime(item.lastUpdated)
|
||||
)}</span>`
|
||||
);
|
||||
}
|
||||
|
||||
const triggerTags = item.triggers
|
||||
.map((triggerName) => `<span class="resource-tag tag-trigger">${escapeHtml(triggerName)}</span>`)
|
||||
.join('');
|
||||
const actionsHtml = `
|
||||
<button id="workflow-details-copy-path" class="btn btn-secondary" type="button" data-workflow-path="${escapeHtml(
|
||||
item.path
|
||||
)}">Copy Path</button>
|
||||
<button class="btn btn-secondary" type="button" data-open-file-path="${escapeHtml(
|
||||
item.path
|
||||
)}" data-open-file-type="workflow">Source</button>
|
||||
`;
|
||||
|
||||
openCardDetailsModal({
|
||||
title: item.title,
|
||||
description: item.description || 'No description',
|
||||
previewIcon: '⚡',
|
||||
previewText: 'Workflow trigger details and source',
|
||||
metaHtml: metaParts.join(''),
|
||||
tagsHtml: triggerTags,
|
||||
actionsHtml,
|
||||
trigger,
|
||||
});
|
||||
}
|
||||
|
||||
function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
if (!list || resourceListHandlersReady) return;
|
||||
|
||||
list.addEventListener('click', (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('.resource-actions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = target.closest('.resource-item') as HTMLElement | null;
|
||||
const button = item?.querySelector('.resource-preview') as HTMLElement | undefined;
|
||||
const path = item?.dataset.path;
|
||||
if (path) {
|
||||
openWorkflowDetailsModal(path, button);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', async (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const copyPathButton = target.closest(
|
||||
'#workflow-details-copy-path'
|
||||
) as HTMLButtonElement | null;
|
||||
if (!copyPathButton) return;
|
||||
const workflowPath = copyPathButton.dataset.workflowPath || '';
|
||||
if (!workflowPath) return;
|
||||
const success = await copyToClipboard(workflowPath);
|
||||
showToast(success ? 'Path copied!' : 'Failed to copy path', success ? 'success' : 'error');
|
||||
});
|
||||
|
||||
resourceListHandlersReady = true;
|
||||
}
|
||||
|
||||
function syncUrlState(): void {
|
||||
updateQueryParams({
|
||||
q: '',
|
||||
@@ -158,13 +79,6 @@ export async function initWorkflowsPage(): Promise<void> {
|
||||
const clearFiltersBtn = document.getElementById('clear-filters');
|
||||
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
|
||||
|
||||
if (!modalReady) {
|
||||
setupModal();
|
||||
modalReady = true;
|
||||
}
|
||||
|
||||
setupResourceListHandlers(list as HTMLElement | null);
|
||||
|
||||
const data = await fetchData<WorkflowsData>('workflows.json');
|
||||
if (!data || !data.items) {
|
||||
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
|
||||
@@ -172,7 +86,6 @@ export async function initWorkflowsPage(): Promise<void> {
|
||||
}
|
||||
|
||||
allItems = data.items;
|
||||
workflowByPath = new Map(allItems.map((item) => [item.path, item]));
|
||||
|
||||
triggerSelectEl = document.getElementById('filter-trigger') as HTMLSelectElement | null;
|
||||
if (triggerSelectEl) {
|
||||
|
||||
@@ -13,6 +13,42 @@ const REPO_GITHUB_URL = "https://github.com/github/awesome-copilot/blob/main";
|
||||
*/
|
||||
export const REPO_IDENTIFIER = "github/awesome-copilot";
|
||||
|
||||
/**
|
||||
* Validate that a value is a safe, repo-relative file path before it is used to
|
||||
* build a raw.githubusercontent.com URL.
|
||||
*
|
||||
* This blocks client-side path traversal (CSPT): without it, a value such as
|
||||
* `../../../attacker/repo/main/evil.md` (typically supplied via the `#file=`
|
||||
* deep-link hash) would resolve outside the awesome-copilot repo prefix once the
|
||||
* URL is normalized by `fetch`, letting an attacker load arbitrary content that
|
||||
* is then rendered into the page.
|
||||
*/
|
||||
export function isSafeRepoFilePath(filePath: unknown): filePath is string {
|
||||
if (typeof filePath !== "string") return false;
|
||||
const path = filePath.trim();
|
||||
if (path === "") return false;
|
||||
// Reject absolute and protocol-relative paths.
|
||||
if (path.startsWith("/") || path.startsWith("\\")) return false;
|
||||
// Reject anything with a URL scheme or Windows drive letter (colons never
|
||||
// appear in legitimate repo file paths).
|
||||
if (path.includes(":")) return false;
|
||||
// Reject backslashes; repo paths only ever use forward slashes.
|
||||
if (path.includes("\\")) return false;
|
||||
// Fail closed on percent-encoding: legitimate repo paths never contain "%",
|
||||
// and encoded dot-segments (e.g. %2e%2e or double-encoded %252e%252e) could be
|
||||
// normalized by the browser URL parser during fetch, reintroducing traversal.
|
||||
if (path.includes("%")) return false;
|
||||
// Reject traversal (".."), current-dir ("."), and empty segments (from "//").
|
||||
if (
|
||||
path
|
||||
.split("/")
|
||||
.some((segment) => segment === "" || segment === "." || segment === "..")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// VS Code install URL configurations
|
||||
const VSCODE_INSTALL_CONFIG: Record<
|
||||
string,
|
||||
@@ -110,7 +146,7 @@ export async function downloadZipBundle(
|
||||
|
||||
return {
|
||||
name: file.name,
|
||||
content: await response.text(),
|
||||
content: await response.arrayBuffer(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -142,7 +178,7 @@ export async function fetchFileContent(
|
||||
filePath: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(`${REPO_BASE_URL}/${filePath}`);
|
||||
const response = await fetch(getRawGitHubUrl(filePath));
|
||||
if (!response.ok) throw new Error(`Failed to fetch ${filePath}`);
|
||||
return await response.text();
|
||||
} catch (error) {
|
||||
@@ -185,6 +221,7 @@ export function getVSCodeInstallUrl(
|
||||
): string | null {
|
||||
const config = VSCODE_INSTALL_CONFIG[type];
|
||||
if (!config) return null;
|
||||
if (!isSafeRepoFilePath(filePath)) return null;
|
||||
|
||||
const rawUrl = `${REPO_BASE_URL}/${filePath}`;
|
||||
const vscodeScheme = insiders ? "vscode-insiders" : "vscode";
|
||||
@@ -206,6 +243,9 @@ export function getGitHubUrl(filePath: string): string {
|
||||
* Get raw GitHub URL for a file (for fetching content)
|
||||
*/
|
||||
export function getRawGitHubUrl(filePath: string): string {
|
||||
if (!isSafeRepoFilePath(filePath)) {
|
||||
throw new Error(`Unsafe repository file path: ${filePath}`);
|
||||
}
|
||||
return `${REPO_BASE_URL}/${filePath}`;
|
||||
}
|
||||
|
||||
@@ -214,7 +254,7 @@ export function getRawGitHubUrl(filePath: string): string {
|
||||
*/
|
||||
export async function downloadFile(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${REPO_BASE_URL}/${filePath}`);
|
||||
const response = await fetch(getRawGitHubUrl(filePath));
|
||||
if (!response.ok) throw new Error("Failed to fetch file");
|
||||
|
||||
const content = await response.text();
|
||||
|
||||
@@ -2792,7 +2792,7 @@ body:has(#main-content) {
|
||||
|
||||
.article-content a {
|
||||
color: var(--color-link);
|
||||
text-decoration: none;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.article-content a:hover {
|
||||
@@ -2800,6 +2800,15 @@ body:has(#main-content) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Beat the global `#main-content a { text-decoration: none }` reset so links
|
||||
inside rendered docs and install notes stay distinguishable without relying
|
||||
on color alone (WCAG 1.4.1 / axe link-in-text-block). */
|
||||
#main-content .article-content a,
|
||||
#main-content .skill-install-note a,
|
||||
#main-content .detail-empty a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.article-content ul,
|
||||
.article-content ol {
|
||||
margin-bottom: 16px;
|
||||
@@ -2836,6 +2845,65 @@ body:has(#main-content) {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.article-content .code-block {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.code-copy-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.code-block:hover .code-copy-btn,
|
||||
.code-block:focus-within .code-copy-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.code-copy-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.code-copy-btn:hover {
|
||||
color: var(--color-link);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.code-copy-btn:focus-visible {
|
||||
opacity: 1;
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.code-copy-btn.copied {
|
||||
color: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.code-copy-btn {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.article-content blockquote {
|
||||
margin: 16px 0;
|
||||
padding: 12px 20px;
|
||||
@@ -3636,3 +3704,498 @@ header .theme-toggle-container {
|
||||
.choices__list[aria-expanded] .choices__item--selectable.is-highlighted {
|
||||
background-color: var(--color-bg-tertiary) !important;
|
||||
}
|
||||
|
||||
|
||||
/* ==========================================================================
|
||||
Resource detail pages (agents, instructions, ...)
|
||||
Shared layout for the dedicated per-item pages.
|
||||
========================================================================== */
|
||||
.resource-detail-page {
|
||||
padding-block: 2rem 3rem;
|
||||
}
|
||||
.resource-detail-page .detail-breadcrumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.resource-detail-page .detail-breadcrumbs a {
|
||||
color: var(--color-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
.resource-detail-page .detail-breadcrumbs a:hover {
|
||||
color: var(--color-link-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.resource-detail-page .detail-header {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.resource-detail-page .detail-header h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: clamp(1.75rem, 4vw, 2.5rem);
|
||||
color: var(--color-text-emphasis);
|
||||
}
|
||||
.resource-detail-page .detail-description {
|
||||
margin: 0;
|
||||
max-width: 60ch;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.resource-detail-page .detail-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 22rem);
|
||||
gap: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
.resource-detail-page .detail-sidebar {
|
||||
position: sticky;
|
||||
top: 1.5rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.resource-detail-page .detail-card {
|
||||
padding: 1.25rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.resource-detail-page .detail-card h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text-emphasis);
|
||||
}
|
||||
.resource-detail-page .detail-actions-card {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.resource-detail-page .detail-actions-card > * {
|
||||
min-width: 0;
|
||||
}
|
||||
.resource-detail-page .detail-actions-card .install-dropdown {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
.resource-detail-page .detail-actions-card .install-btn-main {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.resource-detail-page .detail-actions-card .install-dropdown-menu {
|
||||
left: 0;
|
||||
right: 0;
|
||||
min-width: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.resource-detail-page .detail-actions-secondary {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.resource-detail-page .detail-actions-secondary .btn {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.resource-detail-page .detail-section {
|
||||
min-width: 0;
|
||||
}
|
||||
.resource-detail-page .detail-section h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.35rem;
|
||||
color: var(--color-text-emphasis);
|
||||
}
|
||||
.resource-detail-page .included-items-hint {
|
||||
margin: -0.5rem 0 1.25rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.resource-detail-page .included-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.resource-detail-page .included-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.resource-detail-page .included-group-title {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.resource-detail-page .included-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.resource-detail-page .included-entry {
|
||||
margin: 0;
|
||||
}
|
||||
.resource-detail-page .included-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--color-bg-subtle, transparent);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
}
|
||||
.resource-detail-page .included-link:hover {
|
||||
border-color: var(--color-accent, var(--sl-color-accent));
|
||||
background: var(--sl-color-gray-6, rgba(0, 0, 0, 0.03));
|
||||
}
|
||||
.resource-detail-page .included-kind-badge {
|
||||
flex: none;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.15em 0.5em;
|
||||
border-radius: 999px;
|
||||
background: var(--sl-color-gray-6, rgba(0, 0, 0, 0.06));
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.resource-detail-page .included-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 0.92rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.resource-detail-page .included-external-hint {
|
||||
flex: none;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.resource-detail-page .detail-meta {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.resource-detail-page .detail-meta > div {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.resource-detail-page .detail-meta dt {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.resource-detail-page .detail-meta dd {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.resource-detail-page .detail-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.resource-detail-page .resource-tag-link {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
.resource-detail-page .resource-tag-link:hover,
|
||||
.resource-detail-page .resource-tag-link:focus-visible {
|
||||
color: var(--color-link);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.resource-detail-page .resource-tag-link {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
.resource-detail-page .detail-none {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.resource-detail-page .detail-path {
|
||||
font-size: 0.82rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.resource-detail-page .detail-frontmatter summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-emphasis);
|
||||
}
|
||||
.resource-detail-page .detail-frontmatter pre {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.resource-detail-page .detail-empty {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.resource-detail-page .detail-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.resource-detail-page .detail-sidebar {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Detail install slot (skill/hook/workflow): install command + downloads --- */
|
||||
.detail-actions-card .skill-install {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.detail-actions-card .skill-install-label {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.detail-actions-card .skill-install-command {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.detail-actions-card .skill-install-command code {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
font-size: 0.82rem;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
.detail-actions-card .skill-install-copy {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.35rem;
|
||||
color: var(--color-text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.detail-actions-card .skill-install-copy:hover {
|
||||
color: var(--color-text-emphasis);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.detail-actions-card .skill-download-btn {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.detail-actions-card .skill-install-note {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.detail-actions-card .skill-install-note a {
|
||||
color: var(--color-text-accent);
|
||||
}
|
||||
.detail-actions-card .skill-install-url-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* --- Extension detail preview gallery --- */
|
||||
.extension-gallery .extension-gallery-main {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
.extension-gallery .extension-gallery-main img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.extension-gallery .extension-gallery-thumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.extension-gallery .extension-gallery-thumb {
|
||||
padding: 0;
|
||||
width: 88px;
|
||||
height: 64px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.extension-gallery .extension-gallery-thumb img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.extension-gallery .extension-gallery-thumb:hover {
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
.extension-gallery .extension-gallery-thumb.active {
|
||||
border-color: var(--color-text-accent);
|
||||
box-shadow: 0 0 0 1px var(--color-text-accent);
|
||||
}
|
||||
|
||||
.skill-file-browser {
|
||||
display: block;
|
||||
}
|
||||
.skill-file-view {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
.skill-file-view-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
.skill-file-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.skill-file-picker-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.skill-file-select {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex: 0 1 auto;
|
||||
margin: 0;
|
||||
padding: 0.35rem 2rem 0.35rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%23656d76' stroke-width='1.5'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.5rem center;
|
||||
}
|
||||
.skill-file-select:hover {
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
.skill-file-select:focus-visible {
|
||||
outline: 2px solid var(--color-accent, #6b21c8);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.skill-file-current {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.82rem;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
.skill-file-view-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.skill-file-content {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.skill-file-content .skill-file-image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.skill-file-content.is-code {
|
||||
padding: 0;
|
||||
}
|
||||
.skill-file-content .shiki,
|
||||
.skill-file-content .skill-file-plain {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
:root[data-theme="dark"] .skill-file-content .shiki,
|
||||
:root[data-theme="dark"] .skill-file-content .shiki span {
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
color: var(--shiki-dark) !important;
|
||||
}
|
||||
.skill-file-content.is-code .shiki,
|
||||
.skill-file-content.is-code .skill-file-plain {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
.skill-file-status {
|
||||
padding: 0.85rem 1.25rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.skill-file-view-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.skill-file-picker {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
.skill-file-select {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user