diff --git a/eng/generate-website-data.mjs b/eng/generate-website-data.mjs
index b4d3e13f..a2e2dca0 100644
--- a/eng/generate-website-data.mjs
+++ b/eng/generate-website-data.mjs
@@ -544,6 +544,63 @@ function generatePluginsData(gitDates) {
}
}
+ // Load external plugins from plugins/external.json
+ const externalJsonPath = path.join(PLUGINS_DIR, "external.json");
+ if (fs.existsSync(externalJsonPath)) {
+ try {
+ const externalPlugins = JSON.parse(
+ fs.readFileSync(externalJsonPath, "utf-8")
+ );
+ if (Array.isArray(externalPlugins)) {
+ let addedCount = 0;
+ for (const ext of externalPlugins) {
+ if (!ext.name || !ext.description) {
+ console.warn(
+ `Skipping external plugin with missing name/description`
+ );
+ continue;
+ }
+
+ // Skip if a local plugin with the same name already exists
+ if (plugins.some((p) => p.id === ext.name)) {
+ console.warn(
+ `Skipping external plugin "${ext.name}" — local plugin with same name exists`
+ );
+ continue;
+ }
+
+ const tags = ext.keywords || ext.tags || [];
+
+ plugins.push({
+ id: ext.name,
+ name: ext.name,
+ description: ext.description || "",
+ path: `plugins/${ext.name}`,
+ tags: tags,
+ itemCount: 0,
+ items: [],
+ external: true,
+ repository: ext.repository || null,
+ homepage: ext.homepage || null,
+ author: ext.author || null,
+ license: ext.license || null,
+ source: ext.source || null,
+ lastUpdated: null,
+ searchText: `${ext.name} ${ext.description || ""} ${tags.join(
+ " "
+ )} ${ext.author?.name || ""} ${ext.repository || ""}`.toLowerCase(),
+ });
+ addedCount++;
+ }
+ console.log(
+ ` ✓ Loaded ${addedCount} external plugin(s)`
+ );
+ }
+ } catch (e) {
+ console.warn(`Failed to parse external plugins: ${e.message}`);
+ }
+ }
+
// Collect all unique tags
const allTags = [...new Set(plugins.flatMap((p) => p.tags))].sort();
diff --git a/website/src/scripts/modal.ts b/website/src/scripts/modal.ts
index 94827e6b..f1b8751c 100644
--- a/website/src/scripts/modal.ts
+++ b/website/src/scripts/modal.ts
@@ -13,6 +13,7 @@ import {
getResourceType,
escapeHtml,
getResourceIcon,
+ sanitizeUrl,
} from "./utils";
// Modal state
@@ -83,6 +84,17 @@ interface PluginItem {
usage?: string | null;
}
+interface PluginAuthor {
+ name: string;
+ url?: string;
+}
+
+interface PluginSource {
+ source: string;
+ repo?: string;
+ path?: string;
+}
+
interface Plugin {
id: string;
name: string;
@@ -90,6 +102,12 @@ interface Plugin {
path: string;
items: PluginItem[];
tags?: string[];
+ external?: boolean;
+ repository?: string | null;
+ homepage?: string | null;
+ author?: PluginAuthor | null;
+ license?: string | null;
+ source?: PluginSource | null;
}
interface PluginsData {
@@ -519,7 +537,114 @@ async function openPluginModal(
title.textContent = plugin.name;
document.title = `${plugin.name} | Awesome GitHub Copilot`;
- // Render plugin view
+ // Render external plugin view (metadata + links) or local plugin view (items list)
+ if (plugin.external) {
+ renderExternalPluginModal(plugin, modalContent);
+ } else {
+ renderLocalPluginModal(plugin, modalContent);
+ }
+}
+
+/**
+ * Get the best URL for an external plugin, preferring the deep path within the repo
+ */
+function getExternalPluginUrl(plugin: Plugin): string {
+ if (plugin.source?.source === "github" && plugin.source.repo) {
+ const base = `https://github.com/${plugin.source.repo}`;
+ return plugin.source.path ? `${base}/tree/main/${plugin.source.path}` : base;
+ }
+ // Sanitize URLs from JSON to prevent XSS via javascript:/data: schemes
+ return sanitizeUrl(plugin.repository || plugin.homepage);
+}
+
+/**
+ * Render modal content for an external plugin (no local files)
+ */
+function renderExternalPluginModal(
+ plugin: Plugin,
+ modalContent: HTMLElement
+): void {
+ const authorHtml = plugin.author?.name
+ ? `
`
+ : "";
+
+ const repoHtml = plugin.repository
+ ? ``
+ : "";
+
+ const homepageHtml =
+ plugin.homepage && plugin.homepage !== plugin.repository
+ ? ``
+ : "";
+
+ const licenseHtml = plugin.license
+ ? `
+ License
+ ${escapeHtml(plugin.license)}
+
`
+ : "";
+
+ const sourceHtml = plugin.source?.repo
+ ? `
+ Source
+ GitHub: ${escapeHtml(plugin.source.repo)}${plugin.source.path ? ` (${escapeHtml(plugin.source.path)})` : ""}
+
`
+ : "";
+
+ const repoUrl = getExternalPluginUrl(plugin);
+
+ modalContent.innerHTML = `
+
+
${escapeHtml(plugin.description || "")}
+ ${
+ plugin.tags && plugin.tags.length > 0
+ ? `
+ 🔗 External Plugin
+ ${plugin.tags.map((t) => `${escapeHtml(t)}`).join("")}
+
`
+ : `
+ 🔗 External Plugin
+
`
+ }
+
+ ${authorHtml}
+ ${repoHtml}
+ ${homepageHtml}
+ ${licenseHtml}
+ ${sourceHtml}
+
+
+
+ This is an external plugin maintained outside this repository. Browse the repository to see its contents and installation instructions.
+
+
+ `;
+}
+
+/**
+ * Render modal content for a local plugin (item list)
+ */
+function renderLocalPluginModal(
+ plugin: Plugin,
+ modalContent: HTMLElement
+): void {
modalContent.innerHTML = `
${escapeHtml(
diff --git a/website/src/scripts/pages/plugins.ts b/website/src/scripts/pages/plugins.ts
index 32968b6b..01e1a547 100644
--- a/website/src/scripts/pages/plugins.ts
+++ b/website/src/scripts/pages/plugins.ts
@@ -3,9 +3,20 @@
*/
import { createChoices, getChoicesValues, type Choices } from '../choices';
import { FuzzySearch, type SearchItem } from '../search';
-import { fetchData, debounce, escapeHtml, getGitHubUrl } from '../utils';
+import { fetchData, debounce, escapeHtml, getGitHubUrl, sanitizeUrl } from '../utils';
import { setupModal, openFileModal } from '../modal';
+interface PluginAuthor {
+ name: string;
+ url?: string;
+}
+
+interface PluginSource {
+ source: string;
+ repo?: string;
+ path?: string;
+}
+
interface Plugin extends SearchItem {
id: string;
name: string;
@@ -13,6 +24,12 @@ interface Plugin extends SearchItem {
tags?: string[];
featured?: boolean;
itemCount: number;
+ external?: boolean;
+ repository?: string | null;
+ homepage?: string | null;
+ author?: PluginAuthor | null;
+ license?: string | null;
+ source?: PluginSource | null;
}
interface PluginsData {
@@ -56,6 +73,15 @@ function applyFiltersAndRender(): void {
if (countEl) countEl.textContent = countText;
}
+function getExternalPluginUrl(plugin: Plugin): string {
+ if (plugin.source?.source === 'github' && plugin.source.repo) {
+ const base = `https://github.com/${plugin.source.repo}`;
+ return plugin.source.path ? `${base}/tree/main/${plugin.source.path}` : base;
+ }
+ // Sanitize URLs from JSON to prevent XSS via javascript:/data: schemes
+ return sanitizeUrl(plugin.repository || plugin.homepage);
+}
+
function renderItems(items: Plugin[], query = ''): void {
const list = document.getElementById('resource-list');
if (!list) return;
@@ -65,22 +91,35 @@ function renderItems(items: Plugin[], query = ''): void {
return;
}
- list.innerHTML = items.map(item => `
-
+ list.innerHTML = items.map(item => {
+ const isExternal = item.external === true;
+ const metaTag = isExternal
+ ? `
🔗 External`
+ : `
${item.itemCount} items`;
+ const authorTag = isExternal && item.author?.name
+ ? `
by ${escapeHtml(item.author.name)}`
+ : '';
+ const githubHref = isExternal
+ ? escapeHtml(getExternalPluginUrl(item))
+ : getGitHubUrl(item.path);
+
+ return `
+
${item.featured ? '⭐ ' : ''}${query ? search.highlight(item.name, query) : escapeHtml(item.name)}
${escapeHtml(item.description || 'No description')}
- ${item.itemCount} items
+ ${metaTag}
+ ${authorTag}
${item.tags?.slice(0, 4).map(t => `${escapeHtml(t)}`).join('') || ''}
${item.tags && item.tags.length > 4 ? `+${item.tags.length - 4} more` : ''}
-
- `).join('');
+
`;
+ }).join('');
// Add click handlers
list.querySelectorAll('.resource-item').forEach(el => {
diff --git a/website/src/scripts/utils.ts b/website/src/scripts/utils.ts
index 33eedaab..4b8e2a65 100644
--- a/website/src/scripts/utils.ts
+++ b/website/src/scripts/utils.ts
@@ -214,6 +214,24 @@ export function escapeHtml(text: string): string {
return div.innerHTML;
}
+/**
+ * Validate and sanitize URLs to prevent XSS attacks
+ * Only allows http/https protocols, returns '#' for invalid URLs
+ */
+export function sanitizeUrl(url: string | null | undefined): string {
+ if (!url) return '#';
+ try {
+ const parsed = new URL(url);
+ // Only allow http and https protocols
+ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
+ return url;
+ }
+ } catch {
+ // Invalid URL
+ }
+ return '#';
+}
+
/**
* Truncate text with ellipsis
*/
diff --git a/website/src/styles/global.css b/website/src/styles/global.css
index 640b99bd..c6b4ffbc 100644
--- a/website/src/styles/global.css
+++ b/website/src/styles/global.css
@@ -900,6 +900,71 @@ body:has(#main-content) {
color: var(--color-error);
}
+/* External plugin badge */
+.resource-tag-external {
+ background: var(--color-accent);
+ color: var(--color-bg);
+ font-weight: 500;
+}
+
+/* External plugin modal metadata */
+.external-plugin-metadata {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-bottom: 20px;
+ padding: 12px 16px;
+ background: var(--color-bg-tertiary);
+ border-radius: var(--border-radius);
+ border: 1px solid var(--color-border);
+}
+
+.external-plugin-meta-row {
+ display: flex;
+ align-items: baseline;
+ gap: 12px;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.external-plugin-meta-label {
+ color: var(--color-text-muted);
+ font-weight: 500;
+ min-width: 80px;
+ flex-shrink: 0;
+}
+
+.external-plugin-meta-value {
+ color: var(--color-text);
+ word-break: break-all;
+}
+
+.external-plugin-meta-value a {
+ color: var(--color-accent);
+ text-decoration: none;
+}
+
+.external-plugin-meta-value a:hover {
+ text-decoration: underline;
+}
+
+.external-plugin-cta {
+ margin-bottom: 16px;
+}
+
+.external-plugin-repo-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.external-plugin-note {
+ font-size: 13px;
+ color: var(--color-text-muted);
+ font-style: italic;
+ line-height: 1.5;
+}
+
/* Page Layouts */
.page-header {
padding: 56px 0 40px;