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:
Aaron Powell
2026-07-17 09:29:01 +10:00
committed by GitHub
parent fb80ec4f21
commit f0da81e14a
3 changed files with 198 additions and 28 deletions
+168
View File
@@ -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)
+11 -21
View File
@@ -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"
+19 -7
View File
@@ -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(