From e9c8e3704181fa38245d101308a8527d12af6a4d Mon Sep 17 00:00:00 2001 From: Ashley Wolf Date: Wed, 24 Jun 2026 21:52:58 -0700 Subject: [PATCH] Add contributor attribution to canvas extension cards (#2111) Show "by @handle" on each canvas extension card and in the details modal, linking to the contributor's GitHub profile. Author metadata lives in each extension's canvas.json (and external.json for external extensions), where the rest of the canvas metadata is stored. - Store author {name, url} in canvas.json / external.json - Read author from canvas.json in the website data generator and emit it to extensions.json - Render the GitHub @handle, derived from the profile URL, as the link text, with the contributor's name as the link title - Escape the sanitized author URL before interpolating it into href Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/generate-website-data.mjs | 26 +++++++++++++++++ extensions/accessibility-kanban/canvas.json | 4 +++ extensions/arcade-canvas/canvas.json | 4 +++ extensions/backlog-swipe-triage/canvas.json | 4 +++ .../chromium-control-canvas/canvas.json | 4 +++ extensions/color-orb/canvas.json | 4 +++ extensions/diagram-viewer/canvas.json | 4 +++ extensions/external.json | 1 + extensions/feedback-themes/canvas.json | 4 +++ extensions/gesture-review/canvas.json | 4 +++ extensions/release-notes-showcase/canvas.json | 4 +++ extensions/where-was-i/canvas.json | 4 +++ .../src/scripts/pages/extensions-render.ts | 28 ++++++++++++++++++- website/src/scripts/pages/extensions.ts | 20 +++++++++++++ website/src/scripts/utils.ts | 25 +++++++++++++++++ 15 files changed, 139 insertions(+), 1 deletion(-) diff --git a/eng/generate-website-data.mjs b/eng/generate-website-data.mjs index f20a8bbe..875eb803 100755 --- a/eng/generate-website-data.mjs +++ b/eng/generate-website-data.mjs @@ -101,6 +101,25 @@ function normalizeText(value, fallback = "") { return typeof value === "string" ? value.trim() : fallback; } +/** + * Normalize an author value (npm string form or { name, url } object) to + * { name, url? } | null. Returns null when no usable name is present. + */ +function normalizeAuthor(value) { + if (!value) return null; + if (typeof value === "string") { + const name = value.trim(); + return name ? { name } : null; + } + if (typeof value === "object") { + const name = normalizeText(value.name); + if (!name) return null; + const url = normalizeText(value.url); + return url ? { name, url } : { name }; + } + return null; +} + /** * Find the latest git-modified date for any file under a directory. */ @@ -1002,6 +1021,10 @@ function generateCanvasManifest(gitDates, commitSha) { const packageJson = fs.existsSync(packageJsonPath) ? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) : {}; + const canvasJsonPath = path.join(extensionDir, "canvas.json"); + const canvasJson = fs.existsSync(canvasJsonPath) + ? JSON.parse(fs.readFileSync(canvasJsonPath, "utf-8")) + : {}; const keywords = Array.isArray(packageJson.keywords) ? [...new Set(packageJson.keywords.filter((keyword) => typeof keyword === "string").map((keyword) => keyword.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b)) : []; @@ -1044,6 +1067,7 @@ function generateCanvasManifest(gitDates, commitSha) { installUrl, sourceUrl: null, external: false, + author: normalizeAuthor(canvasJson.author), keywords, }); } @@ -1116,6 +1140,7 @@ function generateCanvasManifest(gitDates, commitSha) { installUrl, sourceUrl: sourceUrl || null, external: true, + author: normalizeAuthor(ext?.author), keywords, }); } @@ -1199,6 +1224,7 @@ function writePerExtensionCanvasManifests(canvasManifestData) { name: item.name, description: item.description || "Canvas extension", version: item.version || "1.0.0", + ...(item.author ? { author: item.author } : {}), keywords: Array.isArray(item.keywords) ? [...new Set(item.keywords)].sort((a, b) => a.localeCompare(b)) : [], diff --git a/extensions/accessibility-kanban/canvas.json b/extensions/accessibility-kanban/canvas.json index 6bc4538e..c9aecb27 100644 --- a/extensions/accessibility-kanban/canvas.json +++ b/extensions/accessibility-kanban/canvas.json @@ -3,6 +3,10 @@ "name": "Accessibility Kanban", "description": "Kanban board to manage accessibility issues, allow you to plan, track, and complete remediation work.", "version": "1.0.0", + "author": { + "name": "Aaron Powell", + "url": "https://github.com/aaronpowell" + }, "keywords": [ "accessibility", "github-issues", diff --git a/extensions/arcade-canvas/canvas.json b/extensions/arcade-canvas/canvas.json index 259a1264..daf543c6 100644 --- a/extensions/arcade-canvas/canvas.json +++ b/extensions/arcade-canvas/canvas.json @@ -3,6 +3,10 @@ "name": "Agent Arcade", "description": "Play five retro Phaser mini-games in a Copilot canvas while agents work.", "version": "1.0.0", + "author": { + "name": "Dan Wahlin", + "url": "https://github.com/DanWahlin" + }, "keywords": [ "arcade-games", "copilot-canvas", diff --git a/extensions/backlog-swipe-triage/canvas.json b/extensions/backlog-swipe-triage/canvas.json index b4b5b350..08dd7ead 100644 --- a/extensions/backlog-swipe-triage/canvas.json +++ b/extensions/backlog-swipe-triage/canvas.json @@ -3,6 +3,10 @@ "name": "Backlog Swipe Triage", "description": "Quickly swipe through backlog issues to triage decisions like assign, needs-info, defer, close, or ignore.", "version": "1.0.0", + "author": { + "name": "James Montemagno", + "url": "https://github.com/jamesmontemagno" + }, "keywords": [ "agent-assignment", "backlog-triage", diff --git a/extensions/chromium-control-canvas/canvas.json b/extensions/chromium-control-canvas/canvas.json index 79ff4cf6..3325ca63 100644 --- a/extensions/chromium-control-canvas/canvas.json +++ b/extensions/chromium-control-canvas/canvas.json @@ -3,6 +3,10 @@ "name": "Chromium Control Canvas", "description": "Opens a real Chromium window you can navigate and interact with from a Copilot canvas control panel and agent actions.", "version": "1.0.0", + "author": { + "name": "Andrea Griffiths", + "url": "https://github.com/AndreaGriffiths11" + }, "keywords": [ "browser-control", "chromium-browser", diff --git a/extensions/color-orb/canvas.json b/extensions/color-orb/canvas.json index 47a1a32b..a68404ed 100644 --- a/extensions/color-orb/canvas.json +++ b/extensions/color-orb/canvas.json @@ -3,6 +3,10 @@ "name": "Color Orb", "description": "A visual orb that users can ask the agent to recolor while showing a live activity log in the canvas.", "version": "1.0.0", + "author": { + "name": "Aaron Powell", + "url": "https://github.com/aaronpowell" + }, "keywords": [ "agent-actions", "color-picker", diff --git a/extensions/diagram-viewer/canvas.json b/extensions/diagram-viewer/canvas.json index 0fe72fde..ddc3b4d2 100644 --- a/extensions/diagram-viewer/canvas.json +++ b/extensions/diagram-viewer/canvas.json @@ -3,6 +3,10 @@ "name": "Diagram Explorer", "description": "Render diagrams, click nodes to drill down, and view agent-generated explanations directly in the canvas.", "version": "1.0.0", + "author": { + "name": "Aaron Powell", + "url": "https://github.com/aaronpowell" + }, "keywords": [ "architecture-mapping", "canvas-navigation", diff --git a/extensions/external.json b/extensions/external.json index c6b98bcc..f0e2f561 100644 --- a/extensions/external.json +++ b/extensions/external.json @@ -3,6 +3,7 @@ "id": "coffilot", "name": "Coffilot", "description": "Java-focused Copilot canvas extension from jdubois.", + "author": { "name": "Julien Dubois", "url": "https://github.com/jdubois" }, "keywords": [ "java", "canvas", diff --git a/extensions/feedback-themes/canvas.json b/extensions/feedback-themes/canvas.json index 01a269be..3bc88213 100644 --- a/extensions/feedback-themes/canvas.json +++ b/extensions/feedback-themes/canvas.json @@ -3,6 +3,10 @@ "name": "Feedback Themes", "description": "Explore grouped customer feedback signals by impact and drill into a theme to guide product next steps.", "version": "1.0.0", + "author": { + "name": "Aaron Powell", + "url": "https://github.com/aaronpowell" + }, "keywords": [ "customer-feedback", "impact-prioritization", diff --git a/extensions/gesture-review/canvas.json b/extensions/gesture-review/canvas.json index c05ecddf..61f91cdc 100644 --- a/extensions/gesture-review/canvas.json +++ b/extensions/gesture-review/canvas.json @@ -3,6 +3,10 @@ "name": "Gesture PR Review", "description": "Review pull requests with a live camera feed and approve or reject using thumbs-up/thumbs-down gestures.", "version": "1.0.0", + "author": { + "name": "Aaron Powell", + "url": "https://github.com/aaronpowell" + }, "keywords": [ "camera-input", "gesture-control", diff --git a/extensions/release-notes-showcase/canvas.json b/extensions/release-notes-showcase/canvas.json index a21dbbb2..4b1b1835 100644 --- a/extensions/release-notes-showcase/canvas.json +++ b/extensions/release-notes-showcase/canvas.json @@ -3,6 +3,10 @@ "name": "Release Notes Showcase", "description": "Compose and refine launch-ready release notes with contributor callouts and export-friendly output.", "version": "1.0.0", + "author": { + "name": "James Montemagno", + "url": "https://github.com/jamesmontemagno" + }, "keywords": [ "changelog", "contributor-callouts", diff --git a/extensions/where-was-i/canvas.json b/extensions/where-was-i/canvas.json index 2909dfb2..bf5a69e3 100644 --- a/extensions/where-was-i/canvas.json +++ b/extensions/where-was-i/canvas.json @@ -3,6 +3,10 @@ "name": "Where Was I?", "description": "Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly.", "version": "1.0.0", + "author": { + "name": "Aaron Powell", + "url": "https://github.com/aaronpowell" + }, "keywords": [ "branch-state", "developer-context", diff --git a/website/src/scripts/pages/extensions-render.ts b/website/src/scripts/pages/extensions-render.ts index ac8f2539..8172cef5 100644 --- a/website/src/scripts/pages/extensions-render.ts +++ b/website/src/scripts/pages/extensions-render.ts @@ -1,4 +1,10 @@ -import { escapeHtml, getGitHubUrl, getLastUpdatedHtml } from "../utils"; +import { + escapeHtml, + getGitHubHandle, + getGitHubUrl, + getLastUpdatedHtml, + sanitizeUrl, +} from "../utils"; import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render"; export interface RenderableExtension { @@ -34,6 +40,7 @@ export interface RenderableExtension { installUrl?: string | null; sourceUrl?: string | null; external?: boolean; + author?: { name: string; url?: string } | null; } export type ExtensionSortOption = "title" | "lastUpdated"; @@ -92,8 +99,27 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string { `; + const authorName = item.author?.name; + const authorUrl = item.author?.url; + const authorHandle = + authorName && authorUrl + ? getGitHubHandle(authorUrl, authorName) + : authorName || ""; + const authorHtml = authorName + ? `by ${ + authorUrl + ? `${escapeHtml(authorHandle)}` + : escapeHtml(authorName) + }` + : ""; + const metaHtml = ` ${item.external ? 'External' : ""} + ${authorHtml} ${getLastUpdatedHtml(item.lastUpdated)} `; diff --git a/website/src/scripts/pages/extensions.ts b/website/src/scripts/pages/extensions.ts index 3c3c9c94..09edf1de 100644 --- a/website/src/scripts/pages/extensions.ts +++ b/website/src/scripts/pages/extensions.ts @@ -12,9 +12,11 @@ import { copyToClipboard, fetchData, formatRelativeTime, + getGitHubHandle, getGitHubUrl, getQueryParam, getQueryParamValues, + sanitizeUrl, showToast, updateQueryParams, } from "../utils"; @@ -178,6 +180,24 @@ function openDetailsModal( if (item.external) { metaParts.push('External'); } + if (item.author?.name) { + const authorName = item.author.name; + const authorUrl = item.author.url; + const authorHandle = authorUrl + ? getGitHubHandle(authorUrl, authorName) + : authorName; + metaParts.push( + authorUrl + ? `by ${escapeHtml(authorHandle)}` + : `by ${escapeHtml( + authorName + )}` + ); + } if (item.lastUpdated) { metaParts.push( `Updated ${escapeHtml( diff --git a/website/src/scripts/utils.ts b/website/src/scripts/utils.ts index 30cc1fac..b90d7793 100644 --- a/website/src/scripts/utils.ts +++ b/website/src/scripts/utils.ts @@ -389,6 +389,31 @@ export function sanitizeUrl(url: string | null | undefined): string { return "#"; } +/** + * Derive a GitHub @handle from a profile URL + * (e.g. "https://github.com/aaronpowell" -> "@aaronpowell"). + * Falls back to the provided value when the URL is not a github.com profile. + */ +export function getGitHubHandle( + url: string | null | undefined, + fallback = "" +): string { + if (!url) return fallback; + try { + const parsed = new URL(url); + const host = parsed.hostname.replace(/^www\./, ""); + if (host === "github.com") { + const segments = parsed.pathname.split("/").filter(Boolean); + if (segments.length === 1) { + return `@${segments[0]}`; + } + } + } catch { + // Invalid URL + } + return fallback; +} + /** * Truncate text with ellipsis */