From f0da81e14a574da0eb4b81fc206d367c1ec7dd7b Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Fri, 17 Jul 2026 09:29:01 +1000 Subject: [PATCH] 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 --- eng/generate-website-data.mjs | 168 ++++++++++++++++++ website/src/pages/extension/[id].astro | 32 ++-- .../src/scripts/pages/extensions-render.ts | 26 ++- 3 files changed, 198 insertions(+), 28 deletions(-) diff --git a/eng/generate-website-data.mjs b/eng/generate-website-data.mjs index 3abfb54e..ef052693 100755 --- a/eng/generate-website-data.mjs +++ b/eng/generate-website-data.mjs @@ -25,12 +25,15 @@ import { parseSkillMetadata, parseYamlFile, } from "./yaml-parser.mjs"; +import { readExternalPlugins } from "./external-plugin-validation.mjs"; const __filename = fileURLToPath(import.meta.url); const WEBSITE_DIR = path.join(ROOT_FOLDER, "website"); const WEBSITE_DATA_DIR = path.join(WEBSITE_DIR, "public", "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 @@ -97,6 +100,23 @@ function normalizeText(value, 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 * { 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) { if (!value) return null; 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 keywordFilters = [...new Set(sortedItems.flatMap((item) => item.keywords || []))] .filter(Boolean) diff --git a/website/src/pages/extension/[id].astro b/website/src/pages/extension/[id].astro index 7eaaec8e..9b38d341 100644 --- a/website/src/pages/extension/[id].astro +++ b/website/src/pages/extension/[id].astro @@ -46,6 +46,7 @@ interface ExtensionEntry extends HeaderItem { installUrl?: string | null; installCommand?: string | null; sourceUrl?: string | null; + externalSource?: string | null; external?: boolean; author?: ExtensionAuthor | null; keywords?: string[]; @@ -117,6 +118,7 @@ const installUrl = safeUrl( : "") ); const sourceUrl = safeUrl(item.sourceUrl); +const externalSource = (item.externalSource || "").trim(); const installCommand = item.installCommand || (item.pluginName @@ -124,16 +126,13 @@ const installCommand = : ""); const githubUrl = isExternal - ? sourceUrl || installUrl || GITHUB_TREE + ? sourceUrl || 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 sidebarItem = isExternal && sourceUrl ? { ...item, path: sourceUrl } : item; const shortHash = (value: string) => /^[0-9a-f]{40}$/i.test(value) ? value.slice(0, 7) : value; @@ -262,22 +261,13 @@ if (!isExternal && item.ref) { > { isExternal ? ( -
-

Install this extension

-

- This extension is maintained in an external repository. -

- {installUrl && ( - - )} -
+ ) : ( ` : "" } - + ` + : "" + } ${ sourceUrl ? `