Skip to content

Commit 3efc4f3

Browse files
aaronpowellCopilot
andauthored
feat: show external plugins on the website (#937)
* feat: show external plugins on the website Read plugins/external.json during website data generation and include external plugins alongside local ones in plugins.json. External plugins are flagged with external:true and carry metadata (author, repository, homepage, license, source). On the website: - Plugin cards show a '🔗 External' badge and author attribution - The 'Repository' button links to the source path within the repo - The modal shows metadata (author, repo, homepage, license) and a 'View Repository' CTA instead of an items list - External plugins are searchable and filterable by tags Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address PR #937 security and UX review comments - Add sanitizeUrl() function to validate URLs and prevent XSS via javascript:/data: schemes - Add rel="noopener noreferrer" to all target="_blank" links to prevent reverse-tabnabbing - Change external plugin path from external/<name> to plugins/<name> for proper deep-linking - Track actual count of external plugins added (after filtering/deduplication) in build logs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b91369d commit 3efc4f3

File tree

5 files changed

+312
-8
lines changed

5 files changed

+312
-8
lines changed

eng/generate-website-data.mjs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,63 @@ function generatePluginsData(gitDates) {
544544
}
545545
}
546546

547+
// Load external plugins from plugins/external.json
548+
const externalJsonPath = path.join(PLUGINS_DIR, "external.json");
549+
if (fs.existsSync(externalJsonPath)) {
550+
try {
551+
const externalPlugins = JSON.parse(
552+
fs.readFileSync(externalJsonPath, "utf-8")
553+
);
554+
if (Array.isArray(externalPlugins)) {
555+
let addedCount = 0;
556+
for (const ext of externalPlugins) {
557+
if (!ext.name || !ext.description) {
558+
console.warn(
559+
`Skipping external plugin with missing name/description`
560+
);
561+
continue;
562+
}
563+
564+
// Skip if a local plugin with the same name already exists
565+
if (plugins.some((p) => p.id === ext.name)) {
566+
console.warn(
567+
`Skipping external plugin "${ext.name}" — local plugin with same name exists`
568+
);
569+
continue;
570+
}
571+
572+
const tags = ext.keywords || ext.tags || [];
573+
574+
plugins.push({
575+
id: ext.name,
576+
name: ext.name,
577+
description: ext.description || "",
578+
path: `plugins/${ext.name}`,
579+
tags: tags,
580+
itemCount: 0,
581+
items: [],
582+
external: true,
583+
repository: ext.repository || null,
584+
homepage: ext.homepage || null,
585+
author: ext.author || null,
586+
license: ext.license || null,
587+
source: ext.source || null,
588+
lastUpdated: null,
589+
searchText: `${ext.name} ${ext.description || ""} ${tags.join(
590+
" "
591+
)} ${ext.author?.name || ""} ${ext.repository || ""}`.toLowerCase(),
592+
});
593+
addedCount++;
594+
}
595+
console.log(
596+
` ✓ Loaded ${addedCount} external plugin(s)`
597+
);
598+
}
599+
} catch (e) {
600+
console.warn(`Failed to parse external plugins: ${e.message}`);
601+
}
602+
}
603+
547604
// Collect all unique tags
548605
const allTags = [...new Set(plugins.flatMap((p) => p.tags))].sort();
549606

website/src/scripts/modal.ts

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
getResourceType,
1414
escapeHtml,
1515
getResourceIcon,
16+
sanitizeUrl,
1617
} from "./utils";
1718

1819
// Modal state
@@ -83,13 +84,30 @@ interface PluginItem {
8384
usage?: string | null;
8485
}
8586

87+
interface PluginAuthor {
88+
name: string;
89+
url?: string;
90+
}
91+
92+
interface PluginSource {
93+
source: string;
94+
repo?: string;
95+
path?: string;
96+
}
97+
8698
interface Plugin {
8799
id: string;
88100
name: string;
89101
description?: string;
90102
path: string;
91103
items: PluginItem[];
92104
tags?: string[];
105+
external?: boolean;
106+
repository?: string | null;
107+
homepage?: string | null;
108+
author?: PluginAuthor | null;
109+
license?: string | null;
110+
source?: PluginSource | null;
93111
}
94112

