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
3 changes: 3 additions & 0 deletions .github/workflows/deploy-website-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ jobs:
node-version: "24"
cache: "npm"

- name: Test plugin install links
run: node --test website/scripts/plugin-install.test.mjs

- name: Install root dependencies
run: npm ci

Expand Down
29 changes: 29 additions & 0 deletions website/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,35 @@ content aligned to the wordmark and a scrollable panel on short screens.
The mobile navigation regression checks menu bounds, padding, icon consistency,
and keyboard behavior.

## Plugin install links

Plugins and marketplace-backed canvas extensions offer **Install in VS Code**
on their catalog cards and detail pages, alongside existing install options.
The link opens VS Code's confirmation flow; it does not report installation
success in the browser. It requires a VS Code build with the `chat-plugin`
handler, plugins enabled, and Copilot on the Agent Host.

`src/lib/plugin-install.ts` builds links with `URLSearchParams`, for example
`vscode://chat-plugin/install?source=github%2Fawesome-copilot&plugin=ai-ready`.
The source is the marketplace repository even for curated external plugins.
Plugin records use their manifest name; canvas records use their owning
`pluginName`, never the canvas ID. External canvases without a marketplace
plugin keep source-only actions. Source and README URLs retain their HTTP(S)
sanitization.

The dependency-free URI tests run in the website preview CI build. Run them
locally, then check representative catalog and detail links against a built preview:

```bash
node --test website/scripts/plugin-install.test.mjs
npm run website:build
node website/scripts/plugin-install-regression.mjs
```

The browser regression covers local and external plugins, canvas owner mapping,
and source-only eligibility. It uses `SITE_BASE_URL` as described above and does
not launch the application or install plugins.

## Reading headers and catalog pagination

Resource details and learning articles let their expanded title, description,
Expand Down
45 changes: 45 additions & 0 deletions website/scripts/plugin-install-regression.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { chromium } from "playwright";

const base = process.env.SITE_BASE_URL ?? "http://127.0.0.1:4321";
const details = [
["/plugin/ai-ready/", "ai-ready"],
["/plugin/agent-council/", "agent-council"],
["/extension/daily-focus-board/", "ember"],
["/extension/azure-cost-health-check/", "azure-cost-health-check"],
["/extension/coffilot/", null],
];
const browser = await chromium.launch();
try {
const page = await browser.newPage();
for (const route of ["/plugins/", "/extensions/"]) {
const response = await page.goto(new URL(route, base).href);
assert.equal(response.status(), 200, route);
await page.waitForFunction(() => !document.querySelector("astro-island[ssr]"));
const links = page.locator('#catalog a[href^="vscode://chat-plugin/install"]');
assert.ok(await links.count() > 0, `${route}: visible VS Code actions`);
for (const link of await links.all()) {
const url = new URL(await link.getAttribute("href"));
assert.equal(url.searchParams.get("source"), "github/awesome-copilot");
assert.ok(url.searchParams.get("plugin"));
assert.match(await link.getAttribute("aria-label"), /^Install in VS Code: .+$/);
assert.equal(await link.evaluate((element) => element.parentElement.closest("a, button")), null,
"install action is not nested inside the card link");
}
console.log(`PASS ${route}: catalog install links`);
}
for (const [route, plugin] of details) {
const response = await page.goto(new URL(route, base).href);
assert.equal(response.status(), 200, route);
await page.waitForFunction(() => !document.querySelector("astro-island[ssr]"));
const link = page.locator("[data-hero-actions]").getByRole("link", { name: "Install in VS Code", exact: true });
assert.equal(await link.count(), plugin ? 1 : 0, route);
if (plugin) {
assert.equal(await link.getAttribute("href"),
`vscode://chat-plugin/install?source=github%2Fawesome-copilot&plugin=${plugin}`, route);
}
console.log(`PASS ${route}: owning-plugin install eligibility`);
}
} finally {
await browser.close();
}
27 changes: 27 additions & 0 deletions website/scripts/plugin-install.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { buildVSCodePluginInstallUrl } from "../src/lib/plugin-install.ts";

test("uses the awesome-copilot marketplace repository", () => {
assert.equal(
buildVSCodePluginInstallUrl("ai-ready"),
"vscode://chat-plugin/install?source=github%2Fawesome-copilot&plugin=ai-ready",
);
});

test("encodes query values once without introducing extra parameters", () => {
const pluginName = "name&plugin=other +/%?#";
const url = new URL(buildVSCodePluginInstallUrl(pluginName));
assert.deepEqual([...url.searchParams], [
["source", "github/awesome-copilot"],
["plugin", pluginName],
]);
assert.equal(url.hash, "");
assert.match(url.search, /source=github%2Fawesome-copilot/);
});

