Skip to content

Commit f0da81e

Browse files
authored
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
1 parent fb80ec4 commit f0da81e

3 files changed

Lines changed: 198 additions & 28 deletions

File tree

eng/generate-website-data.mjs

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,15 @@ import {
2525
parseSkillMetadata,
2626
parseYamlFile,
2727
} from "./yaml-parser.mjs";
28+
import { readExternalPlugins } from "./external-plugin-validation.mjs";
2829

2930
const __filename = fileURLToPath(import.meta.url);
3031

3132
const WEBSITE_DIR = path.join(ROOT_FOLDER, "website");
3233
const WEBSITE_DATA_DIR = path.join(WEBSITE_DIR, "public", "data");
3334
const WEBSITE_SOURCE_DATA_DIR = path.join(WEBSITE_DIR, "data");
35+
const EXTERNAL_CANVAS_KEYWORD = "canvas";
36+
const EXTERNAL_CANVAS_PREVIEW_PATH = "assets/preview.png";
3437

3538
/**
3639
* Ensure the output directory exists
@@ -97,6 +100,23 @@ function normalizeText(value, fallback = "") {
97100
return typeof value === "string" ? value.trim() : fallback;
98101
}
99102

103+
function normalizeRepoRelativePath(value) {
104+
const normalized = normalizeText(value);
105+
if (!normalized || normalized === "/") {
106+
return "";
107+
}
108+
109+
return normalized.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
110+
}
111+
112+
function joinRepoPath(...segments) {
113+
return segments
114+
.map((segment) => String(segment ?? "").trim())
115+
.filter(Boolean)
116+
.join("/")
117+
.replace(/\/+/g, "/");
118+
}
119+
100120
/**
101121
* Normalize an author value (npm string form or { name, url } object) to
102122
* { name, url? } | null. Returns null when no usable name is present.
@@ -1055,6 +1075,67 @@ function normalizeExternalScreenshotRole(value, ref) {
10551075
};
10561076
}
10571077

1078+
function buildExternalRepoImageUrl(repo, locator, assetPath) {
1079+
if (!repo || !locator || !assetPath) {
1080+
return null;
1081+
}
1082+
1083+
const encodedLocator = locator
1084+
.split("/")
1085+
.map((segment) => encodeURIComponent(segment))
1086+
.join("/");
1087+
const encodedPath = assetPath
1088+
.split("/")
1089+
.map((segment) => encodeURIComponent(segment))
1090+
.join("/");
1091+
return `https://raw.githubusercontent.com/${repo}/${encodedLocator}/${encodedPath}`;
1092+
}
1093+
1094+
function buildExternalRepoTreeUrl(repo, locator, pluginRoot) {
1095+
if (!repo) {
1096+
return null;
1097+
}
1098+
1099+
if (locator) {
1100+
const treePath = normalizeRepoRelativePath(pluginRoot);
1101+
const encodedLocator = locator
1102+
.split("/")
1103+
.map((segment) => encodeURIComponent(segment))
1104+
.join("/");
1105+
const encodedTreePath = treePath
1106+
? treePath
1107+
.split("/")
1108+
.map((segment) => encodeURIComponent(segment))
1109+
.join("/")
1110+
: null;
1111+
const suffix = encodedTreePath ? `/${encodedTreePath}` : "";
1112+
return `https://github.com/${repo}/tree/${encodedLocator}${suffix}`;
1113+
}
1114+
1115+
return `https://github.com/${repo}`;
1116+
}
1117+
1118+
function hasCanvasKeyword(plugin) {
1119+
return normalizeExternalKeywords(plugin).some(
1120+
(keyword) => normalizeText(keyword).toLowerCase() === EXTERNAL_CANVAS_KEYWORD
1121+
);
1122+
}
1123+
1124+
function normalizeExternalKeywords(plugin) {
1125+
const source = Array.isArray(plugin?.keywords)
1126+
? plugin.keywords
1127+
: Array.isArray(plugin?.tags)
1128+
? plugin.tags
1129+
: [];
1130+
1131+
return [...new Set(
1132+
source
1133+
.filter((keyword) => typeof keyword === "string")
1134+
.map((keyword) => keyword.trim())
1135+
.filter(Boolean)
1136+
)].sort((a, b) => a.localeCompare(b));
1137+
}
1138+
10581139
function normalizeExtensionScreenshotRole(value, relPath, ref) {
10591140
if (!value) return null;
10601141
if (typeof value === "string") {
@@ -1320,6 +1401,93 @@ function generateCanvasManifest(gitDates, commitSha) {
13201401
}
13211402
}
13221403

1404+
const seenExtensionIds = new Set(items.map((item) => String(item.id).toLowerCase()));
1405+
const {
1406+
plugins: externalPlugins,
1407+
errors: externalPluginErrors,
1408+
warnings: externalPluginWarnings,
1409+
} = readExternalPlugins({ policy: "marketplace" });
1410+
externalPluginWarnings.forEach((warning) => console.warn(`Warning: ${warning}`));
1411+
if (externalPluginErrors.length > 0) {
1412+
externalPluginErrors.forEach((error) => console.error(`Error: ${error}`));
1413+
throw new Error("External plugin validation failed");
1414+
}
1415+
1416+
for (const ext of externalPlugins) {
1417+
if (!hasCanvasKeyword(ext)) {
1418+
continue;
1419+
}
1420+
1421+
const name = normalizeText(ext?.name);
1422+
if (!name) {
1423+
continue;
1424+
}
1425+
const displayName = formatDisplayName(name);
1426+
1427+
const id = normalizeText(ext?.name).toLowerCase().replace(/\s+/g, "-");
1428+
if (seenExtensionIds.has(id)) {
1429+
continue;
1430+
}
1431+
1432+
const source = ext?.source;
1433+
if (source?.source !== "github" || !normalizeText(source?.repo)) {
1434+
console.warn(`Warning: skipping external canvas "${name}" due to missing GitHub source`);
1435+
continue;
1436+
}
1437+
1438+
const locator = normalizeText(source.sha) || normalizeText(source.ref);
1439+
if (!locator) {
1440+
console.warn(`Warning: skipping external canvas "${name}" because source.sha or source.ref is required`);
1441+
continue;
1442+
}
1443+
1444+
const pluginRoot = normalizeRepoRelativePath(source.path);
1445+
const previewPath = joinRepoPath(pluginRoot, EXTERNAL_CANVAS_PREVIEW_PATH);
1446+
const imageUrl = buildExternalRepoImageUrl(source.repo, locator, previewPath);
1447+
const sourceUrl = buildExternalRepoTreeUrl(source.repo, locator, pluginRoot);
1448+
const externalSource = normalizeText(source.repo);
1449+
const keywords = normalizeExternalKeywords(ext);
1450+
1451+
items.push({
1452+
id,
1453+
canvasId: id,
1454+
extensionId: id,
1455+
extensionName: name,
1456+
pluginName: null,
1457+
name: displayName,
1458+
version: normalizeText(ext?.version, "1.0.0"),
1459+
readmeFile: null,
1460+
description: normalizeText(ext?.description, "External canvas extension"),
1461+
path: null,
1462+
ref: null,
1463+
lastUpdated: null,
1464+
screenshots: {
1465+
icon: imageUrl
1466+
? {
1467+
path: imageUrl,
1468+
type: getImageMimeType(EXTERNAL_CANVAS_PREVIEW_PATH),
1469+
}
1470+
: null,
1471+
gallery: imageUrl
1472+
? {
1473+
path: imageUrl,
1474+
type: getImageMimeType(EXTERNAL_CANVAS_PREVIEW_PATH),
1475+
}
1476+
: null,
1477+
},
1478+
imageUrl,
1479+
assetPath: null,
1480+
installUrl: null,
1481+
installCommand: null,
1482+
sourceUrl,
1483+
externalSource,
1484+
external: true,
1485+
author: normalizeAuthor(ext?.author),
1486+
keywords,
1487+
});
1488+
seenExtensionIds.add(id);
1489+
}
1490+
13231491
const sortedItems = items.sort((a, b) => a.name.localeCompare(b.name));
13241492
const keywordFilters = [...new Set(sortedItems.flatMap((item) => item.keywords || []))]
13251493
.filter(Boolean)

website/src/pages/extension/[id].astro

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ interface ExtensionEntry extends HeaderItem {
4646
installUrl?: string | null;
4747
installCommand?: string | null;
4848
sourceUrl?: string | null;
49+
externalSource?: string | null;
4950
external?: boolean;
5051
author?: ExtensionAuthor | null;
5152
keywords?: string[];
@@ -117,23 +118,21 @@ const installUrl = safeUrl(
117118
: "")
118119
);
119120
const sourceUrl = safeUrl(item.sourceUrl);
121+
const externalSource = (item.externalSource || "").trim();
120122
const installCommand =
121123
item.installCommand ||
122124
(item.pluginName
123125
? `copilot plugin install ${item.pluginName}@awesome-copilot`
124126
: "");
125127
126128
const githubUrl = isExternal
127-
? sourceUrl || installUrl || GITHUB_TREE
129+
? sourceUrl || GITHUB_TREE
128130
: item.path
129131
? `${GITHUB_TREE}/${item.path}`
130132
: installUrl || GITHUB_TREE;
131133
132134
// Sidebar "Source" shows item.path; external extensions have no repo path.
133-
const sidebarItem =
134-
isExternal && (sourceUrl || installUrl)
135-
? { ...item, path: sourceUrl || installUrl }
136-
: item;
135+
const sidebarItem = isExternal && sourceUrl ? { ...item, path: sourceUrl } : item;
137136
138137
const shortHash = (value: string) =>
139138
/^[0-9a-f]{40}$/i.test(value) ? value.slice(0, 7) : value;
@@ -262,22 +261,13 @@ if (!isExternal && item.ref) {
262261
>
263262
{
264263
isExternal ? (
265-
<div class="skill-install" slot="install">
266-
<p class="skill-install-label">Install this extension</p>
267-
<p class="skill-install-note">
268-
This extension is maintained in an external repository.
269-
</p>
270-
{installUrl && (
271-
<button
272-
type="button"
273-
class="btn btn-secondary skill-install-url-btn"
274-
data-action="copy-install-url"
275-
data-install-url={installUrl}
276-
>
277-
Copy install URL
278-
</button>
279-
)}
280-
</div>
264+
<PluginInstall
265+
slot="install"
266+
isExternal={true}
267+
externalSource={externalSource || null}
268+
label="Install this extension"
269+
note="This extension is maintained in an external repository."
270+
/>
281271
) : (
282272
<PluginInstall
283273
slot="install"

website/src/scripts/pages/extensions-render.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export interface RenderableExtension {
4848
installCommand?: string | null;
4949
installUrl?: string | null;
5050
sourceUrl?: string | null;
51+
externalSource?: string | null;
5152
external?: boolean;
5253
author?: { name: string; url?: string } | null;
5354
}
@@ -95,13 +96,20 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string {
9596
const sourceUrl = safeUrl(
9697
item.sourceUrl || (item.path ? getGitHubUrl(item.path) : "")
9798
);
99+
const externalSource = (item.externalSource || "").trim();
98100
const pluginId = item.pluginName || item.id;
99101
const ghappInstallUrl =
100-
!item.external && pluginId
101-
? `ghapp://plugins/install?source=${encodeURIComponent(
102-
`${pluginId}@awesome-copilot`
103-
)}`
104-
: "";
102+
item.external
103+
? externalSource
104+
? `ghapp://plugins/marketplace/add?source=${encodeURIComponent(
105+
externalSource
106+
)}`
107+
: ""
108+
: pluginId
109+
? `ghapp://plugins/install?source=${encodeURIComponent(
110+
`${pluginId}@awesome-copilot`
111+
)}`
112+
: "";
105113

106114
const previewImageUrl = safeUrl(item.imageUrl);
107115
const previewMediaHtml = previewImageUrl
@@ -152,14 +160,18 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string {
152160
</a>`
153161
: ""
154162
}
155-
<button
163+
${
164+
!item.external
165+
? `<button
156166
class="btn btn-secondary btn-small copy-install-url-btn"
157167
data-install-url="${escapeHtml(installUrl)}"
158168
title="Copy fallback URL install target"
159169
${installUrl ? "" : "disabled"}
160170
>
161171
Copy URL
162-
</button>
172+
</button>`
173+
: ""
174+
}
163175
${
164176
sourceUrl
165177
? `<a href="${escapeHtml(

0 commit comments

Comments
 (0)