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 + ? `
+ Author + ${ + plugin.author.url + ? `${escapeHtml(plugin.author.name)}` + : escapeHtml(plugin.author.name) + } +
` + : ""; + + const repoHtml = plugin.repository + ? `
+ Repository + ${escapeHtml(plugin.repository)} +
` + : ""; + + const homepageHtml = + plugin.homepage && plugin.homepage !== plugin.repository + ? `
+ Homepage + ${escapeHtml(plugin.homepage)} +
` + : ""; + + 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} +
+
+ + View Repository → + +
+
+ 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;