mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-17 19:31:20 +00:00
chore: publish from main
This commit is contained in:
@@ -24,3 +24,4 @@ website/public/llms.txt
|
|||||||
*.sln
|
*.sln
|
||||||
obj/
|
obj/
|
||||||
bin/
|
bin/
|
||||||
|
.impeccable
|
||||||
|
|||||||
+129
-15
@@ -251,6 +251,10 @@ function generateHooksData(gitDates) {
|
|||||||
.replace(/\\/g, "/");
|
.replace(/\\/g, "/");
|
||||||
const readmeRelativePath = `${relativePath}/README.md`;
|
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
|
// Track unique values
|
||||||
(metadata.hooks || []).forEach((h) => allHookTypes.add(h));
|
(metadata.hooks || []).forEach((h) => allHookTypes.add(h));
|
||||||
(metadata.tags || []).forEach((t) => allTags.add(t));
|
(metadata.tags || []).forEach((t) => allTags.add(t));
|
||||||
@@ -262,8 +266,10 @@ function generateHooksData(gitDates) {
|
|||||||
hooks: metadata.hooks || [],
|
hooks: metadata.hooks || [],
|
||||||
tags: metadata.tags || [],
|
tags: metadata.tags || [],
|
||||||
assets: metadata.assets || [],
|
assets: metadata.assets || [],
|
||||||
|
files,
|
||||||
path: relativePath,
|
path: relativePath,
|
||||||
readmeFile: readmeRelativePath,
|
readmeFile: readmeRelativePath,
|
||||||
|
readmeFileName: "README.md",
|
||||||
lastUpdated: gitDates.get(readmeRelativePath) || null,
|
lastUpdated: gitDates.get(readmeRelativePath) || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -465,7 +471,7 @@ function generateSkillsData(gitDates) {
|
|||||||
.replace(/\\/g, "/");
|
.replace(/\\/g, "/");
|
||||||
|
|
||||||
// Get all files in the skill folder recursively
|
// 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
|
// Get last updated from SKILL.md file
|
||||||
const skillFilePath = `${relativePath}/SKILL.md`;
|
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 = [];
|
const files = [];
|
||||||
|
|
||||||
function walkDir(dir, relDir) {
|
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
|
* Generate plugins metadata
|
||||||
*/
|
*/
|
||||||
function generatePluginsData(gitDates) {
|
function generatePluginsData(gitDates, resourceIndex = {}) {
|
||||||
const plugins = [];
|
const plugins = [];
|
||||||
const extensionEntriesByName = new Map();
|
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))
|
? [...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 relPath = `extensions/${extensionDirName}`;
|
||||||
const extensionItem = {
|
const extensionItem = resolvePluginItem(
|
||||||
kind: "extension",
|
{
|
||||||
path: relPath,
|
kind: "extension",
|
||||||
};
|
path: relPath,
|
||||||
|
},
|
||||||
|
resourceIndex
|
||||||
|
);
|
||||||
|
const extReadmePath = path.join(extensionDir, "README.md");
|
||||||
|
const extReadmeFile = fs.existsSync(extReadmePath)
|
||||||
|
? `${relPath}/README.md`
|
||||||
|
: null;
|
||||||
|
|
||||||
extensionEntriesByName.set(pluginName, {
|
extensionEntriesByName.set(pluginName, {
|
||||||
id: pluginName,
|
id: pluginName,
|
||||||
name: pluginName,
|
name: pluginName,
|
||||||
description: pluginDescription,
|
description: pluginDescription,
|
||||||
path: relPath,
|
path: relPath,
|
||||||
|
readmeFile: extReadmeFile,
|
||||||
|
version: normalizeText(extensionPlugin.version, null),
|
||||||
tags: extensionKeywords,
|
tags: extensionKeywords,
|
||||||
itemCount: 1,
|
itemCount: 1,
|
||||||
items: [extensionItem],
|
items: [extensionItem],
|
||||||
@@ -661,16 +755,23 @@ function generatePluginsData(gitDates) {
|
|||||||
...(data.commands || []).map((p) => ({ kind: "prompt", path: p })),
|
...(data.commands || []).map((p) => ({ kind: "prompt", path: p })),
|
||||||
...(data.skills || []).map((p) => ({ kind: "skill", path: p })),
|
...(data.skills || []).map((p) => ({ kind: "skill", path: p })),
|
||||||
...extensionItems,
|
...extensionItems,
|
||||||
];
|
].map((item) => resolvePluginItem(item, resourceIndex));
|
||||||
|
|
||||||
const tags = data.keywords || data.tags || [];
|
const tags = data.keywords || data.tags || [];
|
||||||
const pluginName = data.name || dir.name;
|
const pluginName = data.name || dir.name;
|
||||||
|
|
||||||
|
const readmePath = path.join(pluginDir, "README.md");
|
||||||
|
const readmeFile = fs.existsSync(readmePath)
|
||||||
|
? `${relPath}/README.md`
|
||||||
|
: null;
|
||||||
|
|
||||||
plugins.push({
|
plugins.push({
|
||||||
id: dir.name,
|
id: dir.name,
|
||||||
name: pluginName,
|
name: pluginName,
|
||||||
description: data.description || "",
|
description: data.description || "",
|
||||||
path: relPath,
|
path: relPath,
|
||||||
|
readmeFile,
|
||||||
|
version: normalizeText(data.version, null),
|
||||||
tags: tags,
|
tags: tags,
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
items: items,
|
items: items,
|
||||||
@@ -720,6 +821,7 @@ function generatePluginsData(gitDates) {
|
|||||||
name: ext.name,
|
name: ext.name,
|
||||||
description: ext.description || "",
|
description: ext.description || "",
|
||||||
path: `plugins/${ext.name}`,
|
path: `plugins/${ext.name}`,
|
||||||
|
version: normalizeText(ext.version, null),
|
||||||
tags: tags,
|
tags: tags,
|
||||||
itemCount: 0,
|
itemCount: 0,
|
||||||
items: [],
|
items: [],
|
||||||
@@ -1196,6 +1298,9 @@ function generateCanvasManifest(gitDates, commitSha) {
|
|||||||
);
|
);
|
||||||
const extensionName = normalizeText(pluginJson.name, normalizeText(packageJson.name, dir.name));
|
const extensionName = normalizeText(pluginJson.name, normalizeText(packageJson.name, dir.name));
|
||||||
const extensionVersion = normalizeText(pluginJson.version, normalizeText(packageJson.version, "1.0.0"));
|
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 screenshots = resolveExtensionScreenshots(pluginJson, extensionDir, relPath, commitSha);
|
||||||
const canvasFiles = getExtensionCanvasFiles(extensionDir);
|
const canvasFiles = getExtensionCanvasFiles(extensionDir);
|
||||||
const canvases = [];
|
const canvases = [];
|
||||||
@@ -1224,6 +1329,7 @@ function generateCanvasManifest(gitDates, commitSha) {
|
|||||||
pluginName: extensionName,
|
pluginName: extensionName,
|
||||||
name: canvasName,
|
name: canvasName,
|
||||||
version: extensionVersion,
|
version: extensionVersion,
|
||||||
|
readmeFile,
|
||||||
description: canvasDescription,
|
description: canvasDescription,
|
||||||
path: relPath,
|
path: relPath,
|
||||||
ref: commitSha,
|
ref: commitSha,
|
||||||
@@ -1299,6 +1405,7 @@ function generateCanvasManifest(gitDates, commitSha) {
|
|||||||
pluginName: null,
|
pluginName: null,
|
||||||
name,
|
name,
|
||||||
version: normalizeText(ext?.version, "1.0.0"),
|
version: normalizeText(ext?.version, "1.0.0"),
|
||||||
|
readmeFile: null,
|
||||||
description: normalizeText(ext?.description, "External canvas extension"),
|
description: normalizeText(ext?.description, "External canvas extension"),
|
||||||
path: null,
|
path: null,
|
||||||
ref: null,
|
ref: null,
|
||||||
@@ -1686,12 +1793,6 @@ async function main() {
|
|||||||
const skills = skillsData.items;
|
const skills = skillsData.items;
|
||||||
console.log(`✓ Generated ${skills.length} skills`);
|
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 extensionManifestData = generateCanvasManifest(gitDates, commitSha);
|
||||||
const extensionsData = generateExtensionsData(extensionManifestData);
|
const extensionsData = generateExtensionsData(extensionManifestData);
|
||||||
const extensions = extensionsData.items;
|
const extensions = extensionsData.items;
|
||||||
@@ -1699,6 +1800,19 @@ async function main() {
|
|||||||
`✓ Generated ${extensions.length} extensions (${extensionsData.filters.keywords.length} keywords)`
|
`✓ 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 toolsData = generateToolsData();
|
||||||
const tools = toolsData.items;
|
const tools = toolsData.items;
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
Generated
+549
@@ -15,6 +15,7 @@
|
|||||||
"choices.js": "^11.2.2",
|
"choices.js": "^11.2.2",
|
||||||
"front-matter": "^4.0.2",
|
"front-matter": "^4.0.2",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
|
"isomorphic-dompurify": "^2.36.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"marked": "^18.0.2",
|
"marked": "^18.0.2",
|
||||||
"shiki": "^4.0.2"
|
"shiki": "^4.0.2"
|
||||||
@@ -24,6 +25,56 @@
|
|||||||
"playwright": "^1.49.0"
|
"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": {
|
"node_modules/@astrojs/compiler-binding": {
|
||||||
"version": "0.2.3",
|
"version": "0.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.2.3.tgz",
|
||||||
@@ -415,6 +466,18 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@bruits/satteri-darwin-arm64": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.9.4.tgz",
|
"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": ">= 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": {
|
"node_modules/@ctrl/tinycolor": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
|
||||||
@@ -1024,6 +1221,23 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@expressive-code/core": {
|
||||||
"version": "0.44.0",
|
"version": "0.44.0",
|
||||||
"resolved": "https://registry.npmjs.org/@expressive-code/core/-/core-0.44.0.tgz",
|
"resolved": "https://registry.npmjs.org/@expressive-code/core/-/core-0.44.0.tgz",
|
||||||
@@ -2181,6 +2395,13 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/unist": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
"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"
|
"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": {
|
"node_modules/am-i-vibing": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.4.0.tgz",
|
"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"
|
"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": {
|
"node_modules/boolbase": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||||
@@ -2723,6 +2962,34 @@
|
|||||||
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
|
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
|
||||||
"license": "CC0-1.0"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"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": {
|
"node_modules/decode-named-character-reference": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
"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"
|
"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": {
|
"node_modules/domutils": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
@@ -3712,6 +3994,18 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/html-escaper": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
|
||||||
@@ -3744,6 +4038,32 @@
|
|||||||
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
|
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
|
||||||
"license": "BSD-2-Clause"
|
"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": {
|
"node_modules/i18next": {
|
||||||
"version": "26.3.3",
|
"version": "26.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.3.tgz",
|
||||||
@@ -3912,6 +4232,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/is-wsl": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
|
||||||
@@ -3933,6 +4259,19 @@
|
|||||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||||
@@ -3955,6 +4294,70 @@
|
|||||||
"js-yaml": "bin/js-yaml.js"
|
"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": {
|
"node_modules/jsonc-parser": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
|
||||||
@@ -5821,6 +6224,15 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/radix3": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz",
|
||||||
@@ -6156,6 +6568,15 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/resolve-pkg-maps": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
@@ -6296,6 +6717,18 @@
|
|||||||
"node": ">=11.0.0"
|
"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": {
|
"node_modules/section-matter": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
||||||
@@ -6543,6 +6976,12 @@
|
|||||||
"url": "https://opencollective.com/svgo"
|
"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": {
|
"node_modules/tiny-inflate": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||||
@@ -6583,6 +7022,48 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"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": {
|
"node_modules/trim-lines": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||||
@@ -6628,6 +7109,15 @@
|
|||||||
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
|
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
"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": {
|
"node_modules/web-namespaces": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
||||||
@@ -7062,6 +7564,38 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/which-pm-runs": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz",
|
||||||
@@ -7071,6 +7605,21 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/xxhash-wasm": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"choices.js": "^11.2.2",
|
"choices.js": "^11.2.2",
|
||||||
"front-matter": "^4.0.2",
|
"front-matter": "^4.0.2",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
|
"isomorphic-dompurify": "^2.36.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"marked": "^18.0.2",
|
"marked": "^18.0.2",
|
||||||
"shiki": "^4.0.2"
|
"shiki": "^4.0.2"
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ const routes = [
|
|||||||
'/contributors/',
|
'/contributors/',
|
||||||
'/learning-hub/cookbook/',
|
'/learning-hub/cookbook/',
|
||||||
'/learning-hub/github-copilot-app/',
|
'/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'];
|
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/",
|
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 {
|
export default function pagefindResources(): AstroIntegration {
|
||||||
let siteBase = "/";
|
let siteBase = "/";
|
||||||
|
|
||||||
@@ -97,12 +110,17 @@ export default function pagefindResources(): AstroIntegration {
|
|||||||
|
|
||||||
let added = 0;
|
let added = 0;
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
|
const hasDetailPage =
|
||||||
|
DETAIL_ROUTE_TYPES.has(record.type) && Boolean(record.id);
|
||||||
const typePage = TYPE_PAGES[record.type];
|
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(
|
const url = hasDetailPage
|
||||||
record.path
|
? `${base}${record.type}/${encodeURIComponent(record.id)}/`
|
||||||
)}`;
|
: `${base}${typePage.slice(1)}#file=${encodeURIComponent(
|
||||||
|
record.path
|
||||||
|
)}`;
|
||||||
const typeLabel = TYPE_LABELS[record.type] || record.type;
|
const typeLabel = TYPE_LABELS[record.type] || record.type;
|
||||||
|
|
||||||
const addResult = await index.addCustomRecord({
|
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 EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||||
import PageHeader from '../components/PageHeader.astro';
|
import PageHeader from '../components/PageHeader.astro';
|
||||||
import BackToTop from '../components/BackToTop.astro';
|
import BackToTop from '../components/BackToTop.astro';
|
||||||
import Modal from '../components/Modal.astro';
|
|
||||||
import { renderAgentsHtml, sortAgents } from '../scripts/pages/agents-render';
|
import { renderAgentsHtml, sortAgents } from '../scripts/pages/agents-render';
|
||||||
|
|
||||||
const initialItems = sortAgents(agentsData.items, 'title');
|
const initialItems = sortAgents(agentsData.items, 'title');
|
||||||
@@ -43,7 +42,6 @@ const initialItems = sortAgents(agentsData.items, 'title');
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BackToTop />
|
<BackToTop />
|
||||||
<Modal />
|
|
||||||
<EmbeddedPageData filename="agents.json" data={agentsData} />
|
<EmbeddedPageData filename="agents.json" data={agentsData} />
|
||||||
|
|
||||||
<script>
|
<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 EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||||
import PageHeader from '../components/PageHeader.astro';
|
import PageHeader from '../components/PageHeader.astro';
|
||||||
import BackToTop from '../components/BackToTop.astro';
|
import BackToTop from '../components/BackToTop.astro';
|
||||||
import Modal from '../components/Modal.astro';
|
|
||||||
import { renderExtensionsHtml, sortExtensions } from '../scripts/pages/extensions-render';
|
import { renderExtensionsHtml, sortExtensions } from '../scripts/pages/extensions-render';
|
||||||
|
|
||||||
const initialItems = sortExtensions(extensionsData.items, 'title');
|
const initialItems = sortExtensions(extensionsData.items, 'title');
|
||||||
@@ -53,7 +52,6 @@ const initialItems = sortExtensions(extensionsData.items, 'title');
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BackToTop />
|
<BackToTop />
|
||||||
<Modal />
|
|
||||||
<EmbeddedPageData filename="extensions.json" data={extensionsData} />
|
<EmbeddedPageData filename="extensions.json" data={extensionsData} />
|
||||||
|
|
||||||
<script>
|
<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 PageHeader from '../components/PageHeader.astro';
|
||||||
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||||
import BackToTop from '../components/BackToTop.astro';
|
import BackToTop from '../components/BackToTop.astro';
|
||||||
import Modal from '../components/Modal.astro';
|
|
||||||
import hooksData from '../../public/data/hooks.json';
|
import hooksData from '../../public/data/hooks.json';
|
||||||
import { renderHooksHtml, sortHooks } from '../scripts/pages/hooks-render';
|
import { renderHooksHtml, sortHooks } from '../scripts/pages/hooks-render';
|
||||||
|
|
||||||
@@ -48,7 +47,6 @@ const initialItems = sortHooks(hooksData.items, 'title');
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BackToTop />
|
<BackToTop />
|
||||||
<Modal />
|
|
||||||
|
|
||||||
<EmbeddedPageData filename="hooks.json" data={hooksData} />
|
<EmbeddedPageData filename="hooks.json" data={hooksData} />
|
||||||
<script>
|
<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 EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||||
import PageHeader from '../components/PageHeader.astro';
|
import PageHeader from '../components/PageHeader.astro';
|
||||||
import BackToTop from '../components/BackToTop.astro';
|
import BackToTop from '../components/BackToTop.astro';
|
||||||
import Modal from '../components/Modal.astro';
|
|
||||||
import { renderInstructionsHtml, sortInstructions } from '../scripts/pages/instructions-render';
|
import { renderInstructionsHtml, sortInstructions } from '../scripts/pages/instructions-render';
|
||||||
|
|
||||||
const initialItems = sortInstructions(instructionsData.items, 'title');
|
const initialItems = sortInstructions(instructionsData.items, 'title');
|
||||||
@@ -48,7 +47,6 @@ const initialItems = sortInstructions(instructionsData.items, 'title');
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BackToTop />
|
<BackToTop />
|
||||||
<Modal />
|
|
||||||
<EmbeddedPageData filename="instructions.json" data={instructionsData} />
|
<EmbeddedPageData filename="instructions.json" data={instructionsData} />
|
||||||
|
|
||||||
<script>
|
<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 EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||||
import PageHeader from '../components/PageHeader.astro';
|
import PageHeader from '../components/PageHeader.astro';
|
||||||
import BackToTop from '../components/BackToTop.astro';
|
import BackToTop from '../components/BackToTop.astro';
|
||||||
import Modal from '../components/Modal.astro';
|
|
||||||
import { renderPluginsHtml, sortPlugins } from '../scripts/pages/plugins-render';
|
import { renderPluginsHtml, sortPlugins } from '../scripts/pages/plugins-render';
|
||||||
|
|
||||||
const initialItems = sortPlugins(pluginsData.items, 'title');
|
const initialItems = sortPlugins(pluginsData.items, 'title');
|
||||||
@@ -57,7 +56,6 @@ const initialItems = sortPlugins(pluginsData.items, 'title');
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BackToTop />
|
<BackToTop />
|
||||||
<Modal />
|
|
||||||
<EmbeddedPageData filename="plugins.json" data={pluginsData} />
|
<EmbeddedPageData filename="plugins.json" data={pluginsData} />
|
||||||
|
|
||||||
<script>
|
<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 PageHeader from '../components/PageHeader.astro';
|
||||||
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||||
import BackToTop from '../components/BackToTop.astro';
|
import BackToTop from '../components/BackToTop.astro';
|
||||||
import Modal from '../components/Modal.astro';
|
|
||||||
import skillsData from '../../public/data/skills.json';
|
import skillsData from '../../public/data/skills.json';
|
||||||
import { renderSkillsHtml, sortSkills } from '../scripts/pages/skills-render';
|
import { renderSkillsHtml, sortSkills } from '../scripts/pages/skills-render';
|
||||||
|
|
||||||
@@ -43,7 +42,6 @@ const initialItems = sortSkills(skillsData.items, 'title');
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BackToTop />
|
<BackToTop />
|
||||||
<Modal />
|
|
||||||
|
|
||||||
<EmbeddedPageData filename="skills.json" data={skillsData} />
|
<EmbeddedPageData filename="skills.json" data={skillsData} />
|
||||||
<script>
|
<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 EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||||
import PageHeader from '../components/PageHeader.astro';
|
import PageHeader from '../components/PageHeader.astro';
|
||||||
import BackToTop from '../components/BackToTop.astro';
|
import BackToTop from '../components/BackToTop.astro';
|
||||||
import Modal from '../components/Modal.astro';
|
|
||||||
import { renderWorkflowsHtml, sortWorkflows } from '../scripts/pages/workflows-render';
|
import { renderWorkflowsHtml, sortWorkflows } from '../scripts/pages/workflows-render';
|
||||||
|
|
||||||
const initialItems = sortWorkflows(workflowsData.items, 'title');
|
const initialItems = sortWorkflows(workflowsData.items, 'title');
|
||||||
@@ -48,7 +47,6 @@ const initialItems = sortWorkflows(workflowsData.items, 'title');
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BackToTop />
|
<BackToTop />
|
||||||
<Modal />
|
|
||||||
<EmbeddedPageData filename="workflows.json" data={workflowsData} />
|
<EmbeddedPageData filename="workflows.json" data={workflowsData} />
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ import {
|
|||||||
escapeHtml,
|
escapeHtml,
|
||||||
getResourceIconSvg,
|
getResourceIconSvg,
|
||||||
sanitizeUrl,
|
sanitizeUrl,
|
||||||
|
isSafeRepoFilePath,
|
||||||
REPO_IDENTIFIER,
|
REPO_IDENTIFIER,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
import { externalRepoUrl } from "../lib/external-source";
|
||||||
|
|
||||||
type ModalViewMode = "rendered" | "raw";
|
type ModalViewMode = "rendered" | "raw";
|
||||||
|
|
||||||
@@ -431,6 +433,8 @@ interface PluginSource {
|
|||||||
source: string;
|
source: string;
|
||||||
repo?: string;
|
repo?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
|
ref?: string;
|
||||||
|
sha?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Plugin {
|
interface Plugin {
|
||||||
@@ -770,8 +774,19 @@ function handleHashChange(): void {
|
|||||||
const hash = window.location.hash;
|
const hash = window.location.hash;
|
||||||
|
|
||||||
if (hash && hash.startsWith("#file=")) {
|
if (hash && hash.startsWith("#file=")) {
|
||||||
const filePath = decodeURIComponent(hash.slice(6));
|
let filePath: string | null = null;
|
||||||
if (filePath && filePath !== currentFilePath) {
|
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);
|
const type = getResourceType(filePath);
|
||||||
openFileModal(filePath, type, false); // Don't update hash since we're responding to it
|
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
|
* Get the best URL for an external plugin, preferring the deep path within the repo
|
||||||
*/
|
*/
|
||||||
function getExternalPluginUrl(plugin: Plugin): string {
|
function getExternalPluginUrl(plugin: Plugin): string {
|
||||||
if (plugin.source?.source === "github" && plugin.source.repo) {
|
// Sanitize URLs from JSON to prevent XSS via javascript:/data: schemes and
|
||||||
const base = `https://github.com/${plugin.source.repo}`;
|
// pin GitHub links to the source's ref/sha when available.
|
||||||
return plugin.source.path && plugin.source.path !== "/"
|
return externalRepoUrl(plugin.source, [plugin.repository, plugin.homepage]);
|
||||||
? `${base}/tree/main/${plugin.source.path}`
|
|
||||||
: base;
|
|
||||||
}
|
|
||||||
// Sanitize URLs from JSON to prevent XSS via javascript:/data: schemes
|
|
||||||
return sanitizeUrl(plugin.repository || plugin.homepage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
getActionButtonsHtml,
|
|
||||||
getGitHubUrl,
|
getGitHubUrl,
|
||||||
getInstallDropdownHtml,
|
getInstallDropdownHtml,
|
||||||
getLastUpdatedHtml,
|
getLastUpdatedHtml,
|
||||||
@@ -8,6 +7,7 @@ import {
|
|||||||
import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render";
|
import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render";
|
||||||
|
|
||||||
export interface RenderableAgent {
|
export interface RenderableAgent {
|
||||||
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -21,6 +21,13 @@ export type AgentSortOption = "title" | "lastUpdated";
|
|||||||
|
|
||||||
const resourceType = "agent";
|
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>(
|
export function sortAgents<T extends RenderableAgent>(
|
||||||
items: T[],
|
items: T[],
|
||||||
sort: AgentSortOption
|
sort: AgentSortOption
|
||||||
@@ -72,7 +79,13 @@ export function renderAgentsHtml(items: RenderableAgent[]): string {
|
|||||||
|
|
||||||
const actionsHtml = `
|
const actionsHtml = `
|
||||||
${getInstallDropdownHtml(resourceType, item.path, true)}
|
${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">
|
<a href="${getGitHubUrl(item.path)}" class="btn btn-secondary btn-small" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">
|
||||||
GitHub
|
GitHub
|
||||||
</a>
|
</a>
|
||||||
@@ -81,6 +94,7 @@ export function renderAgentsHtml(items: RenderableAgent[]): string {
|
|||||||
return renderSharedCardHtml({
|
return renderSharedCardHtml({
|
||||||
title: item.title,
|
title: item.title,
|
||||||
description: item.description || "No description",
|
description: item.description || "No description",
|
||||||
|
href: getAgentDetailUrl(item.id),
|
||||||
articleAttributes: {
|
articleAttributes: {
|
||||||
"data-path": item.path,
|
"data-path": item.path,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,16 +2,12 @@
|
|||||||
* Agents page functionality
|
* Agents page functionality
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
escapeHtml,
|
|
||||||
fetchData,
|
fetchData,
|
||||||
formatRelativeTime,
|
|
||||||
getQueryParam,
|
getQueryParam,
|
||||||
getVSCodeInstallUrl,
|
|
||||||
setupActionHandlers,
|
setupActionHandlers,
|
||||||
setupDropdownCloseHandlers,
|
setupDropdownCloseHandlers,
|
||||||
updateQueryParams,
|
updateQueryParams,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import { openCardDetailsModal, setupModal } from '../modal';
|
|
||||||
import {
|
import {
|
||||||
renderAgentsHtml,
|
renderAgentsHtml,
|
||||||
sortAgents,
|
sortAgents,
|
||||||
@@ -20,7 +16,6 @@ import {
|
|||||||
} from './agents-render';
|
} from './agents-render';
|
||||||
|
|
||||||
interface Agent extends RenderableAgent {
|
interface Agent extends RenderableAgent {
|
||||||
id?: string;
|
|
||||||
lastUpdated?: string | null;
|
lastUpdated?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,10 +24,7 @@ interface AgentsData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let allItems: Agent[] = [];
|
let allItems: Agent[] = [];
|
||||||
let agentByPath = new Map<string, Agent>();
|
|
||||||
let currentSort: AgentSortOption = 'title';
|
let currentSort: AgentSortOption = 'title';
|
||||||
let resourceListHandlersReady = false;
|
|
||||||
let modalReady = false;
|
|
||||||
|
|
||||||
function applyFiltersAndRender(): void {
|
function applyFiltersAndRender(): void {
|
||||||
const countEl = document.getElementById('results-count');
|
const countEl = document.getElementById('results-count');
|
||||||
@@ -51,90 +43,6 @@ function renderItems(items: Agent[]): void {
|
|||||||
list.innerHTML = renderAgentsHtml(items);
|
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 {
|
function syncUrlState(): void {
|
||||||
updateQueryParams({
|
updateQueryParams({
|
||||||
q: '',
|
q: '',
|
||||||
@@ -149,13 +57,6 @@ export async function initAgentsPage(): Promise<void> {
|
|||||||
const list = document.getElementById('resource-list');
|
const list = document.getElementById('resource-list');
|
||||||
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
|
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');
|
const data = await fetchData<AgentsData>('agents.json');
|
||||||
if (!data || !data.items) {
|
if (!data || !data.items) {
|
||||||
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
|
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;
|
allItems = data.items;
|
||||||
agentByPath = new Map(allItems.map((item) => [item.path, item]));
|
|
||||||
|
|
||||||
const initialSort = getQueryParam('sort');
|
const initialSort = getQueryParam('sort');
|
||||||
if (initialSort === 'lastUpdated') {
|
if (initialSort === 'lastUpdated') {
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ export interface SharedCardRenderItem {
|
|||||||
infoExtraHtml?: string;
|
infoExtraHtml?: string;
|
||||||
metaHtml?: string;
|
metaHtml?: string;
|
||||||
actionsHtml?: 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 {
|
function renderAttributes(attributes?: Record<string, string>): string {
|
||||||
@@ -35,9 +41,7 @@ export function renderSharedCardHtml(item: SharedCardRenderItem): string {
|
|||||||
? `resource-item ${item.articleClassName}`
|
? `resource-item ${item.articleClassName}`
|
||||||
: "resource-item";
|
: "resource-item";
|
||||||
|
|
||||||
return `
|
const previewInner = `
|
||||||
<div class="${articleClass}" role="${escapeHtml(role)}"${item.tabIndex !== undefined ? ` tabindex="${String(item.tabIndex)}"` : ""}${renderAttributes(item.articleAttributes)}>
|
|
||||||
<button type="button" class="resource-preview">
|
|
||||||
${item.previewMediaHtml || ""}
|
${item.previewMediaHtml || ""}
|
||||||
<div class="resource-info">
|
<div class="resource-info">
|
||||||
<div class="resource-title">${escapeHtml(item.title)}</div>
|
<div class="resource-title">${escapeHtml(item.title)}</div>
|
||||||
@@ -46,8 +50,17 @@ export function renderSharedCardHtml(item: SharedCardRenderItem): string {
|
|||||||
<div class="resource-meta">
|
<div class="resource-meta">
|
||||||
${item.metaHtml || ""}
|
${item.metaHtml || ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>`;
|
||||||
</button>
|
|
||||||
|
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>` : ""}
|
${item.actionsHtml ? `<div class="resource-actions">${item.actionsHtml}</div>` : ""}
|
||||||
</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,
|
getGitHubUrl,
|
||||||
getLastUpdatedHtml,
|
getLastUpdatedHtml,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
|
import { sanitizeHttpUrl } from "../../lib/external-source";
|
||||||
import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render";
|
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 {
|
export interface RenderableExtension {
|
||||||
id: string;
|
id: string;
|
||||||
canvasId?: string;
|
canvasId?: string;
|
||||||
@@ -46,6 +54,10 @@ export interface RenderableExtension {
|
|||||||
|
|
||||||
export type ExtensionSortOption = "title" | "lastUpdated";
|
export type ExtensionSortOption = "title" | "lastUpdated";
|
||||||
|
|
||||||
|
export function getExtensionDetailUrl(id: string): string {
|
||||||
|
return `/extension/${id}/`;
|
||||||
|
}
|
||||||
|
|
||||||
export function sortExtensions<T extends RenderableExtension>(
|
export function sortExtensions<T extends RenderableExtension>(
|
||||||
items: T[],
|
items: T[],
|
||||||
sort: ExtensionSortOption
|
sort: ExtensionSortOption
|
||||||
@@ -71,23 +83,30 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string {
|
|||||||
|
|
||||||
return items
|
return items
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const installUrl =
|
const installUrl = safeUrl(
|
||||||
item.installUrl ||
|
item.installUrl ||
|
||||||
(item.path && item.ref
|
(item.path && item.ref
|
||||||
? `https://github.com/github/awesome-copilot/tree/${item.ref}/${item.path.replace(
|
? `https://github.com/github/awesome-copilot/tree/${item.ref}/${item.path.replace(
|
||||||
/\\/g,
|
/\\/g,
|
||||||
"/"
|
"/"
|
||||||
)}`
|
)}`
|
||||||
: "");
|
: "")
|
||||||
const installCommand =
|
);
|
||||||
item.installCommand ||
|
const sourceUrl = safeUrl(
|
||||||
(item.pluginName ? `copilot plugin install ${item.pluginName}@awesome-copilot` : "");
|
item.sourceUrl || (item.path ? getGitHubUrl(item.path) : "")
|
||||||
const sourceUrl =
|
);
|
||||||
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
|
const previewImageUrl = safeUrl(item.imageUrl);
|
||||||
? `<div class="resource-thumbnail-btn" data-extension-id="${escapeHtml(item.id)}" aria-hidden="true">
|
const previewMediaHtml = previewImageUrl
|
||||||
<img class="resource-thumbnail" src="${escapeHtml(item.imageUrl)}" alt="${escapeHtml(item.name)} preview" loading="lazy" />
|
? `<div class="resource-thumbnail-btn" aria-hidden="true">
|
||||||
|
<img class="resource-thumbnail" src="${escapeHtml(previewImageUrl)}" alt="${escapeHtml(item.name)} preview" loading="lazy" />
|
||||||
</div>`
|
</div>`
|
||||||
: `<div class="resource-thumbnail resource-thumbnail-placeholder" aria-hidden="true">Canvas</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 = `
|
const actionsHtml = `
|
||||||
<button
|
${
|
||||||
class="btn btn-primary btn-small copy-install-url-btn"
|
ghappInstallUrl
|
||||||
data-install-command="${escapeHtml(installCommand)}"
|
? `<a
|
||||||
title="Copy plugin install command"
|
class="btn btn-primary btn-small"
|
||||||
${installCommand ? "" : "disabled"}
|
href="${escapeHtml(ghappInstallUrl)}"
|
||||||
|
title="Install in the GitHub Copilot app"
|
||||||
>
|
>
|
||||||
Copy Install
|
Install in Copilot app
|
||||||
</button>
|
</a>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
<button
|
<button
|
||||||
class="btn btn-secondary btn-small copy-install-url-btn"
|
class="btn btn-secondary btn-small copy-install-url-btn"
|
||||||
data-install-url="${escapeHtml(installUrl)}"
|
data-install-url="${escapeHtml(installUrl)}"
|
||||||
@@ -150,6 +172,7 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string {
|
|||||||
return renderSharedCardHtml({
|
return renderSharedCardHtml({
|
||||||
title: item.name,
|
title: item.name,
|
||||||
description: item.description || "Canvas extension",
|
description: item.description || "Canvas extension",
|
||||||
|
href: getExtensionDetailUrl(item.id),
|
||||||
previewMediaHtml,
|
previewMediaHtml,
|
||||||
infoExtraHtml,
|
infoExtraHtml,
|
||||||
metaHtml,
|
metaHtml,
|
||||||
|
|||||||
@@ -8,19 +8,13 @@ import {
|
|||||||
type Choices,
|
type Choices,
|
||||||
} from "../choices";
|
} from "../choices";
|
||||||
import {
|
import {
|
||||||
escapeHtml,
|
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
fetchData,
|
fetchData,
|
||||||
formatRelativeTime,
|
|
||||||
getGitHubHandle,
|
|
||||||
getGitHubUrl,
|
|
||||||
getQueryParam,
|
getQueryParam,
|
||||||
getQueryParamValues,
|
getQueryParamValues,
|
||||||
sanitizeUrl,
|
|
||||||
showToast,
|
showToast,
|
||||||
updateQueryParams,
|
updateQueryParams,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import { openCardDetailsModal, setupModal } from "../modal";
|
|
||||||
import {
|
import {
|
||||||
renderExtensionsHtml,
|
renderExtensionsHtml,
|
||||||
sortExtensions,
|
sortExtensions,
|
||||||
@@ -40,229 +34,13 @@ interface ExtensionsData {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExtensionScreenshot {
|
|
||||||
path?: string | null;
|
|
||||||
type?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let allItems: Extension[] = [];
|
let allItems: Extension[] = [];
|
||||||
let extensionById = new Map<string, Extension>();
|
|
||||||
let currentSort: ExtensionSortOption = "title";
|
let currentSort: ExtensionSortOption = "title";
|
||||||
let keywordSelect: Choices;
|
let keywordSelect: Choices;
|
||||||
let currentFilters = {
|
let currentFilters = {
|
||||||
keywords: [] as string[],
|
keywords: [] as string[],
|
||||||
};
|
};
|
||||||
let actionHandlersReady = false;
|
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[] {
|
function sortItems(items: Extension[]): Extension[] {
|
||||||
return sortExtensions(items, currentSort);
|
return sortExtensions(items, currentSort);
|
||||||
@@ -313,6 +91,7 @@ function setupActionHandlers(list: HTMLElement | null): void {
|
|||||||
|
|
||||||
if (!installButton) return;
|
if (!installButton) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const installCommand = installButton.dataset.installCommand || "";
|
const installCommand = installButton.dataset.installCommand || "";
|
||||||
const installUrl = installButton.dataset.installUrl || "";
|
const installUrl = installButton.dataset.installUrl || "";
|
||||||
@@ -330,104 +109,6 @@ function setupActionHandlers(list: HTMLElement | null): void {
|
|||||||
: "Failed to copy install target",
|
: "Failed to copy install target",
|
||||||
success ? "success" : "error"
|
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;
|
actionHandlersReady = true;
|
||||||
@@ -448,11 +129,6 @@ export async function initExtensionsPage(): Promise<void> {
|
|||||||
"sort-select"
|
"sort-select"
|
||||||
) as HTMLSelectElement;
|
) as HTMLSelectElement;
|
||||||
|
|
||||||
if (!modalReady) {
|
|
||||||
setupModal();
|
|
||||||
modalReady = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
setupActionHandlers(list as HTMLElement | null);
|
setupActionHandlers(list as HTMLElement | null);
|
||||||
|
|
||||||
const data = await fetchData<ExtensionsData>("extensions.json");
|
const data = await fetchData<ExtensionsData>("extensions.json");
|
||||||
@@ -464,7 +140,6 @@ export async function initExtensionsPage(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
allItems = data.items;
|
allItems = data.items;
|
||||||
extensionById = new Map(allItems.map((item) => [item.id, item]));
|
|
||||||
|
|
||||||
const availableKeywords = (
|
const availableKeywords = (
|
||||||
data.filters?.keywords ||
|
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";
|
} from "../utils";
|
||||||
import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render";
|
import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render";
|
||||||
|
|
||||||
|
export interface RenderableHookFile {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RenderableHook {
|
export interface RenderableHook {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -14,11 +19,19 @@ export interface RenderableHook {
|
|||||||
hooks: string[];
|
hooks: string[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
assets: string[];
|
assets: string[];
|
||||||
|
files: RenderableHookFile[];
|
||||||
lastUpdated?: string | null;
|
lastUpdated?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HookSortOption = "title" | "lastUpdated";
|
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>(
|
export function sortHooks<T extends RenderableHook>(
|
||||||
items: T[],
|
items: T[],
|
||||||
sort: HookSortOption
|
sort: HookSortOption
|
||||||
@@ -78,6 +91,7 @@ export function renderHooksHtml(items: RenderableHook[]): string {
|
|||||||
return renderSharedCardHtml({
|
return renderSharedCardHtml({
|
||||||
title: item.title,
|
title: item.title,
|
||||||
description: item.description || "No description",
|
description: item.description || "No description",
|
||||||
|
href: getHookDetailUrl(item.id),
|
||||||
articleAttributes: {
|
articleAttributes: {
|
||||||
"data-path": item.readmeFile,
|
"data-path": item.readmeFile,
|
||||||
"data-hook-id": item.id,
|
"data-hook-id": item.id,
|
||||||
|
|||||||
@@ -2,16 +2,13 @@
|
|||||||
* Hooks page functionality
|
* Hooks page functionality
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
escapeHtml,
|
|
||||||
fetchData,
|
fetchData,
|
||||||
formatRelativeTime,
|
|
||||||
getQueryParam,
|
getQueryParam,
|
||||||
getQueryParamValues,
|
getQueryParamValues,
|
||||||
showToast,
|
showToast,
|
||||||
downloadZipBundle,
|
downloadZipBundle,
|
||||||
updateQueryParams,
|
updateQueryParams,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import { openCardDetailsModal, setupModal } from '../modal';
|
|
||||||
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
|
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
|
||||||
import {
|
import {
|
||||||
renderHooksHtml,
|
renderHooksHtml,
|
||||||
@@ -30,14 +27,12 @@ interface HooksData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let allItems: Hook[] = [];
|
let allItems: Hook[] = [];
|
||||||
let hookById = new Map<string, Hook>();
|
|
||||||
let tagSelectEl: HTMLSelectElement | null = null;
|
let tagSelectEl: HTMLSelectElement | null = null;
|
||||||
let currentFilters = {
|
let currentFilters = {
|
||||||
tags: [] as string[],
|
tags: [] as string[],
|
||||||
};
|
};
|
||||||
let currentSort: HookSortOption = 'title';
|
let currentSort: HookSortOption = 'title';
|
||||||
let resourceListHandlersReady = false;
|
let resourceListHandlersReady = false;
|
||||||
let modalReady = false;
|
|
||||||
|
|
||||||
function sortItems(items: Hook[]): Hook[] {
|
function sortItems(items: Hook[]): Hook[] {
|
||||||
return sortHooks(items, currentSort);
|
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 {
|
function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||||
if (!list || resourceListHandlersReady) return;
|
if (!list || resourceListHandlersReady) return;
|
||||||
|
|
||||||
@@ -169,32 +113,12 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
|
|||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
const downloadButton = target.closest('.download-hook-btn') as HTMLButtonElement | null;
|
const downloadButton = target.closest('.download-hook-btn') as HTMLButtonElement | null;
|
||||||
if (downloadButton) {
|
if (downloadButton) {
|
||||||
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const hookId = downloadButton.dataset.hookId;
|
const hookId = downloadButton.dataset.hookId;
|
||||||
if (hookId) downloadHook(hookId, downloadButton);
|
if (hookId) downloadHook(hookId, downloadButton);
|
||||||
return;
|
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;
|
resourceListHandlersReady = true;
|
||||||
@@ -214,11 +138,6 @@ export async function initHooksPage(): Promise<void> {
|
|||||||
const clearFiltersBtn = document.getElementById('clear-filters');
|
const clearFiltersBtn = document.getElementById('clear-filters');
|
||||||
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
|
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
|
||||||
|
|
||||||
if (!modalReady) {
|
|
||||||
setupModal();
|
|
||||||
modalReady = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
setupResourceListHandlers(list as HTMLElement | null);
|
setupResourceListHandlers(list as HTMLElement | null);
|
||||||
|
|
||||||
const data = await fetchData<HooksData>('hooks.json');
|
const data = await fetchData<HooksData>('hooks.json');
|
||||||
@@ -228,7 +147,6 @@ export async function initHooksPage(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
allItems = data.items;
|
allItems = data.items;
|
||||||
hookById = new Map(allItems.map((item) => [item.id, item]));
|
|
||||||
|
|
||||||
tagSelectEl = document.getElementById('filter-tag') as HTMLSelectElement | null;
|
tagSelectEl = document.getElementById('filter-tag') as HTMLSelectElement | null;
|
||||||
if (tagSelectEl) {
|
if (tagSelectEl) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
getActionButtonsHtml,
|
|
||||||
getGitHubUrl,
|
getGitHubUrl,
|
||||||
getInstallDropdownHtml,
|
getInstallDropdownHtml,
|
||||||
getLastUpdatedHtml,
|
getLastUpdatedHtml,
|
||||||
@@ -8,6 +7,7 @@ import {
|
|||||||
import { renderEmptyStateHtml, renderSharedCardHtml } from './card-render';
|
import { renderEmptyStateHtml, renderSharedCardHtml } from './card-render';
|
||||||
|
|
||||||
export interface RenderableInstruction {
|
export interface RenderableInstruction {
|
||||||
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -18,6 +18,13 @@ export interface RenderableInstruction {
|
|||||||
|
|
||||||
export type InstructionSortOption = 'title' | 'lastUpdated';
|
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>(
|
export function sortInstructions<T extends RenderableInstruction>(
|
||||||
items: T[],
|
items: T[],
|
||||||
sort: InstructionSortOption
|
sort: InstructionSortOption
|
||||||
@@ -55,7 +62,13 @@ export function renderInstructionsHtml(
|
|||||||
|
|
||||||
const actionsHtml = `
|
const actionsHtml = `
|
||||||
${getInstallDropdownHtml('instructions', item.path, true)}
|
${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">
|
<a href="${getGitHubUrl(item.path)}" class="btn btn-secondary btn-small" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">
|
||||||
GitHub
|
GitHub
|
||||||
</a>
|
</a>
|
||||||
@@ -64,6 +77,7 @@ export function renderInstructionsHtml(
|
|||||||
return renderSharedCardHtml({
|
return renderSharedCardHtml({
|
||||||
title: item.title,
|
title: item.title,
|
||||||
description: item.description || 'No description',
|
description: item.description || 'No description',
|
||||||
|
href: getInstructionDetailUrl(item.id),
|
||||||
articleAttributes: {
|
articleAttributes: {
|
||||||
'data-path': item.path,
|
'data-path': item.path,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,17 +2,13 @@
|
|||||||
* Instructions page functionality
|
* Instructions page functionality
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
escapeHtml,
|
|
||||||
fetchData,
|
fetchData,
|
||||||
formatRelativeTime,
|
|
||||||
getQueryParam,
|
getQueryParam,
|
||||||
getQueryParamValues,
|
getQueryParamValues,
|
||||||
getVSCodeInstallUrl,
|
|
||||||
setupActionHandlers,
|
setupActionHandlers,
|
||||||
setupDropdownCloseHandlers,
|
setupDropdownCloseHandlers,
|
||||||
updateQueryParams,
|
updateQueryParams,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import { openCardDetailsModal, setupModal } from '../modal';
|
|
||||||
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
|
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
|
||||||
import {
|
import {
|
||||||
renderInstructionsHtml,
|
renderInstructionsHtml,
|
||||||
@@ -36,12 +32,9 @@ interface InstructionsData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let allItems: Instruction[] = [];
|
let allItems: Instruction[] = [];
|
||||||
let instructionByPath = new Map<string, Instruction>();
|
|
||||||
let extensionSelectEl: HTMLSelectElement | null = null;
|
let extensionSelectEl: HTMLSelectElement | null = null;
|
||||||
let currentFilters = { extensions: [] as string[] };
|
let currentFilters = { extensions: [] as string[] };
|
||||||
let currentSort: InstructionSortOption = 'title';
|
let currentSort: InstructionSortOption = 'title';
|
||||||
let resourceListHandlersReady = false;
|
|
||||||
let modalReady = false;
|
|
||||||
|
|
||||||
function sortItems(items: Instruction[]): Instruction[] {
|
function sortItems(items: Instruction[]): Instruction[] {
|
||||||
return sortInstructions(items, currentSort);
|
return sortInstructions(items, currentSort);
|
||||||
@@ -80,73 +73,6 @@ function renderItems(items: Instruction[]): void {
|
|||||||
list.innerHTML = renderInstructionsHtml(items);
|
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 {
|
function syncUrlState(): void {
|
||||||
updateQueryParams({
|
updateQueryParams({
|
||||||
q: '',
|
q: '',
|
||||||
@@ -160,13 +86,6 @@ export async function initInstructionsPage(): Promise<void> {
|
|||||||
const clearFiltersBtn = document.getElementById('clear-filters');
|
const clearFiltersBtn = document.getElementById('clear-filters');
|
||||||
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
|
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');
|
const data = await fetchData<InstructionsData>('instructions.json');
|
||||||
if (!data || !data.items) {
|
if (!data || !data.items) {
|
||||||
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
|
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;
|
allItems = data.items;
|
||||||
instructionByPath = new Map(allItems.map((item) => [item.path, item]));
|
|
||||||
|
|
||||||
extensionSelectEl = document.getElementById('filter-extension') as HTMLSelectElement | null;
|
extensionSelectEl = document.getElementById('filter-extension') as HTMLSelectElement | null;
|
||||||
if (extensionSelectEl) {
|
if (extensionSelectEl) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
getGitHubUrl,
|
getGitHubUrl,
|
||||||
sanitizeUrl,
|
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
import { externalRepoUrl } from '../../lib/external-source';
|
||||||
import { renderEmptyStateHtml, renderSharedCardHtml } from './card-render';
|
import { renderEmptyStateHtml, renderSharedCardHtml } from './card-render';
|
||||||
|
|
||||||
interface PluginAuthor {
|
interface PluginAuthor {
|
||||||
@@ -14,9 +14,12 @@ interface PluginSource {
|
|||||||
source: string;
|
source: string;
|
||||||
repo?: string;
|
repo?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
|
ref?: string;
|
||||||
|
sha?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RenderablePlugin {
|
export interface RenderablePlugin {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -30,6 +33,10 @@ export interface RenderablePlugin {
|
|||||||
source?: PluginSource | null;
|
source?: PluginSource | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPluginDetailUrl(id: string): string {
|
||||||
|
return `/plugin/${id}/`;
|
||||||
|
}
|
||||||
|
|
||||||
export type PluginSortOption = 'title' | 'lastUpdated';
|
export type PluginSortOption = 'title' | 'lastUpdated';
|
||||||
|
|
||||||
export function sortPlugins<T extends RenderablePlugin>(
|
export function sortPlugins<T extends RenderablePlugin>(
|
||||||
@@ -48,12 +55,7 @@ export function sortPlugins<T extends RenderablePlugin>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getExternalPluginUrl(plugin: RenderablePlugin): string {
|
function getExternalPluginUrl(plugin: RenderablePlugin): string {
|
||||||
if (plugin.source?.source === 'github' && plugin.source.repo) {
|
return externalRepoUrl(plugin.source, [plugin.repository, plugin.homepage]);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderPluginsHtml(items: RenderablePlugin[]): string {
|
export function renderPluginsHtml(items: RenderablePlugin[]): string {
|
||||||
@@ -88,6 +90,7 @@ export function renderPluginsHtml(items: RenderablePlugin[]): string {
|
|||||||
return renderSharedCardHtml({
|
return renderSharedCardHtml({
|
||||||
title: item.name,
|
title: item.name,
|
||||||
description: item.description || 'No description',
|
description: item.description || 'No description',
|
||||||
|
href: getPluginDetailUrl(item.id),
|
||||||
articleClassName: isExternal ? 'resource-item-external' : '',
|
articleClassName: isExternal ? 'resource-item-external' : '',
|
||||||
articleAttributes: {
|
articleAttributes: {
|
||||||
'data-path': item.path,
|
'data-path': item.path,
|
||||||
|
|||||||
@@ -2,17 +2,11 @@
|
|||||||
* Plugins page functionality
|
* Plugins page functionality
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
copyToClipboard,
|
|
||||||
escapeHtml,
|
|
||||||
fetchData,
|
fetchData,
|
||||||
formatRelativeTime,
|
|
||||||
getGitHubUrl,
|
|
||||||
getQueryParam,
|
getQueryParam,
|
||||||
getQueryParamValues,
|
getQueryParamValues,
|
||||||
showToast,
|
|
||||||
updateQueryParams,
|
updateQueryParams,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import { openCardDetailsModal, setupModal } from '../modal';
|
|
||||||
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
|
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
|
||||||
import {
|
import {
|
||||||
renderPluginsHtml,
|
renderPluginsHtml,
|
||||||
@@ -60,14 +54,11 @@ interface PluginsData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let allItems: Plugin[] = [];
|
let allItems: Plugin[] = [];
|
||||||
let pluginByPath = new Map<string, Plugin>();
|
|
||||||
let tagSelectEl: HTMLSelectElement | null = null;
|
let tagSelectEl: HTMLSelectElement | null = null;
|
||||||
let currentSort: PluginSortOption = 'title';
|
let currentSort: PluginSortOption = 'title';
|
||||||
let currentFilters = {
|
let currentFilters = {
|
||||||
tags: [] as string[],
|
tags: [] as string[],
|
||||||
};
|
};
|
||||||
let resourceListHandlersReady = false;
|
|
||||||
let modalReady = false;
|
|
||||||
|
|
||||||
function sortItems(items: Plugin[]): Plugin[] {
|
function sortItems(items: Plugin[]): Plugin[] {
|
||||||
return sortPlugins(items, currentSort);
|
return sortPlugins(items, currentSort);
|
||||||
@@ -102,120 +93,6 @@ function renderItems(items: Plugin[]): void {
|
|||||||
list.innerHTML = renderPluginsHtml(items);
|
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 {
|
function syncUrlState(): void {
|
||||||
updateQueryParams({
|
updateQueryParams({
|
||||||
q: '',
|
q: '',
|
||||||
@@ -229,13 +106,6 @@ export async function initPluginsPage(): Promise<void> {
|
|||||||
const clearFiltersBtn = document.getElementById('clear-filters');
|
const clearFiltersBtn = document.getElementById('clear-filters');
|
||||||
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
|
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');
|
const data = await fetchData<PluginsData>('plugins.json');
|
||||||
if (!data || !data.items) {
|
if (!data || !data.items) {
|
||||||
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
|
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;
|
allItems = data.items;
|
||||||
pluginByPath = new Map(allItems.map((item) => [item.path, item]));
|
|
||||||
|
|
||||||
tagSelectEl = document.getElementById('filter-tag') as HTMLSelectElement | null;
|
tagSelectEl = document.getElementById('filter-tag') as HTMLSelectElement | null;
|
||||||
if (tagSelectEl) {
|
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";
|
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>(
|
export function sortSkills<T extends RenderableSkill>(
|
||||||
items: T[],
|
items: T[],
|
||||||
sort: SkillSortOption
|
sort: SkillSortOption
|
||||||
@@ -88,6 +95,7 @@ export function renderSkillsHtml(items: RenderableSkill[]): string {
|
|||||||
return renderSharedCardHtml({
|
return renderSharedCardHtml({
|
||||||
title: item.title,
|
title: item.title,
|
||||||
description: item.description || "No description",
|
description: item.description || "No description",
|
||||||
|
href: getSkillDetailUrl(item.id),
|
||||||
articleAttributes: {
|
articleAttributes: {
|
||||||
"data-path": item.skillFile,
|
"data-path": item.skillFile,
|
||||||
"data-skill-id": item.id,
|
"data-skill-id": item.id,
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
* Skills page functionality
|
* Skills page functionality
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
escapeHtml,
|
|
||||||
fetchData,
|
fetchData,
|
||||||
formatRelativeTime,
|
|
||||||
getQueryParam,
|
getQueryParam,
|
||||||
showToast,
|
showToast,
|
||||||
downloadZipBundle,
|
downloadZipBundle,
|
||||||
@@ -12,7 +10,6 @@ import {
|
|||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
REPO_IDENTIFIER,
|
REPO_IDENTIFIER,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import { openCardDetailsModal, setupModal } from '../modal';
|
|
||||||
import {
|
import {
|
||||||
renderSkillsHtml,
|
renderSkillsHtml,
|
||||||
sortSkills,
|
sortSkills,
|
||||||
@@ -34,10 +31,8 @@ interface SkillsData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let allItems: Skill[] = [];
|
let allItems: Skill[] = [];
|
||||||
let skillById = new Map<string, Skill>();
|
|
||||||
let currentSort: SkillSortOption = 'title';
|
let currentSort: SkillSortOption = 'title';
|
||||||
let resourceListHandlersReady = false;
|
let resourceListHandlersReady = false;
|
||||||
let modalReady = false;
|
|
||||||
|
|
||||||
function applyFiltersAndRender(): void {
|
function applyFiltersAndRender(): void {
|
||||||
const countEl = document.getElementById('results-count');
|
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 {
|
function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||||
if (!list || resourceListHandlersReady) return;
|
if (!list || resourceListHandlersReady) return;
|
||||||
|
|
||||||
@@ -167,6 +102,7 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
|
|||||||
|
|
||||||
const copyInstallButton = target.closest('.copy-install-btn') as HTMLButtonElement | null;
|
const copyInstallButton = target.closest('.copy-install-btn') as HTMLButtonElement | null;
|
||||||
if (copyInstallButton) {
|
if (copyInstallButton) {
|
||||||
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const skillId = copyInstallButton.dataset.skillId;
|
const skillId = copyInstallButton.dataset.skillId;
|
||||||
if (skillId) copyInstallCommand(skillId, copyInstallButton);
|
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;
|
const downloadButton = target.closest('.download-skill-btn') as HTMLButtonElement | null;
|
||||||
if (downloadButton) {
|
if (downloadButton) {
|
||||||
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const skillId = downloadButton.dataset.skillId;
|
const skillId = downloadButton.dataset.skillId;
|
||||||
if (skillId) downloadSkill(skillId, downloadButton);
|
if (skillId) downloadSkill(skillId, downloadButton);
|
||||||
return;
|
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;
|
resourceListHandlersReady = true;
|
||||||
@@ -225,11 +135,6 @@ export async function initSkillsPage(): Promise<void> {
|
|||||||
const list = document.getElementById('resource-list');
|
const list = document.getElementById('resource-list');
|
||||||
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
|
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
|
||||||
|
|
||||||
if (!modalReady) {
|
|
||||||
setupModal();
|
|
||||||
modalReady = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
setupResourceListHandlers(list as HTMLElement | null);
|
setupResourceListHandlers(list as HTMLElement | null);
|
||||||
|
|
||||||
const data = await fetchData<SkillsData>('skills.json');
|
const data = await fetchData<SkillsData>('skills.json');
|
||||||
@@ -239,7 +144,6 @@ export async function initSkillsPage(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
allItems = data.items;
|
allItems = data.items;
|
||||||
skillById = new Map(allItems.map((item) => [item.id, item]));
|
|
||||||
|
|
||||||
const initialSort = getQueryParam('sort');
|
const initialSort = getQueryParam('sort');
|
||||||
if (initialSort === 'lastUpdated') {
|
if (initialSort === 'lastUpdated') {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { renderEmptyStateHtml, renderSharedCardHtml } from './card-render';
|
import { renderEmptyStateHtml, renderSharedCardHtml } from './card-render';
|
||||||
|
|
||||||
export interface RenderableWorkflow {
|
export interface RenderableWorkflow {
|
||||||
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -16,6 +17,13 @@ export interface RenderableWorkflow {
|
|||||||
|
|
||||||
export type WorkflowSortOption = 'title' | 'lastUpdated';
|
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>(
|
export function sortWorkflows<T extends RenderableWorkflow>(
|
||||||
items: T[],
|
items: T[],
|
||||||
sort: WorkflowSortOption
|
sort: WorkflowSortOption
|
||||||
@@ -55,6 +63,7 @@ export function renderWorkflowsHtml(
|
|||||||
return renderSharedCardHtml({
|
return renderSharedCardHtml({
|
||||||
title: item.title,
|
title: item.title,
|
||||||
description: item.description || 'No description',
|
description: item.description || 'No description',
|
||||||
|
href: getWorkflowDetailUrl(item.id),
|
||||||
articleAttributes: {
|
articleAttributes: {
|
||||||
'data-path': item.path,
|
'data-path': item.path,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,17 +2,12 @@
|
|||||||
* Workflows page functionality
|
* Workflows page functionality
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
copyToClipboard,
|
|
||||||
escapeHtml,
|
|
||||||
fetchData,
|
fetchData,
|
||||||
formatRelativeTime,
|
|
||||||
getQueryParam,
|
getQueryParam,
|
||||||
getQueryParamValues,
|
getQueryParamValues,
|
||||||
showToast,
|
|
||||||
setupActionHandlers,
|
setupActionHandlers,
|
||||||
updateQueryParams,
|
updateQueryParams,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import { openCardDetailsModal, setupModal } from '../modal';
|
|
||||||
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
|
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
|
||||||
import {
|
import {
|
||||||
renderWorkflowsHtml,
|
renderWorkflowsHtml,
|
||||||
@@ -36,14 +31,11 @@ interface WorkflowsData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let allItems: Workflow[] = [];
|
let allItems: Workflow[] = [];
|
||||||
let workflowByPath = new Map<string, Workflow>();
|
|
||||||
let triggerSelectEl: HTMLSelectElement | null = null;
|
let triggerSelectEl: HTMLSelectElement | null = null;
|
||||||
let currentFilters = {
|
let currentFilters = {
|
||||||
triggers: [] as string[],
|
triggers: [] as string[],
|
||||||
};
|
};
|
||||||
let currentSort: WorkflowSortOption = 'title';
|
let currentSort: WorkflowSortOption = 'title';
|
||||||
let resourceListHandlersReady = false;
|
|
||||||
let modalReady = false;
|
|
||||||
|
|
||||||
function sortItems(items: Workflow[]): Workflow[] {
|
function sortItems(items: Workflow[]): Workflow[] {
|
||||||
return sortWorkflows(items, currentSort);
|
return sortWorkflows(items, currentSort);
|
||||||
@@ -74,77 +66,6 @@ function renderItems(items: Workflow[]): void {
|
|||||||
list.innerHTML = renderWorkflowsHtml(items);
|
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 {
|
function syncUrlState(): void {
|
||||||
updateQueryParams({
|
updateQueryParams({
|
||||||
q: '',
|
q: '',
|
||||||
@@ -158,13 +79,6 @@ export async function initWorkflowsPage(): Promise<void> {
|
|||||||
const clearFiltersBtn = document.getElementById('clear-filters');
|
const clearFiltersBtn = document.getElementById('clear-filters');
|
||||||
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
|
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');
|
const data = await fetchData<WorkflowsData>('workflows.json');
|
||||||
if (!data || !data.items) {
|
if (!data || !data.items) {
|
||||||
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
|
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;
|
allItems = data.items;
|
||||||
workflowByPath = new Map(allItems.map((item) => [item.path, item]));
|
|
||||||
|
|
||||||
triggerSelectEl = document.getElementById('filter-trigger') as HTMLSelectElement | null;
|
triggerSelectEl = document.getElementById('filter-trigger') as HTMLSelectElement | null;
|
||||||
if (triggerSelectEl) {
|
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";
|
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
|
// VS Code install URL configurations
|
||||||
const VSCODE_INSTALL_CONFIG: Record<
|
const VSCODE_INSTALL_CONFIG: Record<
|
||||||
string,
|
string,
|
||||||
@@ -110,7 +146,7 @@ export async function downloadZipBundle(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
name: file.name,
|
name: file.name,
|
||||||
content: await response.text(),
|
content: await response.arrayBuffer(),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -142,7 +178,7 @@ export async function fetchFileContent(
|
|||||||
filePath: string
|
filePath: string
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
try {
|
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}`);
|
if (!response.ok) throw new Error(`Failed to fetch ${filePath}`);
|
||||||
return await response.text();
|
return await response.text();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -185,6 +221,7 @@ export function getVSCodeInstallUrl(
|
|||||||
): string | null {
|
): string | null {
|
||||||
const config = VSCODE_INSTALL_CONFIG[type];
|
const config = VSCODE_INSTALL_CONFIG[type];
|
||||||
if (!config) return null;
|
if (!config) return null;
|
||||||
|
if (!isSafeRepoFilePath(filePath)) return null;
|
||||||
|
|
||||||
const rawUrl = `${REPO_BASE_URL}/${filePath}`;
|
const rawUrl = `${REPO_BASE_URL}/${filePath}`;
|
||||||
const vscodeScheme = insiders ? "vscode-insiders" : "vscode";
|
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)
|
* Get raw GitHub URL for a file (for fetching content)
|
||||||
*/
|
*/
|
||||||
export function getRawGitHubUrl(filePath: string): string {
|
export function getRawGitHubUrl(filePath: string): string {
|
||||||
|
if (!isSafeRepoFilePath(filePath)) {
|
||||||
|
throw new Error(`Unsafe repository file path: ${filePath}`);
|
||||||
|
}
|
||||||
return `${REPO_BASE_URL}/${filePath}`;
|
return `${REPO_BASE_URL}/${filePath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +254,7 @@ export function getRawGitHubUrl(filePath: string): string {
|
|||||||
*/
|
*/
|
||||||
export async function downloadFile(filePath: string): Promise<boolean> {
|
export async function downloadFile(filePath: string): Promise<boolean> {
|
||||||
try {
|
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");
|
if (!response.ok) throw new Error("Failed to fetch file");
|
||||||
|
|
||||||
const content = await response.text();
|
const content = await response.text();
|
||||||
|
|||||||
@@ -2792,7 +2792,7 @@ body:has(#main-content) {
|
|||||||
|
|
||||||
.article-content a {
|
.article-content a {
|
||||||
color: var(--color-link);
|
color: var(--color-link);
|
||||||
text-decoration: none;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-content a:hover {
|
.article-content a:hover {
|
||||||
@@ -2800,6 +2800,15 @@ body:has(#main-content) {
|
|||||||
text-decoration: underline;
|
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 ul,
|
||||||
.article-content ol {
|
.article-content ol {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
@@ -2836,6 +2845,65 @@ body:has(#main-content) {
|
|||||||
line-height: 1.6;
|
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 {
|
.article-content blockquote {
|
||||||
margin: 16px 0;
|
margin: 16px 0;
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
@@ -3636,3 +3704,498 @@ header .theme-toggle-container {
|
|||||||
.choices__list[aria-expanded] .choices__item--selectable.is-highlighted {
|
.choices__list[aria-expanded] .choices__item--selectable.is-highlighted {
|
||||||
background-color: var(--color-bg-tertiary) !important;
|
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