test("rejects missing, qualified, or control-character plugin names", () => {
for (const name of ["", " ", "name@marketplace", "name\n", "name\u0000", "name\u007f"]) {
assert.throws(() => buildVSCodePluginInstallUrl(name), /Invalid marketplace plugin name/);
}
});
22 changes: 17 additions & 5 deletions website/src/components/brand/ExtensionDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,10 @@ export type ExtensionDetailProps = {
images: ExtensionDetailImage[];
/** Rendered, sanitized README with heading ids already stamped. */
markdownHtml: string;
/** `ghapp://` deep link — absent for external extensions. */
/** `ghapp://` deep link, available for marketplace plugins. */
installUrl?: string | null;
/** Copilot CLI command — absent for external extensions. */
vscodeInstallUrl?: string | null;
/** Copilot CLI command, available for marketplace plugins. */
installCommand?: string | null;
/** Repository the extension is published from. */
sourceUrl?: string | null;
Expand Down Expand Up @@ -95,15 +96,16 @@ function InstallCommand({ command }: { command: string }) {
*
* Two things set it apart from the other resource details: the preview
* screenshot leads the content column at hero scale (a gallery when an
* extension ships more than one), and installation has two paths — the
* `ghapp://` deep link and the Copilot CLI command. Externally hosted
* extensions carry neither, so they fall back to a link to their source repo.
* extension ships more than one), and installation targets the owning plugin
* in the Copilot app, VS Code, or CLI. Extensions without a marketplace plugin
* fall back to a link to their source repo.
*/
export function ExtensionDetail({
item,
images,
markdownHtml,
installUrl,
vscodeInstallUrl,
installCommand,
sourceUrl,
lastUpdated,
Expand All @@ -127,6 +129,16 @@ export function ExtensionDetail({
Open in Copilot app
</Button>
) : null}
{vscodeInstallUrl ? (
<Button
as="a"
href={vscodeInstallUrl}
variant="secondary"
hasArrow={false}
>
Install in VS Code
</Button>
) : null}
{sourceUrl ? (
<Button
as="a"
Expand Down
15 changes: 15 additions & 0 deletions website/src/components/brand/ExtensionsCatalog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { pageHref } from "./pageHref";
import type { SearchItem } from "./searchIndex";
import styles from "./styles/extensions.module.css";
import { useCatalogPageFocus } from "./useCatalogPageFocus";
import { buildVSCodePluginInstallUrl } from "../../lib/plugin-install";

const CONTRIBUTE_URL =
"https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md#adding-canvas-extensions";
Expand Down Expand Up @@ -394,6 +395,9 @@ export function ExtensionsCatalog({
const detailHref = pageHref(`extension/${ext.id}`);
const sourceUrl = extensionSourceUrl(ext);
const installUrl = appInstallUrl(ext);
const vscodeInstallUrl = ext.pluginName
? buildVSCodePluginInstallUrl(ext.pluginName)
: null;
const safeImageUrl = httpUrl(ext.imageUrl);
return (
<Grid.Column
Expand Down Expand Up @@ -445,6 +449,17 @@ export function ExtensionsCatalog({
Open in Copilot app
</Button>
) : null}
{vscodeInstallUrl ? (
<Button
as="a"
href={vscodeInstallUrl}
variant="secondary"
hasArrow={false}
aria-label={`Install in VS Code: ${ext.name}`}
>
Install in VS Code
</Button>
) : null}
{ext.installCommand ? (
<CopyInstallButton
ext={ext}
Expand Down
14 changes: 12 additions & 2 deletions website/src/components/brand/PluginDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export type PluginDetailProps = {
installCommand: string;
/** `ghapp://` deep link that installs this plugin into the Copilot app. */
appInstallUrl: string;
vscodeInstallUrl: string;
lastUpdated?: string | null;
previous?: DetailSibling;
next?: DetailSibling;
Expand Down Expand Up @@ -108,8 +109,8 @@ function itemSourceUrl(item: PluginIncludedItem, fallback: string): string {
*
* Differs from the other resource detail pages in three ways: the plugin's
* bundled contents are rendered as a card grid linking to each item's own
* detail page, installation deep-links into the Copilot app (with the CLI
* command offered as a secondary path), and externally-hosted plugins carry
* detail page, installation deep-links into the Copilot app or VS Code (with
* the CLI command offered as another path), and externally-hosted plugins carry
* provenance badges.
*/
export function PluginDetail({
Expand All @@ -118,6 +119,7 @@ export function PluginDetail({
githubUrl,
installCommand,
appInstallUrl,
vscodeInstallUrl,
lastUpdated,
previous,
next,
Expand Down Expand Up @@ -172,6 +174,14 @@ export function PluginDetail({
</ActionMenu.Item>
</ActionMenu.Overlay>
</ActionMenu>
<Button
as="a"
href={vscodeInstallUrl}
variant="secondary"
hasArrow={false}
>
Install in VS Code
</Button>
<Button
as="a"
href={githubUrl}
Expand Down
12 changes: 11 additions & 1 deletion website/src/components/brand/PluginsCatalog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { clsx } from "clsx";
import { useMemo, useState } from "react";

import { externalRepoUrl, type ExternalSource } from "../../lib/external-source";
import { buildVSCodePluginInstallUrl } from "../../lib/plugin-install";
import { PageShell } from "./PageShell";
import { CatalogSortControl, CATALOG_SORT_OPTIONS } from "./CatalogSortControl";
import {
Expand Down Expand Up @@ -372,8 +373,17 @@ export function PluginsCatalog({
<div className={styles.cardActions}>
<Button
as="a"
href={sourceUrlOf(plugin)}
href={buildVSCodePluginInstallUrl(plugin.name)}
variant="primary"
hasArrow={false}
aria-label={`Install in VS Code: ${plugin.name}`}
>
Install in VS Code
</Button>
<Button
as="a"
href={sourceUrlOf(plugin)}
variant="secondary"
leadingVisual={MarkGithubIcon}
target="_blank"
rel="noopener noreferrer"
Expand Down
8 changes: 8 additions & 0 deletions website/src/lib/plugin-install.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function buildVSCodePluginInstallUrl(pluginName: string): string {
if (!pluginName.trim() || /[@\u0000-\u001f\u007f]/.test(pluginName)) {
throw new Error(`Invalid marketplace plugin name: ${pluginName}`);
}

const query = new URLSearchParams({ source: "github/awesome-copilot", plugin: pluginName });
return `vscode://chat-plugin/install?${query}`;
}
11 changes: 7 additions & 4 deletions website/src/pages/extension/[id].astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import extensionsData from "../../../public/data/extensions.json";
import { searchIndex } from "../../lib/site-data";
import { formatLastUpdated, readResourceMarkdown } from "../../lib/detail-page";
import { sanitizeHttpUrl } from "../../lib/external-source";
import { buildVSCodePluginInstallUrl } from "../../lib/plugin-install";

const RAW_BASE = "https://raw.githubusercontent.com/github/awesome-copilot";

Expand Down Expand Up @@ -56,8 +57,7 @@ const { item, previous, next } = Astro.props as {
next?: ExtensionItem;
};

// Only http(s) URLs may reach an href/src; anything else collapses to "" so the
// truthiness guards below drop it.
// Untrusted source and asset URLs must be http(s).
const safeUrl = (value?: string | null): string => {
const sanitized = sanitizeHttpUrl(value);
return sanitized === "#" ? "" : sanitized;
Expand Down Expand Up @@ -99,8 +99,7 @@ const images = Array.from(
: `${item.name} screenshot ${index + 1}`,
}));

// `loadDetailPage` only covers the VS Code-installable resource types; an
// extension body is its README, read straight off disk when one exists.
// An extension body is its README, read straight off disk when one exists.
const { markdownHtml } = !item.external && item.readmeFile
? readResourceMarkdown(item.readmeFile, item.ref ? {
rawBase: `${RAW_BASE}/${encodeURIComponent(item.ref)}`,
Expand All @@ -122,6 +121,9 @@ const pluginSource = item.pluginName
const appInstallUrl = pluginSource
? `ghapp://plugins/install?source=${encodeURIComponent(pluginSource)}`
: null;
const vscodeInstallUrl = item.pluginName
? buildVSCodePluginInstallUrl(item.pluginName)
: null;
const sourceUrl = safeUrl(item.sourceUrl) || safeUrl(item.installUrl) || null;

const base = import.meta.env.BASE_URL ?? "/";
Expand All @@ -144,6 +146,7 @@ const siblingOf = (sibling?: ExtensionItem) =>
images={images}
markdownHtml={html}
installUrl={appInstallUrl}
vscodeInstallUrl={vscodeInstallUrl}
installCommand={item.installCommand}
sourceUrl={sourceUrl}
lastUpdated={lastUpdated}
Expand Down
3 changes: 3 additions & 0 deletions website/src/pages/plugin/[id].astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { searchIndex } from "../../lib/site-data";
import { formatLastUpdated, readResourceMarkdown } from "../../lib/detail-page";
import { finalizePluginReadme, renderPluginReadme } from "../../lib/plugin-readme";
import { externalRepoUrl, type ExternalSource } from "../../lib/external-source";
import { buildVSCodePluginInstallUrl } from "../../lib/plugin-install";

const GITHUB_TREE = "https://github.com/github/awesome-copilot/tree/main";

Expand Down Expand Up @@ -80,6 +81,7 @@ const githubUrl = item.external
: `${GITHUB_TREE}/${item.path}`;

const installCommand = `copilot plugin install ${item.id}@awesome-copilot`;
const vscodeInstallUrl = buildVSCodePluginInstallUrl(item.name);

// Deep link handled by the Copilot app's `ghapp://` protocol handler. The
// marketplace-qualified source is encoded because the `@` separator is not
Expand Down Expand Up @@ -112,6 +114,7 @@ const siblingOf = (sibling?: PluginItem) =>
githubUrl={githubUrl}
installCommand={installCommand}
appInstallUrl={appInstallUrl}
vscodeInstallUrl={vscodeInstallUrl}
lastUpdated={lastUpdated}
previous={siblingOf(previous)}
next={siblingOf(next)}
Expand Down
Loading