mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-17 11:23:27 +00:00
Adding logic to render external plugins with canvases in the canvas gallery (#2323)
* Adding logic to render external plugins with canvases in the canvas gallery * Fix external canvas plugin URL encoding and keyword detection Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: db85480e-d839-4f69-8271-08f8cc845596 * Fail fast on external plugin errors and fix external install links Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: db85480e-d839-4f69-8271-08f8cc845596
This commit is contained in:
@@ -25,12 +25,15 @@ import {
|
|||||||
parseSkillMetadata,
|
parseSkillMetadata,
|
||||||
parseYamlFile,
|
parseYamlFile,
|
||||||
} from "./yaml-parser.mjs";
|
} from "./yaml-parser.mjs";
|
||||||
|
import { readExternalPlugins } from "./external-plugin-validation.mjs";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
const WEBSITE_DIR = path.join(ROOT_FOLDER, "website");
|
const WEBSITE_DIR = path.join(ROOT_FOLDER, "website");
|
||||||
const WEBSITE_DATA_DIR = path.join(WEBSITE_DIR, "public", "data");
|
const WEBSITE_DATA_DIR = path.join(WEBSITE_DIR, "public", "data");
|
||||||
const WEBSITE_SOURCE_DATA_DIR = path.join(WEBSITE_DIR, "data");
|
const WEBSITE_SOURCE_DATA_DIR = path.join(WEBSITE_DIR, "data");
|
||||||
|
const EXTERNAL_CANVAS_KEYWORD = "canvas";
|
||||||
|
const EXTERNAL_CANVAS_PREVIEW_PATH = "assets/preview.png";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure the output directory exists
|
* Ensure the output directory exists
|
||||||
@@ -97,6 +100,23 @@ function normalizeText(value, fallback = "") {
|
|||||||
return typeof value === "string" ? value.trim() : fallback;
|
return typeof value === "string" ? value.trim() : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRepoRelativePath(value) {
|
||||||
|
const normalized = normalizeText(value);
|
||||||
|
if (!normalized || normalized === "/") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinRepoPath(...segments) {
|
||||||
|
return segments
|
||||||
|
.map((segment) => String(segment ?? "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("/")
|
||||||
|
.replace(/\/+/g, "/");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize an author value (npm string form or { name, url } object) to
|
* Normalize an author value (npm string form or { name, url } object) to
|
||||||
* { name, url? } | null. Returns null when no usable name is present.
|
* { name, url? } | null. Returns null when no usable name is present.
|
||||||
@@ -1055,6 +1075,67 @@ function normalizeExternalScreenshotRole(value, ref) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildExternalRepoImageUrl(repo, locator, assetPath) {
|
||||||
|
if (!repo || !locator || !assetPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedLocator = locator
|
||||||
|
.split("/")
|
||||||
|
.map((segment) => encodeURIComponent(segment))
|
||||||
|
.join("/");
|
||||||
|
const encodedPath = assetPath
|
||||||
|
.split("/")
|
||||||
|
.map((segment) => encodeURIComponent(segment))
|
||||||
|
.join("/");
|
||||||
|
return `https://raw.githubusercontent.com/${repo}/${encodedLocator}/${encodedPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExternalRepoTreeUrl(repo, locator, pluginRoot) {
|
||||||
|
if (!repo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locator) {
|
||||||
|
const treePath = normalizeRepoRelativePath(pluginRoot);
|
||||||
|
const encodedLocator = locator
|
||||||
|
.split("/")
|
||||||
|
.map((segment) => encodeURIComponent(segment))
|
||||||
|
.join("/");
|
||||||
|
const encodedTreePath = treePath
|
||||||
|
? treePath
|
||||||
|
.split("/")
|
||||||
|
.map((segment) => encodeURIComponent(segment))
|
||||||
|
.join("/")
|
||||||
|
: null;
|
||||||
|
const suffix = encodedTreePath ? `/${encodedTreePath}` : "";
|
||||||
|
return `https://github.com/${repo}/tree/${encodedLocator}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `https://github.com/${repo}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCanvasKeyword(plugin) {
|
||||||
|
return normalizeExternalKeywords(plugin).some(
|
||||||
|
(keyword) => normalizeText(keyword).toLowerCase() === EXTERNAL_CANVAS_KEYWORD
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExternalKeywords(plugin) {
|
||||||
|
const source = Array.isArray(plugin?.keywords)
|
||||||
|
? plugin.keywords
|
||||||
|
: Array.isArray(plugin?.tags)
|
||||||
|
? plugin.tags
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return [...new Set(
|
||||||
|
source
|
||||||
|
.filter((keyword) => typeof keyword === "string")
|
||||||
|
.map((keyword) => keyword.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)].sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeExtensionScreenshotRole(value, relPath, ref) {
|
function normalizeExtensionScreenshotRole(value, relPath, ref) {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
@@ -1320,6 +1401,93 @@ function generateCanvasManifest(gitDates, commitSha) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seenExtensionIds = new Set(items.map((item) => String(item.id).toLowerCase()));
|
||||||
|
const {
|
||||||
|
plugins: externalPlugins,
|
||||||
|
errors: externalPluginErrors,
|
||||||
|
warnings: externalPluginWarnings,
|
||||||
|
} = readExternalPlugins({ policy: "marketplace" });
|
||||||
|
externalPluginWarnings.forEach((warning) => console.warn(`Warning: ${warning}`));
|
||||||
|
if (externalPluginErrors.length > 0) {
|
||||||
|
externalPluginErrors.forEach((error) => console.error(`Error: ${error}`));
|
||||||
|
throw new Error("External plugin validation failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ext of externalPlugins) {
|
||||||
|
if (!hasCanvasKeyword(ext)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = normalizeText(ext?.name);
|
||||||
|
if (!name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const displayName = formatDisplayName(name);
|
||||||
|
|
||||||
|
const id = normalizeText(ext?.name).toLowerCase().replace(/\s+/g, "-");
|
||||||
|
if (seenExtensionIds.has(id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = ext?.source;
|
||||||
|
if (source?.source !== "github" || !normalizeText(source?.repo)) {
|
||||||
|
console.warn(`Warning: skipping external canvas "${name}" due to missing GitHub source`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const locator = normalizeText(source.sha) || normalizeText(source.ref);
|
||||||
|
if (!locator) {
|
||||||
|
console.warn(`Warning: skipping external canvas "${name}" because source.sha or source.ref is required`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginRoot = normalizeRepoRelativePath(source.path);
|
||||||
|
const previewPath = joinRepoPath(pluginRoot, EXTERNAL_CANVAS_PREVIEW_PATH);
|
||||||
|
const imageUrl = buildExternalRepoImageUrl(source.repo, locator, previewPath);
|
||||||
|
const sourceUrl = buildExternalRepoTreeUrl(source.repo, locator, pluginRoot);
|
||||||
|
const externalSource = normalizeText(source.repo);
|
||||||
|
const keywords = normalizeExternalKeywords(ext);
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id,
|
||||||
|
canvasId: id,
|
||||||
|
extensionId: id,
|
||||||
|
extensionName: name,
|
||||||
|
pluginName: null,
|
||||||
|
name: displayName,
|
||||||
|
version: normalizeText(ext?.version, "1.0.0"),
|
||||||
|
readmeFile: null,
|
||||||
|
description: normalizeText(ext?.description, "External canvas extension"),
|
||||||
|
path: null,
|
||||||
|
ref: null,
|
||||||
|
lastUpdated: null,
|
||||||
|
screenshots: {
|
||||||
|
icon: imageUrl
|
||||||
|
? {
|
||||||
|
path: imageUrl,
|
||||||
|
type: getImageMimeType(EXTERNAL_CANVAS_PREVIEW_PATH),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
gallery: imageUrl
|
||||||
|
? {
|
||||||
|
path: imageUrl,
|
||||||
|
type: getImageMimeType(EXTERNAL_CANVAS_PREVIEW_PATH),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
imageUrl,
|
||||||
|
assetPath: null,
|
||||||
|
installUrl: null,
|
||||||
|
installCommand: null,
|
||||||
|
sourceUrl,
|
||||||
|
externalSource,
|
||||||
|
external: true,
|
||||||
|
author: normalizeAuthor(ext?.author),
|
||||||
|
keywords,
|
||||||
|
});
|
||||||
|
seenExtensionIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
const sortedItems = items.sort((a, b) => a.name.localeCompare(b.name));
|
const sortedItems = items.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
const keywordFilters = [...new Set(sortedItems.flatMap((item) => item.keywords || []))]
|
const keywordFilters = [...new Set(sortedItems.flatMap((item) => item.keywords || []))]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ interface ExtensionEntry extends HeaderItem {
|
|||||||
installUrl?: string | null;
|
installUrl?: string | null;
|
||||||
installCommand?: string | null;
|
installCommand?: string | null;
|
||||||
sourceUrl?: string | null;
|
sourceUrl?: string | null;
|
||||||
|
externalSource?: string | null;
|
||||||
external?: boolean;
|
external?: boolean;
|
||||||
author?: ExtensionAuthor | null;
|
author?: ExtensionAuthor | null;
|
||||||
keywords?: string[];
|
keywords?: string[];
|
||||||
@@ -117,6 +118,7 @@ const installUrl = safeUrl(
|
|||||||
: "")
|
: "")
|
||||||
);
|
);
|
||||||
const sourceUrl = safeUrl(item.sourceUrl);
|
const sourceUrl = safeUrl(item.sourceUrl);
|
||||||
|
const externalSource = (item.externalSource || "").trim();
|
||||||
const installCommand =
|
const installCommand =
|
||||||
item.installCommand ||
|
item.installCommand ||
|
||||||
(item.pluginName
|
(item.pluginName
|
||||||
@@ -124,16 +126,13 @@ const installCommand =
|
|||||||
: "");
|
: "");
|
||||||
|
|
||||||
const githubUrl = isExternal
|
const githubUrl = isExternal
|
||||||
? sourceUrl || installUrl || GITHUB_TREE
|
? sourceUrl || GITHUB_TREE
|
||||||
: item.path
|
: item.path
|
||||||
? `${GITHUB_TREE}/${item.path}`
|
? `${GITHUB_TREE}/${item.path}`
|
||||||
: installUrl || GITHUB_TREE;
|
: installUrl || GITHUB_TREE;
|
||||||
|
|
||||||
// Sidebar "Source" shows item.path; external extensions have no repo path.
|
// Sidebar "Source" shows item.path; external extensions have no repo path.
|
||||||
const sidebarItem =
|
const sidebarItem = isExternal && sourceUrl ? { ...item, path: sourceUrl } : item;
|
||||||
isExternal && (sourceUrl || installUrl)
|
|
||||||
? { ...item, path: sourceUrl || installUrl }
|
|
||||||
: item;
|
|
||||||
|
|
||||||
const shortHash = (value: string) =>
|
const shortHash = (value: string) =>
|
||||||
/^[0-9a-f]{40}$/i.test(value) ? value.slice(0, 7) : value;
|
/^[0-9a-f]{40}$/i.test(value) ? value.slice(0, 7) : value;
|
||||||
@@ -262,22 +261,13 @@ if (!isExternal && item.ref) {
|
|||||||
>
|
>
|
||||||
{
|
{
|
||||||
isExternal ? (
|
isExternal ? (
|
||||||
<div class="skill-install" slot="install">
|
<PluginInstall
|
||||||
<p class="skill-install-label">Install this extension</p>
|
slot="install"
|
||||||
<p class="skill-install-note">
|
isExternal={true}
|
||||||
This extension is maintained in an external repository.
|
externalSource={externalSource || null}
|
||||||
</p>
|
label="Install this extension"
|
||||||
{installUrl && (
|
note="This extension is maintained in an external repository."
|
||||||
<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
|
<PluginInstall
|
||||||
slot="install"
|
slot="install"
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface RenderableExtension {
|
|||||||
installCommand?: string | null;
|
installCommand?: string | null;
|
||||||
installUrl?: string | null;
|
installUrl?: string | null;
|
||||||
sourceUrl?: string | null;
|
sourceUrl?: string | null;
|
||||||
|
externalSource?: string | null;
|
||||||
external?: boolean;
|
external?: boolean;
|
||||||
author?: { name: string; url?: string } | null;
|
author?: { name: string; url?: string } | null;
|
||||||
}
|
}
|
||||||
@@ -95,13 +96,20 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string {
|
|||||||
const sourceUrl = safeUrl(
|
const sourceUrl = safeUrl(
|
||||||
item.sourceUrl || (item.path ? getGitHubUrl(item.path) : "")
|
item.sourceUrl || (item.path ? getGitHubUrl(item.path) : "")
|
||||||
);
|
);
|
||||||
|
const externalSource = (item.externalSource || "").trim();
|
||||||
const pluginId = item.pluginName || item.id;
|
const pluginId = item.pluginName || item.id;
|
||||||
const ghappInstallUrl =
|
const ghappInstallUrl =
|
||||||
!item.external && pluginId
|
item.external
|
||||||
? `ghapp://plugins/install?source=${encodeURIComponent(
|
? externalSource
|
||||||
`${pluginId}@awesome-copilot`
|
? `ghapp://plugins/marketplace/add?source=${encodeURIComponent(
|
||||||
)}`
|
externalSource
|
||||||
: "";
|
)}`
|
||||||
|
: ""
|
||||||
|
: pluginId
|
||||||
|
? `ghapp://plugins/install?source=${encodeURIComponent(
|
||||||
|
`${pluginId}@awesome-copilot`
|
||||||
|
)}`
|
||||||
|
: "";
|
||||||
|
|
||||||
const previewImageUrl = safeUrl(item.imageUrl);
|
const previewImageUrl = safeUrl(item.imageUrl);
|
||||||
const previewMediaHtml = previewImageUrl
|
const previewMediaHtml = previewImageUrl
|
||||||
@@ -152,14 +160,18 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string {
|
|||||||
</a>`
|
</a>`
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
<button
|
${
|
||||||
|
!item.external
|
||||||
|
? `<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)}"
|
||||||
title="Copy fallback URL install target"
|
title="Copy fallback URL install target"
|
||||||
${installUrl ? "" : "disabled"}
|
${installUrl ? "" : "disabled"}
|
||||||
>
|
>
|
||||||
Copy URL
|
Copy URL
|
||||||
</button>
|
</button>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
${
|
${
|
||||||
sourceUrl
|
sourceUrl
|
||||||
? `<a href="${escapeHtml(
|
? `<a href="${escapeHtml(
|
||||||
|
|||||||
Reference in New Issue
Block a user