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
+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";
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)}
`;
+20
View File
@@ -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(
+25
View File
@@ -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
*/