Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions eng/generate-website-data.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,15 @@ import {
parseSkillMetadata,
parseYamlFile,
} from "./yaml-parser.mjs";
import { readExternalPlugins } from "./external-plugin-validation.mjs";

const __filename = fileURLToPath(import.meta.url);

const WEBSITE_DIR = path.join(ROOT_FOLDER, "website");
const WEBSITE_DATA_DIR = path.join(WEBSITE_DIR, "public", "data");
const WEBSITE_SOURCE_DATA_DIR = path.join(WEBSITE_DIR, "data");
const EXTERNAL_CANVAS_KEYWORD = "canvas";
const EXTERNAL_CANVAS_PREVIEW_PATH = "assets/preview.png";

/**
* Ensure the output directory exists
Expand Down Expand Up @@ -97,6 +100,23 @@ function normalizeText(value, fallback = "") {
return typeof value === "string" ? value.trim() : fallback;
}

function normalizeRepoRelativePath(value) {
const normalized = normalizeText(value);
if (!normalized || normalized === "/") {
return "";
}

return normalized.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
}

function joinRepoPath(...segments) {
return segments
.map((segment) => String(segment ?? "").trim())
.filter(Boolean)
.join("/")
.replace(/\/+/g, "/");
}

/**
* Normalize an author value (npm string form or { name, url } object) to
* { name, url? } | null. Returns null when no usable name is present.
Expand Down Expand Up @@ -1055,6 +1075,67 @@ function normalizeExternalScreenshotRole(value, ref) {
};
}

function buildExternalRepoImageUrl(repo, locator, assetPath) {
if (!repo || !locator || !assetPath) {
return null;
}

const encodedLocator = locator
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
const encodedPath = assetPath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
return `https://raw.githubusercontent.com/${repo}/${encodedLocator}/${encodedPath}`;
}

function buildExternalRepoTreeUrl(repo, locator, pluginRoot) {
if (!repo) {
return null;
}

if (locator) {
const treePath = normalizeRepoRelativePath(pluginRoot);
const encodedLocator = locator
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
const encodedTreePath = treePath
? treePath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/")
: null;
const suffix = encodedTreePath ? `/${encodedTreePath}` : "";
return `https://github.com/${repo}/tree/${encodedLocator}${suffix}`;
}

return `https://github.com/${repo}`;
}

function hasCanvasKeyword(plugin) {
return normalizeExternalKeywords(plugin).some(
(keyword) => normalizeText(keyword).toLowerCase() === EXTERNAL_CANVAS_KEYWORD
);
}

function normalizeExternalKeywords(plugin) {
const source = Array.isArray(plugin?.keywords)
? plugin.keywords
: Array.isArray(plugin?.tags)
? plugin.tags
: [];

return [...new Set(
source
.filter((keyword) => typeof keyword === "string")
.map((keyword) => keyword.trim())
.filter(Boolean)
)].sort((a, b) => a.localeCompare(b));
}

function normalizeExtensionScreenshotRole(value, relPath, ref) {
if (!value) return null;
if (typeof value === "string") {
Expand Down Expand Up @@ -1320,6 +1401,93 @@ function generateCanvasManifest(gitDates, commitSha) {
}
}

const seenExtensionIds = new Set(items.map((item) => String(item.id).toLowerCase()));
const {
plugins: externalPlugins,
errors: externalPluginErrors,
warnings: externalPluginWarnings,
} = readExternalPlugins({ policy: "marketplace" });
externalPluginWarnings.forEach((warning) => console.warn(`Warning: ${warning}`));
if (externalPluginErrors.length > 0) {
externalPluginErrors.forEach((error) => console.error(`Error: ${error}`));
throw new Error("External plugin validation failed");
}

for (const ext of externalPlugins) {
if (!hasCanvasKeyword(ext)) {
continue;
}

const name = normalizeText(ext?.name);
if (!name) {
continue;
}
const displayName = formatDisplayName(name);

const id = normalizeText(ext?.name).toLowerCase().replace(/\s+/g, "-");
if (seenExtensionIds.has(id)) {
continue;
}

const source = ext?.source;
if (source?.source !== "github" || !normalizeText(source?.repo)) {
console.warn(`Warning: skipping external canvas "${name}" due to missing GitHub source`);
continue;
}

const locator = normalizeText(source.sha) || normalizeText(source.ref);
if (!locator) {
console.warn(`Warning: skipping external canvas "${name}" because source.sha or source.ref is required`);
continue;
}

const pluginRoot = normalizeRepoRelativePath(source.path);
const previewPath = joinRepoPath(pluginRoot, EXTERNAL_CANVAS_PREVIEW_PATH);
const imageUrl = buildExternalRepoImageUrl(source.repo, locator, previewPath);
const sourceUrl = buildExternalRepoTreeUrl(source.repo, locator, pluginRoot);
const externalSource = normalizeText(source.repo);
const keywords = normalizeExternalKeywords(ext);

items.push({
id,
canvasId: id,
extensionId: id,
extensionName: name,
pluginName: null,
name: displayName,
version: normalizeText(ext?.version, "1.0.0"),
readmeFile: null,
description: normalizeText(ext?.description, "External canvas extension"),
path: null,
ref: null,
lastUpdated: null,
screenshots: {
icon: imageUrl
? {
path: imageUrl,
type: getImageMimeType(EXTERNAL_CANVAS_PREVIEW_PATH),
}
: null,
gallery: imageUrl
? {
path: imageUrl,
type: getImageMimeType(EXTERNAL_CANVAS_PREVIEW_PATH),
}
: null,
},
imageUrl,
assetPath: null,
installUrl: null,
installCommand: null,
sourceUrl,
externalSource,
external: true,
author: normalizeAuthor(ext?.author),
keywords,
});
seenExtensionIds.add(id);
}

const sortedItems = items.sort((a, b) => a.name.localeCompare(b.name));
const keywordFilters = [...new Set(sortedItems.flatMap((item) => item.keywords || []))]
.filter(Boolean)
Expand Down
32 changes: 11 additions & 21 deletions website/src/pages/extension/[id].astro
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ interface ExtensionEntry extends HeaderItem {
installUrl?: string | null;
installCommand?: string | null;
sourceUrl?: string | null;
externalSource?: string | null;
external?: boolean;
author?: ExtensionAuthor | null;
keywords?: string[];
Expand Down Expand Up @@ -117,23 +118,21 @@ const installUrl = safeUrl(
: "")
);
const sourceUrl = safeUrl(item.sourceUrl);
const externalSource = (item.externalSource || "").trim();
const installCommand =
item.installCommand ||
(item.pluginName
? `copilot plugin install ${item.pluginName}@awesome-copilot`
: "");

const githubUrl = isExternal
? sourceUrl || installUrl || GITHUB_TREE
? sourceUrl || GITHUB_TREE
: item.path
? `${GITHUB_TREE}/${item.path}`
: installUrl || GITHUB_TREE;

// Sidebar "Source" shows item.path; external extensions have no repo path.
const sidebarItem =
isExternal && (sourceUrl || installUrl)
? { ...item, path: sourceUrl || installUrl }
: item;
const sidebarItem = isExternal && sourceUrl ? { ...item, path: sourceUrl } : item;

const shortHash = (value: string) =>
/^[0-9a-f]{40}$/i.test(value) ? value.slice(0, 7) : value;
Expand Down Expand Up @@ -262,22 +261,13 @@ if (!isExternal && item.ref) {
>
{
isExternal ? (
<div class="skill-install" slot="install">
<p class="skill-install-label">Install this extension</p>
<p class="skill-install-note">
This extension is maintained in an external repository.
</p>
{installUrl && (
<button
type="button"
class="btn btn-secondary skill-install-url-btn"
data-action="copy-install-url"
data-install-url={installUrl}
>
Copy install URL
</button>
)}
</div>
<PluginInstall
slot="install"
isExternal={true}
externalSource={externalSource || null}
label="Install this extension"
note="This extension is maintained in an external repository."
/>
) : (
<PluginInstall
slot="install"
Expand Down
26 changes: 19 additions & 7 deletions website/src/scripts/pages/extensions-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface RenderableExtension {
installCommand?: string | null;
installUrl?: string | null;
sourceUrl?: string | null;
externalSource?: string | null;
external?: boolean;
author?: { name: string; url?: string } | null;
}
Expand Down Expand Up @@ -95,13 +96,20 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string {
const sourceUrl = safeUrl(
item.sourceUrl || (item.path ? getGitHubUrl(item.path) : "")
);
const externalSource = (item.externalSource || "").trim();
const pluginId = item.pluginName || item.id;
const ghappInstallUrl =
!item.external && pluginId
? `ghapp://plugins/install?source=${encodeURIComponent(
`${pluginId}@awesome-copilot`
)}`
: "";
item.external
? externalSource
? `ghapp://plugins/marketplace/add?source=${encodeURIComponent(
externalSource
)}`
: ""
: pluginId
? `ghapp://plugins/install?source=${encodeURIComponent(
`${pluginId}@awesome-copilot`
)}`
: "";

const previewImageUrl = safeUrl(item.imageUrl);
const previewMediaHtml = previewImageUrl
Expand Down Expand Up @@ -152,14 +160,18 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string {
</a>`
: ""
}
<button
${
!item.external
? `<button
class="btn btn-secondary btn-small copy-install-url-btn"
data-install-url="${escapeHtml(installUrl)}"
title="Copy fallback URL install target"
${installUrl ? "" : "disabled"}
>
Copy URL
</button>
</button>`
: ""
}
${
sourceUrl
? `<a href="${escapeHtml(
Expand Down
Loading