95113
interface PluginsData {
@@ -519,7 +537,114 @@ async function openPluginModal(
519537
title.textContent = plugin.name;
520538
document.title = `${plugin.name} | Awesome GitHub Copilot`;
521539

522-
// Render plugin view
540+
// Render external plugin view (metadata + links) or local plugin view (items list)
541+
if (plugin.external) {
542+
renderExternalPluginModal(plugin, modalContent);
543+
} else {
544+
renderLocalPluginModal(plugin, modalContent);
545+
}
546+
}
547+
548+
/**
549+
* Get the best URL for an external plugin, preferring the deep path within the repo
550+
*/
551+
function getExternalPluginUrl(plugin: Plugin): string {
552+
if (plugin.source?.source === "github" && plugin.source.repo) {
553+
const base = `https://github.com/${plugin.source.repo}`;
554+
return plugin.source.path ? `${base}/tree/main/${plugin.source.path}` : base;
555+
}
556+
// Sanitize URLs from JSON to prevent XSS via javascript:/data: schemes
557+
return sanitizeUrl(plugin.repository || plugin.homepage);
558+
}
559+
560+
/**
561+
* Render modal content for an external plugin (no local files)
562+
*/
563+
function renderExternalPluginModal(
564+
plugin: Plugin,
565+
modalContent: HTMLElement
566+
): void {
567+
const authorHtml = plugin.author?.name
568+
? `<div class="external-plugin-meta-row">
569+
<span class="external-plugin-meta-label">Author</span>
570+
<span class="external-plugin-meta-value">${
571+
plugin.author.url
572+
? `<a href="${sanitizeUrl(plugin.author.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(plugin.author.name)}</a>`
573+
: escapeHtml(plugin.author.name)
574+
}</span>
575+
</div>`
576+
: "";
577+
578+
const repoHtml = plugin.repository
579+
? `<div class="external-plugin-meta-row">
580+
<span class="external-plugin-meta-label">Repository</span>
581+
<span class="external-plugin-meta-value"><a href="${sanitizeUrl(plugin.repository)}" target="_blank" rel="noopener noreferrer">${escapeHtml(plugin.repository)}</a></span>
582+
</div>`
583+
: "";
584+
585+
const homepageHtml =
586+
plugin.homepage && plugin.homepage !== plugin.repository
587+
? `<div class="external-plugin-meta-row">
588+
<span class="external-plugin-meta-label">Homepage</span>
589+
<span class="external-plugin-meta-value"><a href="${sanitizeUrl(plugin.homepage)}" target="_blank" rel="noopener noreferrer">${escapeHtml(plugin.homepage)}</a></span>
590+
</div>`
591+
: "";
592+
593+
const licenseHtml = plugin.license
594+
? `<div class="external-plugin-meta-row">
595+
<span class="external-plugin-meta-label">License</span>
596+
<span class="external-plugin-meta-value">${escapeHtml(plugin.license)}</span>
597+
</div>`
598+
: "";
599+
600+
const sourceHtml = plugin.source?.repo
601+
? `<div class="external-plugin-meta-row">
602+
<span class="external-plugin-meta-label">Source</span>
603+
<span class="external-plugin-meta-value">GitHub: ${escapeHtml(plugin.source.repo)}${plugin.source.path ? ` (${escapeHtml(plugin.source.path)})` : ""}</span>
604+
</div>`
605+
: "";
606+
607+
const repoUrl = getExternalPluginUrl(plugin);
608+
609+
modalContent.innerHTML = `
610+
<div class="collection-view">
611+
<div class="collection-description">${escapeHtml(plugin.description || "")}</div>
612+
${
613+
plugin.tags && plugin.tags.length > 0
614+
? `<div class="collection-tags">
615+
<span class="resource-tag resource-tag-external">🔗 External Plugin</span>
616+
${plugin.tags.map((t) => `<span class="resource-tag">${escapeHtml(t)}</span>`).join("")}
617+
</div>`
618+
: `<div class="collection-tags">
619+
<span class="resource-tag resource-tag-external">🔗 External Plugin</span>
620+
</div>`
621+
}
622+
<div class="external-plugin-metadata">
623+
${authorHtml}
624+
${repoHtml}
625+
${homepageHtml}
626+
${licenseHtml}
627+
${sourceHtml}
628+
</div>
629+
<div class="external-plugin-cta">
630+
<a href="${sanitizeUrl(repoUrl)}" class="btn btn-primary external-plugin-repo-btn" target="_blank" rel="noopener noreferrer">
631+
View Repository →
632+
</a>
633+
</div>
634+
<div class="external-plugin-note">
635+
This is an external plugin maintained outside this repository. Browse the repository to see its contents and installation instructions.
636+
</div>
637+
</div>
638+
`;
639+
}
640+
641+
/**
642+
* Render modal content for a local plugin (item list)
643+
*/
644+
function renderLocalPluginModal(
645+
plugin: Plugin,
646+
modalContent: HTMLElement
647+
): void {
523648
modalContent.innerHTML = `
524649
<div class="collection-view">
525650
<div class="collection-description">${escapeHtml(

website/src/scripts/pages/plugins.ts

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,33 @@
33
*/
44
import { createChoices, getChoicesValues, type Choices } from '../choices';
55
import { FuzzySearch, type SearchItem } from '../search';
6-
import { fetchData, debounce, escapeHtml, getGitHubUrl } from '../utils';
6+
import { fetchData, debounce, escapeHtml, getGitHubUrl, sanitizeUrl } from '../utils';
77
import { setupModal, openFileModal } from '../modal';
88

9+
interface PluginAuthor {
10+
name: string;
11+
url?: string;
12+
}
13+
14+
interface PluginSource {
15+
source: string;
16+
repo?: string;
17+
path?: string;
18+
}
19+
920
interface Plugin extends SearchItem {
1021
id: string;
1122
name: string;
1223
path: string;
1324
tags?: string[];
1425
featured?: boolean;
1526
itemCount: number;
27+
external?: boolean;
28+
repository?: string | null;
29+
homepage?: string | null;
30+
author?: PluginAuthor | null;
31+
license?: string | null;
32+
source?: PluginSource | null;
1633
}
1734

1835
interface PluginsData {
@@ -56,6 +73,15 @@ function applyFiltersAndRender(): void {
5673
if (countEl) countEl.textContent = countText;
5774
}
5875

76+
function getExternalPluginUrl(plugin: Plugin): string {
77+
if (plugin.source?.source === 'github' && plugin.source.repo) {
78+
const base = `https://github.com/${plugin.source.repo}`;
79+
return plugin.source.path ? `${base}/tree/main/${plugin.source.path}` : base;
80+
}
81+
// Sanitize URLs from JSON to prevent XSS via javascript:/data: schemes
82+
return sanitizeUrl(plugin.repository || plugin.homepage);
83+
}
84+
5985
function renderItems(items: Plugin[], query = ''): void {
6086
const list = document.getElementById('resource-list');
6187
if (!list) return;
@@ -65,22 +91,35 @@ function renderItems(items: Plugin[], query = ''): void {
6591
return;
6692
}
6793

68-
list.innerHTML = items.map(item => `
69-
<div class="resource-item" data-path="${escapeHtml(item.path)}">
94+
list.innerHTML = items.map(item => {
95+
const isExternal = item.external === true;
96+
const metaTag = isExternal
97+
? `<span class="resource-tag resource-tag-external">🔗 External</span>`
98+
: `<span class="resource-tag">${item.itemCount} items</span>`;
99+
const authorTag = isExternal && item.author?.name
100+
? `<span class="resource-tag">by ${escapeHtml(item.author.name)}</span>`
101+
: '';
102+
const githubHref = isExternal
103+
? escapeHtml(getExternalPluginUrl(item))
104+
: getGitHubUrl(item.path);
105+
106+
return `
107+
<div class="resource-item${isExternal ? ' resource-item-external' : ''}" data-path="${escapeHtml(item.path)}">
70108
<div class="resource-info">
71109
<div class="resource-title">${item.featured ? '⭐ ' : ''}${query ? search.highlight(item.name, query) : escapeHtml(item.name)}</div>
72110
<div class="resource-description">${escapeHtml(item.description || 'No description')}</div>
73111
<div class="resource-meta">
74-
<span class="resource-tag">${item.itemCount} items</span>
112+
${metaTag}
113+
${authorTag}
75114
${item.tags?.slice(0, 4).map(t => `<span class="resource-tag">${escapeHtml(t)}</span>`).join('') || ''}
76115
${item.tags && item.tags.length > 4 ? `<span class="resource-tag">+${item.tags.length - 4} more</span>` : ''}
77116
</div>
78117
</div>
79118
<div class="resource-actions">
80-
<a href="${getGitHubUrl(item.path)}" class="btn btn-secondary" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">GitHub</a>
119+
<a href="${githubHref}" class="btn btn-secondary" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()" title="${isExternal ? 'View repository' : 'View on GitHub'}">${isExternal ? 'Repository' : 'GitHub'}</a>
81120
</div>
82-
</div>
83-
`).join('');
121+
</div>`;
122+
}).join('');
84123

85124
// Add click handlers
86125
list.querySelectorAll('.resource-item').forEach(el => {

website/src/scripts/utils.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,24 @@ export function escapeHtml(text: string): string {
214214
return div.innerHTML;
215215
}
216216

217+
/**
218+
* Validate and sanitize URLs to prevent XSS attacks
219+
* Only allows http/https protocols, returns '#' for invalid URLs
220+
*/
221+
export function sanitizeUrl(url: string | null | undefined): string {
222+
if (!url) return '#';
223+
try {
224+
const parsed = new URL(url);
225+
// Only allow http and https protocols
226+
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
227+
return url;
228+
}
229+
} catch {
230+
// Invalid URL
231+
}
232+
return '#';
233+
}
234+
217235
/**
218236
* Truncate text with ellipsis
219237
*/

0 commit comments

Comments
 (0)