mirror of
https://github.com/github/awesome-copilot.git
synced 2026-06-25 17:00:20 +00:00
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:
@@ -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))
|
||||
: [],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
</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 = `
|
||||
${item.external ? '<span class="resource-tag">External</span>' : ""}
|
||||
${authorHtml}
|
||||
${getLastUpdatedHtml(item.lastUpdated)}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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('<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) {
|
||||
metaParts.push(
|
||||
`<span class="last-updated">Updated ${escapeHtml(
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user