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>
This commit is contained in:
Ashley Wolf
2026-06-24 21:52:58 -07:00
committed by GitHub
parent f72401434f
commit e9c8e37041
15 changed files with 139 additions and 1 deletions
+26
View File
@@ -101,6 +101,25 @@ function normalizeText(value, fallback = "") {
return typeof value === "string" ? value.trim() : 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. * 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) const packageJson = fs.existsSync(packageJsonPath)
? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) ? 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) 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)) ? [...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, installUrl,
sourceUrl: null, sourceUrl: null,
external: false, external: false,
author: normalizeAuthor(canvasJson.author),
keywords, keywords,
}); });
} }
@@ -1116,6 +1140,7 @@ function generateCanvasManifest(gitDates, commitSha) {
installUrl, installUrl,
sourceUrl: sourceUrl || null, sourceUrl: sourceUrl || null,
external: true, external: true,
author: normalizeAuthor(ext?.author),
keywords, keywords,
}); });
} }
@@ -1199,6 +1224,7 @@ function writePerExtensionCanvasManifests(canvasManifestData) {
name: item.name, name: item.name,
description: item.description || "Canvas extension", description: item.description || "Canvas extension",
version: item.version || "1.0.0", version: item.version || "1.0.0",
...(item.author ? { author: item.author } : {}),
keywords: Array.isArray(item.keywords) keywords: Array.isArray(item.keywords)
? [...new Set(item.keywords)].sort((a, b) => a.localeCompare(b)) ? [...new Set(item.keywords)].sort((a, b) => a.localeCompare(b))
: [], : [],
@@ -3,6 +3,10 @@
"name": "Accessibility Kanban", "name": "Accessibility Kanban",
"description": "Kanban board to manage accessibility issues, allow you to plan, track, and complete remediation work.", "description": "Kanban board to manage accessibility issues, allow you to plan, track, and complete remediation work.",
"version": "1.0.0", "version": "1.0.0",
"author": {
"name": "Aaron Powell",
"url": "https://github.com/aaronpowell"
},
"keywords": [ "keywords": [
"accessibility", "accessibility",
"github-issues", "github-issues",
+4
View File
@@ -3,6 +3,10 @@
"name": "Agent Arcade", "name": "Agent Arcade",
"description": "Play five retro Phaser mini-games in a Copilot canvas while agents work.", "description": "Play five retro Phaser mini-games in a Copilot canvas while agents work.",
"version": "1.0.0", "version": "1.0.0",
"author": {
"name": "Dan Wahlin",
"url": "https://github.com/DanWahlin"
},
"keywords": [ "keywords": [
"arcade-games", "arcade-games",
"copilot-canvas", "copilot-canvas",
@@ -3,6 +3,10 @@
"name": "Backlog Swipe Triage", "name": "Backlog Swipe Triage",
"description": "Quickly swipe through backlog issues to triage decisions like assign, needs-info, defer, close, or ignore.", "description": "Quickly swipe through backlog issues to triage decisions like assign, needs-info, defer, close, or ignore.",
"version": "1.0.0", "version": "1.0.0",
"author": {
"name": "James Montemagno",
"url": "https://github.com/jamesmontemagno"
},
"keywords": [ "keywords": [
"agent-assignment", "agent-assignment",
"backlog-triage", "backlog-triage",
@@ -3,6 +3,10 @@
"name": "Chromium Control Canvas", "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.", "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", "version": "1.0.0",
"author": {
"name": "Andrea Griffiths",
"url": "https://github.com/AndreaGriffiths11"
},
"keywords": [ "keywords": [
"browser-control", "browser-control",
"chromium-browser", "chromium-browser",
+4
View File
@@ -3,6 +3,10 @@
"name": "Color Orb", "name": "Color Orb",
"description": "A visual orb that users can ask the agent to recolor while showing a live activity log in the canvas.", "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", "version": "1.0.0",
"author": {
"name": "Aaron Powell",
"url": "https://github.com/aaronpowell"
},
"keywords": [ "keywords": [
"agent-actions", "agent-actions",
"color-picker", "color-picker",
+4
View File
@@ -3,6 +3,10 @@
"name": "Diagram Explorer", "name": "Diagram Explorer",
"description": "Render diagrams, click nodes to drill down, and view agent-generated explanations directly in the canvas.", "description": "Render diagrams, click nodes to drill down, and view agent-generated explanations directly in the canvas.",
"version": "1.0.0", "version": "1.0.0",
"author": {
"name": "Aaron Powell",
"url": "https://github.com/aaronpowell"
},
"keywords": [ "keywords": [
"architecture-mapping", "architecture-mapping",
"canvas-navigation", "canvas-navigation",
+1
View File
@@ -3,6 +3,7 @@
"id": "coffilot", "id": "coffilot",
"name": "Coffilot", "name": "Coffilot",
"description": "Java-focused Copilot canvas extension from jdubois.", "description": "Java-focused Copilot canvas extension from jdubois.",
"author": { "name": "Julien Dubois", "url": "https://github.com/jdubois" },
"keywords": [ "keywords": [
"java", "java",
"canvas", "canvas",
+4
View File
@@ -3,6 +3,10 @@
"name": "Feedback Themes", "name": "Feedback Themes",
"description": "Explore grouped customer feedback signals by impact and drill into a theme to guide product next steps.", "description": "Explore grouped customer feedback signals by impact and drill into a theme to guide product next steps.",
"version": "1.0.0", "version": "1.0.0",
"author": {
"name": "Aaron Powell",
"url": "https://github.com/aaronpowell"
},
"keywords": [ "keywords": [
"customer-feedback", "customer-feedback",
"impact-prioritization", "impact-prioritization",
+4
View File
@@ -3,6 +3,10 @@
"name": "Gesture PR Review", "name": "Gesture PR Review",
"description": "Review pull requests with a live camera feed and approve or reject using thumbs-up/thumbs-down gestures.", "description": "Review pull requests with a live camera feed and approve or reject using thumbs-up/thumbs-down gestures.",
"version": "1.0.0", "version": "1.0.0",
"author": {
"name": "Aaron Powell",
"url": "https://github.com/aaronpowell"
},
"keywords": [ "keywords": [
"camera-input", "camera-input",
"gesture-control", "gesture-control",
@@ -3,6 +3,10 @@
"name": "Release Notes Showcase", "name": "Release Notes Showcase",
"description": "Compose and refine launch-ready release notes with contributor callouts and export-friendly output.", "description": "Compose and refine launch-ready release notes with contributor callouts and export-friendly output.",
"version": "1.0.0", "version": "1.0.0",
"author": {
"name": "James Montemagno",
"url": "https://github.com/jamesmontemagno"
},
"keywords": [ "keywords": [
"changelog", "changelog",
"contributor-callouts", "contributor-callouts",
+4
View File
@@ -3,6 +3,10 @@
"name": "Where Was I?", "name": "Where Was I?",
"description": "Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly.", "description": "Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly.",
"version": "1.0.0", "version": "1.0.0",
"author": {
"name": "Aaron Powell",
"url": "https://github.com/aaronpowell"
},
"keywords": [ "keywords": [
"branch-state", "branch-state",
"developer-context", "developer-context",
+27 -1
View File
@@ -1,4 +1,10 @@
import { escapeHtml, getGitHubUrl, getLastUpdatedHtml } from "../utils"; import {
escapeHtml,
getGitHubHandle,
getGitHubUrl,
getLastUpdatedHtml,
sanitizeUrl,
} from "../utils";
import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render"; import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render";
export interface RenderableExtension { export interface RenderableExtension {
@@ -34,6 +40,7 @@ export interface RenderableExtension {
installUrl?: string | null; installUrl?: string | null;
sourceUrl?: string | null; sourceUrl?: string | null;
external?: boolean; external?: boolean;
author?: { name: string; url?: string } | null;
} }
export type ExtensionSortOption = "title" | "lastUpdated"; export type ExtensionSortOption = "title" | "lastUpdated";
@@ -92,8 +99,27 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string {
</div> </div>
`; `;
const authorName = item.author?.name;
const authorUrl = item.author?.url;
const authorHandle =
authorName && authorUrl
? getGitHubHandle(authorUrl, authorName)
: authorName || "";
const authorHtml = authorName
? `<span class="resource-tag resource-author">by ${
authorUrl
? `<a href="${escapeHtml(
sanitizeUrl(authorUrl)
)}" target="_blank" rel="noopener noreferrer" title="${escapeHtml(
authorName
)}">${escapeHtml(authorHandle)}</a>`
: escapeHtml(authorName)
}</span>`
: "";
const metaHtml = ` const metaHtml = `
${item.external ? '<span class="resource-tag">External</span>' : ""} ${item.external ? '<span class="resource-tag">External</span>' : ""}
${authorHtml}
${getLastUpdatedHtml(item.lastUpdated)} ${getLastUpdatedHtml(item.lastUpdated)}
`; `;
+20
View File
@@ -12,9 +12,11 @@ import {
copyToClipboard, copyToClipboard,
fetchData, fetchData,
formatRelativeTime, formatRelativeTime,
getGitHubHandle,
getGitHubUrl, getGitHubUrl,
getQueryParam, getQueryParam,
getQueryParamValues, getQueryParamValues,
sanitizeUrl,
showToast, showToast,
updateQueryParams, updateQueryParams,
} from "../utils"; } from "../utils";
@@ -178,6 +180,24 @@ function openDetailsModal(
if (item.external) { if (item.external) {
metaParts.push('<span class="resource-tag">External</span>'); metaParts.push('<span class="resource-tag">External</span>');
} }
if (item.author?.name) {
const authorName = item.author.name;
const authorUrl = item.author.url;
const authorHandle = authorUrl
? getGitHubHandle(authorUrl, authorName)
: authorName;
metaParts.push(
authorUrl
? `<span class="resource-author">by <a href="${escapeHtml(
sanitizeUrl(authorUrl)
)}" target="_blank" rel="noopener noreferrer" title="${escapeHtml(
authorName
)}">${escapeHtml(authorHandle)}</a></span>`
: `<span class="resource-author">by ${escapeHtml(
authorName
)}</span>`
);
}
if (item.lastUpdated) { if (item.lastUpdated) {
metaParts.push( metaParts.push(
`<span class="last-updated">Updated ${escapeHtml( `<span class="last-updated">Updated ${escapeHtml(
+25
View File
@@ -389,6 +389,31 @@ export function sanitizeUrl(url: string | null | undefined): string {
return "#"; 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 * Truncate text with ellipsis
*/ */