feat(website): rebuild site on Primer Brand design prototype
Replace the Astro + Starlight site with the Brand Engineering design prototype, ported component-for-component onto plain Astro + React islands. The prototype is treated as the authority on markup and styling; dynamic data is injected into its components rather than the components being reinterpreted. Framework: - Remove @astrojs/starlight entirely, along with its document shell, search, footer and language selector. BaseLayout.astro now owns the document head, CSP, social meta and analytics. - Add @primer/react-brand and @astrojs/react. Alias the package to its ESM build in vite.resolve, since the default CJS entrypoint breaks named-export detection during SSR, and mark it noExternal so its stylesheet imports resolve. - Promote pagefind to an explicit devDependency; it was previously pulled in transitively by Starlight. Pages: home, the five catalogs, the five detail routes, contributors, Playbook index and articles, and the cookbook are all rendered by ported prototype components inside a shared PageShell. Detail pages share a DetailChassis (hero, breadcrumbs, sticky TOC with scroll-spy, prev/next) while keeping what makes each type distinct: a file switcher over bundled skill assets, an included-items grid and external provenance for plugins, and a hero-scale preview for extensions. i18n: resolve translated Playbook entries via Astro.currentLocale inside the shared article route instead of separate [locale] routes. The explicit routes collided with the i18n fallback routes, so translated articles were being shadowed by their English originals and 900 nonsensical double-locale pages were emitted. Search: TopNavSearch now queries the Pagefind index client-side in addition to the static resource index, merging on href and degrading to the static index in dev, where no index has been built. Also delete the superseded vanilla-TS page renderers and Astro partials, which the ported React components fully replace. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 80686fef-efe3-4cdd-8cd6-bfa61a5d0af6
@@ -1,15 +1,13 @@
|
||||
import { unified } from "@astrojs/markdown-remark";
|
||||
import sitemap from "@astrojs/sitemap";
|
||||
import starlight from "@astrojs/starlight";
|
||||
import react from "@astrojs/react";
|
||||
import { defineConfig } from "astro/config";
|
||||
import remarkGithubAdmonitionsToDirectives from "remark-github-admonitions-to-directives";
|
||||
import pagefindResources from "./src/integrations/pagefind-resources";
|
||||
|
||||
// Learning Hub course content mirrored from external workshop repos is authored
|
||||
// in GitHub admonition syntax (`> [!NOTE]`). This remark plugin rewrites those
|
||||
// callouts into Starlight aside directives before Starlight renders them, so the
|
||||
// same syntax used in the source repos and on github.com also produces styled
|
||||
// callouts here. The mapping targets Starlight's aside types (note / tip / caution).
|
||||
// Playbook course content mirrored from external workshop repos is authored in
|
||||
// GitHub admonition syntax (`> [!NOTE]`). This remark plugin rewrites those
|
||||
// callouts into directives before rendering, so the same syntax used in the
|
||||
// source repos and on github.com also produces styled callouts here.
|
||||
const githubAdmonitionMapping = {
|
||||
NOTE: "note",
|
||||
TIP: "tip",
|
||||
@@ -19,15 +17,6 @@ const githubAdmonitionMapping = {
|
||||
};
|
||||
|
||||
const site = "https://awesome-copilot.github.com/";
|
||||
const siteDescription =
|
||||
"Community-contributed agents, instructions, and skills to enhance your GitHub Copilot experience";
|
||||
// Social preview image used for all Open Graph / Twitter cards (e.g. LinkedIn, which is
|
||||
// Open Graph-driven). socialImageWidth/Height MUST match the actual pixels of social-image.png.
|
||||
// If a page ever overrides og:image, also override og:image:width/height and twitter:image
|
||||
// (Head.astro derives og:image:secure_url from twitter:image first).
|
||||
const socialImageUrl = new URL("/images/social-image.png", site).toString();
|
||||
const socialImageWidth = "2400";
|
||||
const socialImageHeight = "1260";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
@@ -35,238 +24,31 @@ export default defineConfig({
|
||||
base: "/",
|
||||
output: "static",
|
||||
markdown: {
|
||||
// Astro 7.1+ uses Sätteri as the default Markdown processor, but its plugin
|
||||
// API is incompatible with remark/rehype plugins. We explicitly opt into the
|
||||
// unified() (remark/rehype) processor so we can keep using
|
||||
// remark-github-admonitions-to-directives, which rewrites > [!NOTE] callouts
|
||||
// from Learning Hub course content into Starlight aside directives.
|
||||
processor: unified({
|
||||
remarkPlugins: [
|
||||
[remarkGithubAdmonitionsToDirectives, { mapping: githubAdmonitionMapping }],
|
||||
],
|
||||
}),
|
||||
},
|
||||
integrations: [
|
||||
starlight({
|
||||
title: "Awesome GitHub Copilot",
|
||||
favicon: "/images/favicon.svg",
|
||||
description: siteDescription,
|
||||
social: [],
|
||||
head: [
|
||||
{
|
||||
tag: "meta",
|
||||
attrs: {
|
||||
property: "og:image",
|
||||
content: socialImageUrl,
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: "meta",
|
||||
attrs: {
|
||||
property: "og:image:width",
|
||||
content: socialImageWidth,
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: "meta",
|
||||
attrs: {
|
||||
property: "og:image:height",
|
||||
content: socialImageHeight,
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: "meta",
|
||||
attrs: {
|
||||
property: "og:image:alt",
|
||||
content: siteDescription,
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: "meta",
|
||||
attrs: {
|
||||
name: "twitter:image",
|
||||
content: socialImageUrl,
|
||||
},
|
||||
},
|
||||
],
|
||||
customCss: [
|
||||
"./src/styles/starlight-overrides.css",
|
||||
"./src/styles/global.css",
|
||||
],
|
||||
// English is served at the site root (no locale prefix), preserving all
|
||||
// existing URLs. Additional locales are served under a locale prefix
|
||||
// (e.g. /es-es/…) and fall back to the English page when a translation
|
||||
// does not yet exist. These keys match the locale directory names used by
|
||||
// mirrored Learning Hub course content (website/src/content/docs/<locale>/…).
|
||||
defaultLocale: "root",
|
||||
locales: {
|
||||
root: { label: "English", lang: "en" },
|
||||
"es-es": { label: "Español", lang: "es-ES" },
|
||||
"ja-jp": { label: "日本語", lang: "ja-JP" },
|
||||
"ko-kr": { label: "한국어", lang: "ko-KR" },
|
||||
"pt-br": { label: "Português (Brasil)", lang: "pt-BR" },
|
||||
"zh-cn": { label: "简体中文", lang: "zh-CN" },
|
||||
// (e.g. /es-es/…) and fall back to the English page when a translation does
|
||||
// not yet exist. These keys match the locale directory names used by mirrored
|
||||
// Playbook course content (website/src/content/docs/<locale>/…).
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en", "es-es", "ja-jp", "ko-kr", "pt-br", "zh-cn"],
|
||||
routing: {
|
||||
prefixDefaultLocale: false,
|
||||
fallbackType: "rewrite",
|
||||
},
|
||||
editLink: {
|
||||
baseUrl:
|
||||
"https://github.com/github/awesome-copilot/edit/staged/website/",
|
||||
fallback: {
|
||||
"es-es": "en",
|
||||
"ja-jp": "en",
|
||||
"ko-kr": "en",
|
||||
"pt-br": "en",
|
||||
"zh-cn": "en",
|
||||
},
|
||||
sidebar: [
|
||||
{
|
||||
label: "Fundamentals",
|
||||
items: [
|
||||
"learning-hub/github-copilot-app",
|
||||
"learning-hub/working-with-canvas-extensions",
|
||||
"learning-hub/using-automations-in-copilot-app",
|
||||
"learning-hub/what-are-agents-skills-instructions",
|
||||
"learning-hub/agents-and-subagents",
|
||||
"learning-hub/understanding-copilot-context",
|
||||
"learning-hub/copilot-configuration-basics",
|
||||
"learning-hub/defining-custom-instructions",
|
||||
"learning-hub/creating-effective-skills",
|
||||
"learning-hub/building-custom-agents",
|
||||
"learning-hub/understanding-mcp-servers",
|
||||
"learning-hub/automating-with-hooks",
|
||||
"learning-hub/agentic-workflows",
|
||||
"learning-hub/using-copilot-coding-agent",
|
||||
"learning-hub/installing-and-using-plugins",
|
||||
"learning-hub/before-after-customization-examples",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Reference",
|
||||
items: ["learning-hub/github-copilot-terminology-glossary"],
|
||||
},
|
||||
{
|
||||
label: "Copilot CLI for Beginners",
|
||||
items: [
|
||||
{
|
||||
label: "Overview",
|
||||
link: "/learning-hub/cli-for-beginners/",
|
||||
},
|
||||
"learning-hub/cli-for-beginners/00-quick-start",
|
||||
"learning-hub/cli-for-beginners/01-setup-and-first-steps",
|
||||
"learning-hub/cli-for-beginners/02-context-and-conversations",
|
||||
"learning-hub/cli-for-beginners/03-development-workflows",
|
||||
"learning-hub/cli-for-beginners/04-agents-and-custom-instructions",
|
||||
"learning-hub/cli-for-beginners/05-skills",
|
||||
"learning-hub/cli-for-beginners/06-mcp-servers",
|
||||
"learning-hub/cli-for-beginners/07-putting-it-all-together",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Copilot Workshops",
|
||||
items: [
|
||||
{
|
||||
label: "Overview",
|
||||
link: "/learning-hub/copilot-workshops/",
|
||||
},
|
||||
{
|
||||
label: "VS Code",
|
||||
items: [
|
||||
{
|
||||
label: "Overview",
|
||||
link: "/learning-hub/copilot-workshops/vscode/",
|
||||
},
|
||||
"learning-hub/copilot-workshops/vscode/0-prerequisites",
|
||||
"learning-hub/copilot-workshops/vscode/1-custom-instructions",
|
||||
"learning-hub/copilot-workshops/vscode/2-agent-mode",
|
||||
"learning-hub/copilot-workshops/vscode/3-mcp",
|
||||
"learning-hub/copilot-workshops/vscode/4-custom-agents",
|
||||
"learning-hub/copilot-workshops/vscode/5-managing-agents",
|
||||
"learning-hub/copilot-workshops/vscode/6-iterating",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Copilot CLI",
|
||||
items: [
|
||||
{
|
||||
label: "Overview",
|
||||
link: "/learning-hub/copilot-workshops/cli/",
|
||||
},
|
||||
"learning-hub/copilot-workshops/cli/0-prerequisites",
|
||||
"learning-hub/copilot-workshops/cli/1-install-copilot-cli",
|
||||
"learning-hub/copilot-workshops/cli/2-custom-instructions",
|
||||
"learning-hub/copilot-workshops/cli/3-generating-code",
|
||||
"learning-hub/copilot-workshops/cli/4-mcp",
|
||||
"learning-hub/copilot-workshops/cli/5-agent-skills",
|
||||
"learning-hub/copilot-workshops/cli/6-custom-agents",
|
||||
"learning-hub/copilot-workshops/cli/7-slash-commands",
|
||||
"learning-hub/copilot-workshops/cli/8-review",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Copilot App",
|
||||
items: [
|
||||
{
|
||||
label: "Overview",
|
||||
link: "/learning-hub/copilot-workshops/app/",
|
||||
},
|
||||
"learning-hub/copilot-workshops/app/0-prerequisites",
|
||||
"learning-hub/copilot-workshops/app/1-install-copilot-app",
|
||||
"learning-hub/copilot-workshops/app/2-add-star-rating",
|
||||
"learning-hub/copilot-workshops/app/3-custom-instructions",
|
||||
"learning-hub/copilot-workshops/app/4-build-filtering",
|
||||
"learning-hub/copilot-workshops/app/5-mcp-playwright",
|
||||
"learning-hub/copilot-workshops/app/6-agent-merge",
|
||||
"learning-hub/copilot-workshops/app/7-canvases",
|
||||
"learning-hub/copilot-workshops/app/8-review",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Copilot Cloud Agent",
|
||||
items: [
|
||||
{
|
||||
label: "Overview",
|
||||
link: "/learning-hub/copilot-workshops/cloud/",
|
||||
},
|
||||
"learning-hub/copilot-workshops/cloud/0-prerequisites",
|
||||
"learning-hub/copilot-workshops/cloud/1-custom-instructions",
|
||||
"learning-hub/copilot-workshops/cloud/2-cloud-agent",
|
||||
"learning-hub/copilot-workshops/cloud/3-custom-agents",
|
||||
"learning-hub/copilot-workshops/cloud/4-managing-agents",
|
||||
"learning-hub/copilot-workshops/cloud/5-iterating",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Hands-on",
|
||||
items: [
|
||||
{
|
||||
label: "Cookbook",
|
||||
link: "/learning-hub/cookbook/",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Browse Resources",
|
||||
items: [
|
||||
{ label: "Home", link: "/" },
|
||||
{ label: "Agents", link: "/agents/" },
|
||||
{ label: "Instructions", link: "/instructions/" },
|
||||
{ label: "Skills", link: "/skills/" },
|
||||
{ label: "Canvas Extensions", link: "/extensions/" },
|
||||
{ label: "Plugins", link: "/plugins/" },
|
||||
{ label: "Contributors", link: "/contributors/" },
|
||||
],
|
||||
},
|
||||
],
|
||||
disable404Route: true,
|
||||
// pagefind: true is required so Starlight renders the search UI.
|
||||
// Our pagefindResources() integration overwrites the index after build.
|
||||
pagefind: true,
|
||||
tableOfContents: { minHeadingLevel: 2, maxHeadingLevel: 3 },
|
||||
components: {
|
||||
Head: "./src/components/Head.astro",
|
||||
Footer: "./src/components/Footer.astro",
|
||||
Search: "./src/components/Search.astro",
|
||||
LanguageSelect: "./src/components/LanguageSelect.astro",
|
||||
},
|
||||
}),
|
||||
sitemap(),
|
||||
pagefindResources(),
|
||||
],
|
||||
integrations: [react(), sitemap(), pagefindResources()],
|
||||
redirects: {
|
||||
"/samples/": "/learning-hub/cookbook/",
|
||||
},
|
||||
@@ -275,12 +57,25 @@ export default defineConfig({
|
||||
},
|
||||
trailingSlash: "always",
|
||||
vite: {
|
||||
// @primer/react-brand's default entrypoint is CJS, so Node's ESM loader
|
||||
// cannot detect its named exports during SSR. The package also ships a
|
||||
// proper ESM build; alias to it so named imports resolve in both the
|
||||
// server render and the client bundle.
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
// Only the bare package specifier (the JS entrypoint) is redirected;
|
||||
// subpath imports such as `/lib/css/main.css` must resolve normally.
|
||||
find: /^@primer\/react-brand$/,
|
||||
replacement: "@primer/react-brand/esm",
|
||||
},
|
||||
],
|
||||
// The ESM build imports its own .css files, which Node's loader cannot
|
||||
// handle, so it has to be bundled rather than externalised for SSR.
|
||||
noExternal: ["@primer/react-brand"],
|
||||
},
|
||||
build: {
|
||||
// Production sourcemaps trigger a known warning in the expressive-code Vite plugin.
|
||||
// The docs site does not need emitted JS sourcemaps for its validation build.
|
||||
sourcemap: false,
|
||||
// Starlight ships large syntax-highlighting chunks that are expected for this site.
|
||||
// Raise the threshold so Vite only warns on materially larger regressions.
|
||||
chunkSizeWarningLimit: 900,
|
||||
},
|
||||
css: {
|
||||
|
||||
@@ -20,20 +20,26 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@astrojs/markdown-remark": "^7.2.0",
|
||||
"@astrojs/react": "^6.0.2",
|
||||
"@astrojs/sitemap": "^3.7.2",
|
||||
"@astrojs/starlight": "^0.41.1",
|
||||
"@primer/octicons-react": "^19.33.0",
|
||||
"@primer/react-brand": "^0.72.1-rc.c5f3a3b5",
|
||||
"astro": "^7.1.3",
|
||||
"choices.js": "^11.2.2",
|
||||
"clsx": "^2.1.1",
|
||||
"front-matter": "^4.0.2",
|
||||
"gray-matter": "^4.0.3",
|
||||
"isomorphic-dompurify": "^2.36.0",
|
||||
"jszip": "^3.10.1",
|
||||
"marked": "^18.0.2",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"remark-github-admonitions-to-directives": "^2.1.0",
|
||||
"shiki": "^4.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"axe-core": "^4.10.2",
|
||||
"pagefind": "^1.5.2",
|
||||
"playwright": "^1.49.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="20" viewBox="0 0 24 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.9429 0.185587C14.0827 0.209694 14.3432 0.283749 14.4819 0.324577C15.4445 0.602404 16.3345 1.08816 17.0889 1.74755C18.0697 2.61357 18.8014 3.77996 19.0743 5.06576C19.1496 5.42018 19.1738 5.69532 19.1964 6.05452C19.9052 6.47241 21.2122 6.2735 21.9765 6.03087C22.1873 5.96385 22.3845 5.87582 22.6099 5.82415C22.9897 5.73725 23.1819 6.00675 23.0693 6.35913C22.8743 6.87065 22.4732 7.20858 21.9897 7.44881C22.0123 7.61916 21.9954 7.74974 21.9265 7.90591C21.8274 8.12719 21.6671 8.31554 21.4645 8.4488C20.9198 8.80304 19.9085 8.88554 19.275 9.03554C19.0621 9.08601 18.8856 9.11698 18.6757 9.20013C18.6987 9.4758 18.8254 9.88842 18.8772 10.1801C19.0389 11.0894 19.1707 11.983 19.1081 12.9078C18.9947 14.5849 18.2823 16.1646 17.0032 17.2757C15.9232 18.2137 13.9932 19.1155 12.5675 19.2694C11.871 19.3447 10.4044 19.2245 9.68554 19.1726C9.05337 19.1213 8.36025 19.0719 7.74226 18.9414C7.30463 18.8528 6.9007 18.6595 6.50142 18.4785C4.66837 17.648 3.37669 16.1668 2.36961 14.4607C2.08619 13.9807 1.77939 13.4403 1.45674 12.9741C1.30495 12.7518 1.14267 12.5371 0.970473 12.3303C0.725396 12.0342 0.394927 11.6638 0.219525 11.3284C0.0631726 11.034 -0.0120767 10.7034 0.00148597 10.3704C0.0188007 10.0126 0.141781 9.70504 0.407086 9.45774C0.866194 9.02986 1.65549 9.09982 2.25038 9.13383C2.46709 8.8137 2.77553 8.62464 3.16888 8.62028C3.29122 8.61884 3.46177 8.64084 3.56944 8.59358C3.83632 8.47664 3.99491 8.29038 4.27854 8.18377C4.77063 7.99883 5.45129 8.23071 5.90622 8.44268C5.91912 8.08903 5.8361 7.70143 5.75546 7.35716C5.5471 6.46729 5.44281 5.76631 5.63389 4.85059C5.95244 3.32398 6.76008 2.08157 8.07312 1.2288C9.88348 0.0530301 11.8548 -0.242356 13.9429 0.185587Z" fill="#5FED83"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -1,129 +0,0 @@
|
||||
---
|
||||
// Back to Top Button Component
|
||||
---
|
||||
|
||||
<button
|
||||
id="back-to-top"
|
||||
class="back-to-top"
|
||||
aria-label="Back to top"
|
||||
title="Back to top"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 15l-6-6-6 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const button = document.getElementById('back-to-top');
|
||||
if (!button) return;
|
||||
|
||||
// Move button to body level to escape stacking contexts
|
||||
if (button.parentElement !== document.body) {
|
||||
document.body.appendChild(button);
|
||||
}
|
||||
|
||||
// Show/hide button based on scroll position
|
||||
function toggleVisibility() {
|
||||
const scrollY = window.scrollY || document.documentElement.scrollTop;
|
||||
if (scrollY > 400) {
|
||||
button.classList.add('visible');
|
||||
} else {
|
||||
button.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to top with smooth behavior
|
||||
function scrollToTop() {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
|
||||
// Throttled scroll handler
|
||||
let ticking = false;
|
||||
window.addEventListener('scroll', () => {
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
toggleVisibility();
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
// Click handler
|
||||
button.addEventListener('click', scrollToTop);
|
||||
|
||||
// Initial check
|
||||
toggleVisibility();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style is:global>
|
||||
.back-to-top {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(20px) scale(0.8);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 4px 12px rgba(133, 52, 243, 0.4);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.back-to-top.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.back-to-top:hover {
|
||||
background: var(--color-accent-hover);
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
box-shadow: 0 6px 20px rgba(133, 52, 243, 0.5);
|
||||
}
|
||||
|
||||
.back-to-top:active {
|
||||
transform: translateY(0) scale(0.95);
|
||||
}
|
||||
|
||||
.back-to-top:focus-visible {
|
||||
outline: 2px solid var(--color-text-emphasis);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.back-to-top {
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Respect reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.back-to-top {
|
||||
transition: opacity 0.2s ease;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.back-to-top.visible {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,108 +0,0 @@
|
||||
---
|
||||
interface Props {
|
||||
resourceType: string;
|
||||
}
|
||||
|
||||
const { resourceType } = Astro.props;
|
||||
const contributingUrl = 'https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md';
|
||||
const newIssueUrl = 'https://github.com/github/awesome-copilot/issues/new';
|
||||
---
|
||||
|
||||
<aside class="contribute-cta" aria-label="Contribute">
|
||||
<div class="contribute-cta-inner">
|
||||
<div class="contribute-cta-content">
|
||||
<h2 class="contribute-cta-title">Don't see what you're looking for?</h2>
|
||||
<p class="contribute-cta-description">This collection is community-driven. Share your own {resourceType} to help others get more out of GitHub Copilot.</p>
|
||||
</div>
|
||||
<div class="contribute-cta-actions">
|
||||
<a href={contributingUrl} class="btn btn-primary" target="_blank" rel="noopener">
|
||||
Contribute yours
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
|
||||
<path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href={newIssueUrl} class="btn btn-outline" target="_blank" rel="noopener">
|
||||
Request a resource
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.contribute-cta {
|
||||
margin-top: 48px;
|
||||
padding: 0 0 16px;
|
||||
}
|
||||
|
||||
.contribute-cta-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 32px;
|
||||
padding: 32px 40px;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.contribute-cta-inner::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--gradient-primary);
|
||||
}
|
||||
|
||||
.contribute-cta-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.contribute-cta-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-emphasis);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.contribute-cta-description {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.contribute-cta-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.contribute-cta-actions .btn {
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.contribute-cta-inner {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: 28px 24px;
|
||||
}
|
||||
|
||||
.contribute-cta-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.contribute-cta-actions .btn {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
import {
|
||||
getEmbeddedDataElementId,
|
||||
serializeEmbeddedData,
|
||||
} from "../scripts/embedded-data";
|
||||
|
||||
interface Props {
|
||||
filename: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
const { filename, data } = Astro.props;
|
||||
---
|
||||
|
||||
<script
|
||||
id={getEmbeddedDataElementId(filename)}
|
||||
type="application/json"
|
||||
set:html={serializeEmbeddedData(data)}
|
||||
></script>
|
||||
@@ -1,257 +0,0 @@
|
||||
---
|
||||
import EditLink from "@astrojs/starlight/components/EditLink.astro";
|
||||
import LastUpdated from "@astrojs/starlight/components/LastUpdated.astro";
|
||||
import Pagination from "@astrojs/starlight/components/Pagination.astro";
|
||||
import config from "virtual:starlight/user-config";
|
||||
import { Icon } from "@astrojs/starlight/components";
|
||||
import ThemeToggle from "./ThemeToggle.astro";
|
||||
const currentYear = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer class="sl-flex">
|
||||
<div class="meta sl-flex">
|
||||
<EditLink />
|
||||
<LastUpdated />
|
||||
</div>
|
||||
<Pagination />
|
||||
|
||||
{
|
||||
config.credits && (
|
||||
<a class="kudos sl-flex" href="https://starlight.astro.build">
|
||||
<Icon name={"starlight"} /> {Astro.locals.t("starlight:builtWithStarlight.label")}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
<p class="made-by">Made with love by our amazing <a href="/contributors/">contributors</a></p>
|
||||
|
||||
<div class="subfooter-bar">
|
||||
<div class="subfooter">
|
||||
<nav aria-label="Legal and resource links" class="subfooter-legal-nav">
|
||||
<ul class="subfooter-legal">
|
||||
<li class="subfooter-copyright">
|
||||
<time datetime={String(currentYear)}>© {currentYear} GitHub, Inc.</time>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://docs.github.com/site-policy/github-terms/github-terms-of-service">
|
||||
Terms
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://docs.github.com/site-policy/privacy-policies/github-privacy-statement">
|
||||
Privacy
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<nav aria-label="GitHub's social media links">
|
||||
<ul class="social-links">
|
||||
<li>
|
||||
<a href="https://www.linkedin.com/company/github" aria-label="GitHub on LinkedIn">
|
||||
<svg viewBox="0 0 19 18" aria-hidden="true">
|
||||
<path d="M3.94 2A2 2 0 1 1 2 0a2 2 0 0 1 1.94 2zM4 5.48H0V18h4zm6.32 0H6.34V18h3.94v-6.57c0-3.66 4.77-4 4.77 0V18H19v-7.93c0-6.17-7.06-5.94-8.72-2.91z" />
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.instagram.com/github" aria-label="GitHub on Instagram">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 0C8.74 0 8.33.02 7.05.07 5.78.13 4.91.33 4.14.63c-.79.31-1.46.72-2.13 1.38S.94 3.35.63 4.14C.33 4.91.13 5.78.07 7.05.01 8.33 0 8.74 0 12s.02 3.67.07 4.95c.06 1.28.26 2.15.56 2.91.31.79.72 1.46 1.38 2.13s1.34 1.08 2.13 1.38c.77.3 1.64.5 2.91.56C8.33 23.99 8.74 24 12 24s3.67-.02 4.95-.07c1.28-.06 2.15-.26 2.91-.56.79-.31 1.46-.72 2.13-1.38s1.08-1.34 1.38-2.13c.3-.77.5-1.64.56-2.91.06-1.28.07-1.69.07-4.95s-.02-3.67-.07-4.95c-.06-1.28-.26-2.15-.56-2.91-.31-.79-.72-1.46-1.38-2.13S20.65.94 19.86.63c-.77-.3-1.64-.5-2.91-.56C15.67.01 15.26 0 12 0zm0 5.84A6.16 6.16 0 1 0 12 18.16 6.16 6.16 0 0 0 12 5.84zm0 10.16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm7.85-10.41a1.44 1.44 0 1 1-2.88 0 1.44 1.44 0 0 1 2.88 0z" />
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.youtube.com/github" aria-label="GitHub on YouTube">
|
||||
<svg viewBox="0 0 19.17 13.6" aria-hidden="true">
|
||||
<path d="M18.77 2.13A2.4 2.4 0 0 0 17.09.42C15.59 0 9.58 0 9.58 0a57.55 57.55 0 0 0-7.5.4A2.49 2.49 0 0 0 .39 2.13 26.27 26.27 0 0 0 0 6.8a26.15 26.15 0 0 0 .39 4.67 2.43 2.43 0 0 0 1.69 1.71c1.52.42 7.5.42 7.5.42a57.69 57.69 0 0 0 7.51-.4 2.4 2.4 0 0 0 1.68-1.71 25.63 25.63 0 0 0 .4-4.67 24 24 0 0 0-.4-4.69zM7.67 9.71V3.89l5 2.91z" />
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/github" aria-label="GitHub on X">
|
||||
<svg viewBox="0 0 1200 1227" aria-hidden="true">
|
||||
<path d="M714.16 519.28 1160.89 0h-105.86L667.14 450.89 357.33 0H0l468.49 681.82L0 1226.37h105.87l409.62-476.15 327.18 476.15H1200L714.14 519.28h.02ZM569.17 687.83 144.01 79.69h162.6l752.47 1070.61H892.48L569.17 687.83Z" />
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.tiktok.com/@github" aria-label="GitHub on TikTok">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12.53.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z" />
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.twitch.tv/github" aria-label="GitHub on Twitch">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M11.57 4.71h1.72v5.15h-1.72zm4.72 0H18v5.15h-1.71zM6 0 1.71 4.29v15.42h5.15V24l4.28-4.29h3.43L22.29 12V0zm14.57 11.14-3.43 3.43h-3.43l-3 3v-3H6.86V1.71h13.71Z" />
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/github" aria-label="GitHub's organization on GitHub">
|
||||
<svg viewBox="0 0 98 96" aria-hidden="true">
|
||||
<path d="M48.85 0C21.84 0 0 22 0 49.22c0 21.75 13.99 40.17 33.41 46.69 2.42.49 3.31-1.06 3.31-2.36 0-1.14-.08-5.05-.08-9.13-13.59 2.94-16.42-5.87-16.42-5.87-2.18-5.7-5.42-7.17-5.42-7.17-4.45-3.01.33-3.01.33-3.01 4.93.32 7.52 5.05 7.52 5.05 4.37 7.5 11.4 5.38 14.24 4.07.4-3.18 1.7-5.38 3.07-6.6-10.84-1.14-22.24-5.38-22.24-24.28 0-5.38 1.94-9.78 5.01-13.2-.48-1.22-2.18-6.28.49-13.04 0 0 4.12-1.3 13.42 5.05a46.97 46.97 0 0 1 12.22-1.63c4.12 0 8.33.57 12.21 1.63 9.3-6.35 13.43-5.05 13.43-5.05 2.67 6.76.97 11.82.48 13.04 3.16 3.42 5.02 7.82 5.02 13.2 0 18.9-11.4 23.06-22.32 24.28 1.78 1.55 3.31 4.48 3.31 9.13 0 6.6-.08 11.9-.08 13.53 0 1.3.89 2.85 3.32 2.36 19.41-6.52 33.4-24.94 33.4-46.69C97.71 22 75.79 0 48.85 0z" />
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<ThemeToggle />
|
||||
|
||||
<style>
|
||||
footer {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
gap: 0.75rem 3rem;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.75rem;
|
||||
font-size: var(--sl-text-sm);
|
||||
color: var(--sl-color-gray-3);
|
||||
}
|
||||
|
||||
.meta > :global(p:only-child) {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
:global(.pagination-links:empty) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.made-by {
|
||||
text-align: center;
|
||||
margin: 0 auto 0.75rem;
|
||||
font-size: var(--sl-text-xs);
|
||||
color: var(--sl-color-gray-3);
|
||||
}
|
||||
|
||||
.kudos {
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
margin: 0 auto;
|
||||
font-size: var(--sl-text-xs);
|
||||
text-decoration: none;
|
||||
color: var(--sl-color-gray-3);
|
||||
}
|
||||
|
||||
.kudos:hover {
|
||||
color: var(--sl-color-white);
|
||||
}
|
||||
|
||||
.kudos :global(svg) {
|
||||
color: var(--sl-color-orange);
|
||||
}
|
||||
|
||||
.made-by a {
|
||||
color: var(--sl-color-gray-3);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.15em;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.subfooter a {
|
||||
color: var(--sl-color-gray-3);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.made-by a:hover,
|
||||
.subfooter a:hover {
|
||||
color: var(--sl-color-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.subfooter-bar {
|
||||
width: 100%;
|
||||
margin-top: 1.5rem;
|
||||
background: var(--sl-color-bg-nav);
|
||||
border-top: 1px solid var(--sl-color-gray-5);
|
||||
}
|
||||
|
||||
.subfooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem;
|
||||
color: var(--sl-color-gray-3);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.subfooter ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.subfooter-legal-nav {
|
||||
color: var(--sl-color-gray-3);
|
||||
font-family: var(--__sl-font-mono);
|
||||
font-size: var(--sl-text-xs);
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.subfooter-legal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem 1.25rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.subfooter-copyright {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.social-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.social-links a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.social-links a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.social-links svg {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.subfooter {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding-inline: 2rem;
|
||||
}
|
||||
|
||||
.subfooter-legal {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,116 +0,0 @@
|
||||
---
|
||||
import Default from '@astrojs/starlight/components/Head.astro';
|
||||
const { head, entry } = Astro.locals.starlightRoute;
|
||||
const basePath = import.meta.env.BASE_URL;
|
||||
|
||||
const getMetaContent = (key: string, attribute: 'name' | 'property' = 'name') =>
|
||||
head.find((tag) => tag.tag === 'meta' && tag.attrs?.[attribute] === key)?.attrs?.content;
|
||||
|
||||
const getLinkHref = (rel: string) =>
|
||||
head.find((tag) => tag.tag === 'link' && tag.attrs?.rel === rel)?.attrs?.href;
|
||||
|
||||
const twitterTitle = entry.data.title;
|
||||
const description =
|
||||
entry.data.description ??
|
||||
getMetaContent('description') ??
|
||||
getMetaContent('og:description', 'property');
|
||||
const canonicalUrl =
|
||||
getLinkHref('canonical') ?? getMetaContent('og:url', 'property');
|
||||
const socialImageUrl =
|
||||
getMetaContent('twitter:image') ?? getMetaContent('og:image', 'property');
|
||||
const socialImageAlt =
|
||||
getMetaContent('twitter:image:alt') ??
|
||||
getMetaContent('og:image:alt', 'property') ??
|
||||
description;
|
||||
const socialImageType = socialImageUrl?.endsWith('.png')
|
||||
? 'image/png'
|
||||
: socialImageUrl?.endsWith('.jpg') || socialImageUrl?.endsWith('.jpeg')
|
||||
? 'image/jpeg'
|
||||
: socialImageUrl?.endsWith('.webp')
|
||||
? 'image/webp'
|
||||
: undefined;
|
||||
|
||||
const twitterDomain =
|
||||
canonicalUrl && URL.canParse(canonicalUrl)
|
||||
? new URL(canonicalUrl).hostname
|
||||
: undefined;
|
||||
const contentSecurityPolicy = [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"object-src 'none'",
|
||||
"form-action 'none'",
|
||||
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://analytics.githubassets.com",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: https:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' https://raw.githubusercontent.com https://collector.githubapp.com",
|
||||
"worker-src 'self' blob:",
|
||||
"upgrade-insecure-requests",
|
||||
].join("; ");
|
||||
---
|
||||
|
||||
<meta http-equiv="Content-Security-Policy" content={contentSecurityPolicy} />
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
||||
<Default><slot /></Default>
|
||||
{twitterTitle && <meta name="twitter:title" content={twitterTitle} />}
|
||||
{description && <meta name="twitter:description" content={description} />}
|
||||
{canonicalUrl && <meta property="twitter:url" content={canonicalUrl} />}
|
||||
{twitterDomain && <meta property="twitter:domain" content={twitterDomain} />}
|
||||
{socialImageUrl && <meta property="og:image:secure_url" content={socialImageUrl} />}
|
||||
{socialImageType && <meta property="og:image:type" content={socialImageType} />}
|
||||
{socialImageAlt && <meta name="twitter:image:alt" content={socialImageAlt} />}
|
||||
|
||||
<!-- Theme initialization script (runs early to prevent flash) -->
|
||||
<script is:inline>
|
||||
(function() {
|
||||
const STORAGE_KEY = 'awesome-copilot-theme';
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
|
||||
// Theme handling:
|
||||
// - 'dark' or 'light' → set data-theme attribute to that value
|
||||
// - 'auto' or unset → don't set data-theme (CSS media query handles system preference)
|
||||
if (stored === 'dark' || stored === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', stored);
|
||||
}
|
||||
// For 'auto' or unset, no attribute means CSS media query controls theme
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script is:inline define:vars={{ basePath }}>
|
||||
// basePath setup for runtime use
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.body.dataset.basePath = basePath;
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.body.dataset.basePath = basePath;
|
||||
});
|
||||
</script>
|
||||
{import.meta.env.PROD && (
|
||||
<>
|
||||
<meta
|
||||
name="ha-url"
|
||||
content="https://collector.githubapp.com/awesome-copilot-web/collect"
|
||||
/>
|
||||
<script is:inline>
|
||||
(function() {
|
||||
const doNotTrackEnabled =
|
||||
navigator.doNotTrack === "1" ||
|
||||
navigator.doNotTrack === "yes" ||
|
||||
window.doNotTrack === "1" ||
|
||||
navigator.msDoNotTrack === "1";
|
||||
const globalPrivacyControlEnabled =
|
||||
typeof navigator.globalPrivacyControl === "boolean" &&
|
||||
navigator.globalPrivacyControl;
|
||||
|
||||
if (doNotTrackEnabled || globalPrivacyControlEnabled) return;
|
||||
|
||||
const analyticsScript = document.createElement("script");
|
||||
analyticsScript.async = true;
|
||||
analyticsScript.defer = true;
|
||||
analyticsScript.src = "https://analytics.githubassets.com/hydro-marketing.min.js";
|
||||
document.head.appendChild(analyticsScript);
|
||||
})();
|
||||
</script>
|
||||
</>
|
||||
)}
|
||||
@@ -1,120 +0,0 @@
|
||||
---
|
||||
// Icon component with SVG icons
|
||||
// Icons are either fill-based (from GitHub Primer) or stroke-based (custom)
|
||||
// GitHub Primer icons are sourced from https://primer.style/foundations/icons/
|
||||
|
||||
export interface Props {
|
||||
name: 'agents' | 'instructions' | 'skills' | 'hooks' | 'workflows' | 'plugins' | 'tools' | 'learning' | 'close' | 'copy' | 'download' | 'share' | 'external' | 'plus' | 'search' | 'chevron-down' | 'document' | 'lightning' | 'hook' | 'workflow' | 'plug' | 'wrench' | 'book' | 'robot' | 'sync';
|
||||
size?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { name, size = 24, class: className = '' } = Astro.props;
|
||||
|
||||
// Icon definitions: { path: SVG path(s), fill: true for fill-based icons }
|
||||
const icons: Record<string, { path: string; fill?: boolean }> = {
|
||||
// Resource type icons - using GitHub Primer icons where available
|
||||
|
||||
// Agent icon - using GitHub Primer's agent-24 (sparkle over workflow)
|
||||
// Source: https://primer.style/foundations/icons/agent-24
|
||||
'robot': {
|
||||
fill: true,
|
||||
path: `<path d="M22.5 13.919v-.278a5.097 5.097 0 0 0-4.961-5.086.858.858 0 0 1-.754-.497l-.149-.327A6.414 6.414 0 0 0 10.81 4a6.133 6.133 0 0 0-6.13 6.32l.019.628a.863.863 0 0 1-.67.869A3.263 3.263 0 0 0 1.5 14.996v.108A3.397 3.397 0 0 0 4.896 18.5h1.577a.75.75 0 0 1 0 1.5H4.896A4.896 4.896 0 0 1 0 15.104v-.108a4.761 4.761 0 0 1 3.185-4.493l-.004-.137A7.633 7.633 0 0 1 10.81 2.5a7.911 7.911 0 0 1 7.176 4.58C21.36 7.377 24 10.207 24 13.641v.278a.75.75 0 0 1-1.5 0Z"/><path d="m12.306 11.77 3.374 3.375a.749.749 0 0 1 0 1.061l-3.375 3.375-.057.051a.751.751 0 0 1-1.004-.051.751.751 0 0 1-.051-1.004l.051-.057 2.845-2.845-2.844-2.844a.75.75 0 1 1 1.061-1.061ZM22.5 19.8H18a.75.75 0 0 1 0-1.5h4.5a.75.75 0 0 1 0 1.5Z"/>`
|
||||
},
|
||||
|
||||
// Document icon - custom stroke-based
|
||||
'document': {
|
||||
path: `<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
// Lightning icon - custom stroke-based (for skills)
|
||||
'lightning': {
|
||||
path: `<path d="M13 2 4.09 12.11a1.23 1.23 0 0 0 .13 1.72l.16.14a1.23 1.23 0 0 0 1.52 0L13 9.5V22l8.91-10.11a1.23 1.23 0 0 0-.13-1.72l-.16-.14a1.23 1.23 0 0 0-1.52 0L13 14.5V2Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
// Hook icon - using GitHub Primer's sync-24 (represents hooks/iterations)
|
||||
// Source: https://primer.style/foundations/icons/sync-24
|
||||
'hook': {
|
||||
fill: true,
|
||||
path: `<path d="M3.38 8A9.502 9.502 0 0 1 12 2.5a9.502 9.502 0 0 1 9.215 7.182.75.75 0 1 0 1.456-.364C21.473 4.539 17.15 1 12 1a10.995 10.995 0 0 0-9.5 5.452V4.75a.75.75 0 0 0-1.5 0V8.5a1 1 0 0 0 1 1h3.75a.75.75 0 0 0 0-1.5H3.38Zm-.595 6.318a.75.75 0 0 0-1.455.364C2.527 19.461 6.85 23 12 23c4.052 0 7.592-2.191 9.5-5.451v1.701a.75.75 0 0 0 1.5 0V15.5a1 1 0 0 0-1-1h-3.75a.75.75 0 0 0 0 1.5h2.37A9.502 9.502 0 0 1 12 21.5c-4.446 0-8.181-3.055-9.215-7.182Z"/>`
|
||||
},
|
||||
|
||||
// Workflow icon - using GitHub Primer's workflow-24
|
||||
// Source: https://primer.style/foundations/icons/workflow-24
|
||||
// Also used by https://github.github.com/gh-aw/
|
||||
'workflow': {
|
||||
fill: true,
|
||||
path: `<path d="M1 3a2 2 0 0 1 2-2h6.5a2 2 0 0 1 2 2v6.5a2 2 0 0 1-2 2H7v4.063C7 16.355 7.644 17 8.438 17H12.5v-2.5a2 2 0 0 1 2-2H21a2 2 0 0 1 2 2V21a2 2 0 0 1-2 2h-6.5a2 2 0 0 1-2-2v-2.5H8.437A2.939 2.939 0 0 1 5.5 15.562V11.5H3a2 2 0 0 1-2-2Zm2-.5a.5.5 0 0 0-.5.5v6.5a.5.5 0 0 0 .5.5h6.5a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5ZM14.5 14a.5.5 0 0 0-.5.5V21a.5.5 0 0 0 .5.5H21a.5.5 0 0 0 .5-.5v-6.5a.5.5 0 0 0-.5-.5Z"/>`
|
||||
},
|
||||
|
||||
// Plug icon - using GitHub Primer's plug-24
|
||||
// Source: https://primer.style/foundations/icons/plug-24
|
||||
'plug': {
|
||||
fill: true,
|
||||
path: `<path d="M7 11.5H2.938c-.794 0-1.438.644-1.438 1.437v8.313a.75.75 0 0 1-1.5 0v-8.312A2.939 2.939 0 0 1 2.937 10H7V6.151c0-.897.678-1.648 1.57-1.74l6.055-.626 1.006-1.174A1.752 1.752 0 0 1 16.96 2h1.29c.966 0 1.75.784 1.75 1.75V6h3.25a.75.75 0 0 1 0 1.5H20V14h3.25a.75.75 0 0 1 0 1.5H20v2.25a1.75 1.75 0 0 1-1.75 1.75h-1.29a1.75 1.75 0 0 1-1.329-.611l-1.006-1.174-6.055-.627A1.749 1.749 0 0 1 7 15.348Zm9.77-7.913v.001l-1.201 1.4a.75.75 0 0 1-.492.258l-6.353.657a.25.25 0 0 0-.224.249v9.196a.25.25 0 0 0 .224.249l6.353.657c.191.02.368.112.493.258l1.2 1.401a.252.252 0 0 0 .19.087h1.29a.25.25 0 0 0 .25-.25v-14a.25.25 0 0 0-.25-.25h-1.29a.252.252 0 0 0-.19.087Z"/>`
|
||||
},
|
||||
|
||||
// Wrench icon - custom stroke-based (for tools)
|
||||
'wrench': {
|
||||
path: `<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
// Book icon - custom stroke-based (for learning)
|
||||
'book': {
|
||||
path: `<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
// Action icons - all custom stroke-based
|
||||
'close': {
|
||||
path: `<path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
'copy': {
|
||||
path: `<path d="M8 4h8a2 2 0 0 1 2 2v8M8 4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8 4v10a2 2 0 0 0 2 2h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
'download': {
|
||||
path: `<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
'share': {
|
||||
path: `<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
'external': {
|
||||
path: `<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14 21 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
'plus': {
|
||||
path: `<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
'search': {
|
||||
path: `<circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="m21 21-4.35-4.35" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
'chevron-down': {
|
||||
path: `<path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
},
|
||||
|
||||
// Alias for hook - same as 'hook'
|
||||
'sync': {
|
||||
fill: true,
|
||||
path: `<path d="M3.38 8A9.502 9.502 0 0 1 12 2.5a9.502 9.502 0 0 1 9.215 7.182.75.75 0 1 0 1.456-.364C21.473 4.539 17.15 1 12 1a10.995 10.995 0 0 0-9.5 5.452V4.75a.75.75 0 0 0-1.5 0V8.5a1 1 0 0 0 1 1h3.75a.75.75 0 0 0 0-1.5H3.38Zm-.595 6.318a.75.75 0 0 0-1.455.364C2.527 19.461 6.85 23 12 23c4.052 0 7.592-2.191 9.5-5.451v1.701a.75.75 0 0 0 1.5 0V15.5a1 1 0 0 0-1-1h-3.75a.75.75 0 0 0 0 1.5h2.37A9.502 9.502 0 0 1 12 21.5c-4.446 0-8.181-3.055-9.215-7.182Z"/>`
|
||||
},
|
||||
};
|
||||
|
||||
const iconData = icons[name] || { path: '' };
|
||||
const isFill = iconData.fill ?? false;
|
||||
const iconPath = iconData.path;
|
||||
---
|
||||
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
fill={isFill ? 'currentColor' : 'none'}
|
||||
class={className}
|
||||
aria-hidden="true"
|
||||
set:html={iconPath}
|
||||
/>
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
// Custom override of Starlight's LanguageSelect.
|
||||
//
|
||||
// By default Starlight renders the language dropdown on every page as soon as
|
||||
// more than one locale is configured. Most of this site's docs are English-only,
|
||||
// so we only want the picker to appear on pages that actually have a translation
|
||||
// in a non-English locale. This override checks the docs collection for a
|
||||
// translated version of the current page and renders the stock picker only when
|
||||
// one exists; otherwise it renders nothing (no dropdown).
|
||||
import Default from "@astrojs/starlight/components/LanguageSelect.astro";
|
||||
import config from "virtual:starlight/user-config";
|
||||
import { getCollection } from "astro:content";
|
||||
|
||||
// Locale directory names configured for the site, excluding the English root.
|
||||
const localeCodes = Object.keys(config.locales ?? {}).filter(
|
||||
(code) => code !== "root"
|
||||
);
|
||||
|
||||
/** Strip a leading `<locale>/` segment from a docs entry id, if present. */
|
||||
function baseSlug(id) {
|
||||
const [first, ...rest] = id.split("/");
|
||||
return localeCodes.includes(first) ? rest.join("/") : id;
|
||||
}
|
||||
|
||||
// Set of base slugs (locale-stripped) that have at least one non-English
|
||||
// translation available in the docs collection.
|
||||
const docs = await getCollection("docs");
|
||||
const translatedBaseSlugs = new Set();
|
||||
for (const entry of docs) {
|
||||
const [first, ...rest] = entry.id.split("/");
|
||||
if (localeCodes.includes(first)) {
|
||||
translatedBaseSlugs.add(rest.join("/"));
|
||||
}
|
||||
}
|
||||
|
||||
const currentBaseSlug = baseSlug(Astro.locals.starlightRoute.id);
|
||||
const hasTranslations = translatedBaseSlugs.has(currentBaseSlug);
|
||||
---
|
||||
|
||||
{hasTranslations && <Default {...Astro.props} />}
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: 'robot' | 'document' | 'lightning' | 'hook' | 'workflow' | 'plug' | 'wrench' | 'book';
|
||||
}
|
||||
|
||||
const { title, description, icon } = Astro.props;
|
||||
const contributingUrl = 'https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md';
|
||||
---
|
||||
|
||||
<div class="page-header">
|
||||
<div class="container">
|
||||
<div class="page-header-row">
|
||||
<div>
|
||||
<h1>
|
||||
{icon && <Icon name={icon} size={28} />}
|
||||
<Fragment set:html={title} />
|
||||
</h1>
|
||||
<p><slot><Fragment set:html={description} /></slot></p>
|
||||
</div>
|
||||
<a href={contributingUrl} class="contribute-link" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/></svg>
|
||||
Contribute
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page-header h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,516 +0,0 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import project from 'virtual:starlight/project-context';
|
||||
|
||||
const pagefindTranslations = {
|
||||
placeholder: Astro.locals.t('search.label'),
|
||||
...Object.fromEntries(
|
||||
Object.entries(Astro.locals.t.all())
|
||||
.filter(([key]) => key.startsWith('pagefind.'))
|
||||
.map(([key, value]) => [key.replace('pagefind.', ''), value])
|
||||
),
|
||||
};
|
||||
|
||||
const dataAttributes: DOMStringMap = { 'data-translations': JSON.stringify(pagefindTranslations) };
|
||||
if (project.trailingSlash === 'never') dataAttributes['data-strip-trailing-slash'] = '';
|
||||
---
|
||||
|
||||
<site-search class={Astro.props.class} {...dataAttributes}>
|
||||
<button
|
||||
data-open-modal
|
||||
disabled
|
||||
aria-label={Astro.locals.t('search.label')}
|
||||
aria-keyshortcuts="Control+K"
|
||||
>
|
||||
<Icon name="search" />
|
||||
<span class="sl-hidden md:sl-block" aria-hidden="true">{Astro.locals.t('search.label')}</span>
|
||||
<kbd class="sl-hidden md:sl-flex" style="display: none;">
|
||||
<kbd>{Astro.locals.t('search.ctrlKey')}</kbd><kbd>K</kbd>
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<dialog style="padding:0" aria-label={Astro.locals.t('search.label')}>
|
||||
<div class="dialog-frame sl-flex">
|
||||
{
|
||||
/* TODO: Make the layout of this button flexible to accommodate different word lengths. Currently hard-coded for English: “Cancel” */
|
||||
}
|
||||
<button data-close-modal class="sl-flex md:sl-hidden">
|
||||
{Astro.locals.t('search.cancelLabel')}
|
||||
</button>
|
||||
{
|
||||
import.meta.env.DEV ? (
|
||||
<div style="margin: auto; text-align: center; white-space: pre-line;" dir="ltr">
|
||||
<p>{Astro.locals.t('search.devWarning')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div class="search-container">
|
||||
<div id="starlight__search" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</dialog>
|
||||
</site-search>
|
||||
|
||||
{
|
||||
/**
|
||||
* This is intentionally inlined to avoid briefly showing an invalid shortcut.
|
||||
* Purposely using the deprecated `navigator.platform` property to detect Apple devices, as the
|
||||
* user agent is spoofed by some browsers when opening the devtools.
|
||||
*/
|
||||
}
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const openBtn = document.querySelector('button[data-open-modal]');
|
||||
const shortcut = openBtn?.querySelector('kbd');
|
||||
if (!openBtn || !(shortcut instanceof HTMLElement)) return;
|
||||
const platformKey = shortcut.querySelector('kbd');
|
||||
if (platformKey && /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform)) {
|
||||
platformKey.textContent = '⌘';
|
||||
openBtn.setAttribute('aria-keyshortcuts', 'Meta+K');
|
||||
}
|
||||
shortcut.style.display = '';
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import { pagefindUserConfig } from 'virtual:starlight/pagefind-config';
|
||||
|
||||
const ROUTE_TYPE_FILTERS: Record<string, string> = {
|
||||
'/agents': 'agent',
|
||||
'/instructions': 'instruction',
|
||||
'/skills': 'skill',
|
||||
'/hooks': 'hook',
|
||||
'/workflows': 'workflow',
|
||||
'/plugins': 'plugin',
|
||||
'/tools': 'tool',
|
||||
};
|
||||
|
||||
function getRouteTypeFilter(pathname: string, baseUrl: string): string | undefined {
|
||||
const normalizedBaseUrl = baseUrl === '/' ? '' : baseUrl.replace(/\/$/, '');
|
||||
const pathWithoutBase =
|
||||
normalizedBaseUrl && pathname.startsWith(normalizedBaseUrl)
|
||||
? pathname.slice(normalizedBaseUrl.length) || '/'
|
||||
: pathname;
|
||||
const normalizedPath = pathWithoutBase.replace(/\/$/, '') || '/';
|
||||
return ROUTE_TYPE_FILTERS[normalizedPath];
|
||||
}
|
||||
|
||||
class SiteSearch extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
const openBtn = this.querySelector<HTMLButtonElement>('button[data-open-modal]')!;
|
||||
const closeBtn = this.querySelector<HTMLButtonElement>('button[data-close-modal]')!;
|
||||
const dialog = this.querySelector('dialog')!;
|
||||
const dialogFrame = this.querySelector('.dialog-frame')!;
|
||||
|
||||
/** Close the modal if a user clicks on a link or outside of the modal. */
|
||||
const onClick = (event: MouseEvent) => {
|
||||
const isLink = 'href' in (event.target || {});
|
||||
if (
|
||||
isLink ||
|
||||
(document.body.contains(event.target as Node) &&
|
||||
!dialogFrame.contains(event.target as Node))
|
||||
) {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
const openModal = (event?: MouseEvent) => {
|
||||
dialog.showModal();
|
||||
document.body.toggleAttribute('data-search-modal-open', true);
|
||||
this.querySelector('input')?.focus();
|
||||
event?.stopPropagation();
|
||||
window.addEventListener('click', onClick);
|
||||
};
|
||||
|
||||
const closeModal = () => dialog.close();
|
||||
|
||||
openBtn.addEventListener('click', openModal);
|
||||
openBtn.disabled = false;
|
||||
closeBtn.addEventListener('click', closeModal);
|
||||
|
||||
dialog.addEventListener('close', () => {
|
||||
document.body.toggleAttribute('data-search-modal-open', false);
|
||||
window.removeEventListener('click', onClick);
|
||||
});
|
||||
|
||||
// Listen for `ctrl + k` and `cmd + k` keyboard shortcuts.
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if ((e.metaKey === true || e.ctrlKey === true) && e.key === 'k') {
|
||||
dialog.open ? closeModal() : openModal();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
let translations = {};
|
||||
try {
|
||||
translations = JSON.parse(this.dataset.translations || '{}');
|
||||
} catch {}
|
||||
|
||||
const shouldStrip = this.dataset.stripTrailingSlash !== undefined;
|
||||
const stripTrailingSlash = (path: string) => path.replace(/(.)\/(#.*)?$/, '$1$2');
|
||||
const formatURL = shouldStrip ? stripTrailingSlash : (path: string) => path;
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
if (import.meta.env.DEV) return;
|
||||
const onIdle = window.requestIdleCallback || ((cb) => setTimeout(cb, 1));
|
||||
onIdle(async () => {
|
||||
// @ts-expect-error — Missing types for @pagefind/default-ui package.
|
||||
const { PagefindUI } = await import('@pagefind/default-ui');
|
||||
const pagefind = new PagefindUI({
|
||||
...pagefindUserConfig,
|
||||
element: '#starlight__search',
|
||||
baseUrl: import.meta.env.BASE_URL,
|
||||
bundlePath: import.meta.env.BASE_URL.replace(/\/$/, '') + '/pagefind/',
|
||||
showImages: false,
|
||||
translations,
|
||||
showSubResults: true,
|
||||
processResult: (result: { url: string; sub_results: Array<{ url: string }> }) => {
|
||||
result.url = formatURL(result.url);
|
||||
result.sub_results = result.sub_results.map((sub_result) => {
|
||||
sub_result.url = formatURL(sub_result.url);
|
||||
return sub_result;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const routeTypeFilter = getRouteTypeFilter(
|
||||
window.location.pathname,
|
||||
import.meta.env.BASE_URL
|
||||
);
|
||||
if (routeTypeFilter) {
|
||||
pagefind.triggerFilters({ type: routeTypeFilter });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
customElements.define('site-search', SiteSearch);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@layer starlight.core {
|
||||
site-search {
|
||||
display: contents;
|
||||
}
|
||||
button[data-open-modal] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
color: var(--sl-color-gray-1);
|
||||
cursor: pointer;
|
||||
height: 2.5rem;
|
||||
font-size: var(--sl-text-xl);
|
||||
}
|
||||
|
||||
@media (min-width: 50rem) {
|
||||
button[data-open-modal] {
|
||||
border: 1px solid var(--sl-color-gray-5);
|
||||
border-radius: 0.5rem;
|
||||
padding-inline-start: 0.75rem;
|
||||
padding-inline-end: 0.5rem;
|
||||
background-color: var(--sl-color-black);
|
||||
color: var(--sl-color-gray-2);
|
||||
font-size: var(--sl-text-sm);
|
||||
width: 100%;
|
||||
max-width: 22rem;
|
||||
}
|
||||
button[data-open-modal]:hover {
|
||||
border-color: var(--sl-color-gray-2);
|
||||
color: var(--sl-color-white);
|
||||
}
|
||||
|
||||
button[data-open-modal] > :last-child {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
}
|
||||
|
||||
button > kbd {
|
||||
border-radius: 0.25rem;
|
||||
font-size: var(--sl-text-2xs);
|
||||
gap: 0.25em;
|
||||
padding-inline: 0.375rem;
|
||||
background-color: var(--sl-color-gray-6);
|
||||
}
|
||||
|
||||
kbd {
|
||||
font-family: var(--__sl-font);
|
||||
}
|
||||
|
||||
dialog {
|
||||
margin: 0;
|
||||
background-color: var(--sl-color-gray-6);
|
||||
border: 1px solid var(--sl-color-gray-5);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
box-shadow: var(--sl-shadow-lg);
|
||||
}
|
||||
dialog[open] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background-color: var(--sl-color-backdrop-overlay);
|
||||
-webkit-backdrop-filter: blur(0.25rem);
|
||||
backdrop-filter: blur(0.25rem);
|
||||
}
|
||||
|
||||
.dialog-frame {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
button[data-close-modal] {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
align-items: center;
|
||||
align-self: flex-end;
|
||||
height: calc(64px * var(--pagefind-ui-scale));
|
||||
padding: 0.25rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--sl-color-text-accent);
|
||||
}
|
||||
|
||||
#starlight__search {
|
||||
--pagefind-ui-primary: var(--sl-color-text);
|
||||
--pagefind-ui-text: var(--sl-color-gray-2);
|
||||
--pagefind-ui-font: var(--__sl-font);
|
||||
--pagefind-ui-background: var(--sl-color-black);
|
||||
--pagefind-ui-border: var(--sl-color-gray-5);
|
||||
--pagefind-ui-border-width: 1px;
|
||||
--pagefind-ui-tag: var(--sl-color-gray-5);
|
||||
--sl-search-cancel-space: 5rem;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] #starlight__search {
|
||||
--pagefind-ui-tag: var(--sl-color-gray-6);
|
||||
}
|
||||
|
||||
@media (min-width: 50rem) {
|
||||
#starlight__search {
|
||||
--sl-search-cancel-space: 0px;
|
||||
}
|
||||
|
||||
dialog {
|
||||
margin: 4rem auto auto;
|
||||
border-radius: 0.5rem;
|
||||
width: 90%;
|
||||
max-width: 40rem;
|
||||
height: max-content;
|
||||
min-height: 15rem;
|
||||
max-height: calc(100% - 8rem);
|
||||
}
|
||||
|
||||
.dialog-frame {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style is:global>
|
||||
@import url('@pagefind/default-ui/css/ui.css') layer(starlight.core);
|
||||
|
||||
@layer starlight.core {
|
||||
[data-search-modal-open] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#starlight__search {
|
||||
--sl-search-result-spacing: calc(1.25rem * var(--pagefind-ui-scale));
|
||||
--sl-search-result-pad-inline-start: calc(3.75rem * var(--pagefind-ui-scale));
|
||||
--sl-search-result-pad-inline-end: calc(1.25rem * var(--pagefind-ui-scale));
|
||||
--sl-search-result-pad-block: calc(0.9375rem * var(--pagefind-ui-scale));
|
||||
--sl-search-result-nested-pad-block: calc(0.625rem * var(--pagefind-ui-scale));
|
||||
--sl-search-corners: calc(0.3125rem * var(--pagefind-ui-scale));
|
||||
--sl-search-page-icon-size: calc(1.875rem * var(--pagefind-ui-scale));
|
||||
--sl-search-page-icon-inline-start: calc(
|
||||
(var(--sl-search-result-pad-inline-start) - var(--sl-search-page-icon-size)) / 2
|
||||
);
|
||||
--sl-search-tree-diagram-size: calc(2.5rem * var(--pagefind-ui-scale));
|
||||
--sl-search-tree-diagram-inline-start: calc(
|
||||
(var(--sl-search-result-pad-inline-start) - var(--sl-search-tree-diagram-size)) / 2
|
||||
);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__form::before {
|
||||
--pagefind-ui-text: var(--sl-color-gray-1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__search-input {
|
||||
color: var(--sl-color-white);
|
||||
font-weight: 400;
|
||||
width: calc(100% - var(--sl-search-cancel-space));
|
||||
}
|
||||
|
||||
#starlight__search input:focus {
|
||||
--pagefind-ui-border: var(--sl-color-accent);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__search-clear {
|
||||
inset-inline-end: var(--sl-search-cancel-space);
|
||||
width: calc(60px * var(--pagefind-ui-scale));
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
#starlight__search .pagefind-ui__search-clear:focus {
|
||||
outline: 1px solid var(--sl-color-accent);
|
||||
}
|
||||
#starlight__search .pagefind-ui__search-clear::before {
|
||||
content: '';
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='m13.41 12 6.3-6.29a1 1 0 1 0-1.42-1.42L12 10.59l-6.29-6.3a1 1 0 0 0-1.42 1.42l6.3 6.29-6.3 6.29a1 1 0 0 0 .33 1.64 1 1 0 0 0 1.09-.22l6.29-6.3 6.29 6.3a1 1 0 0 0 1.64-.33 1 1 0 0 0-.22-1.09L13.41 12Z'/%3E%3C/svg%3E")
|
||||
center / 50% no-repeat;
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='m13.41 12 6.3-6.29a1 1 0 1 0-1.42-1.42L12 10.59l-6.29-6.3a1 1 0 0 0-1.42 1.42l6.3 6.29-6.3 6.29a1 1 0 0 0 .33 1.64 1 1 0 0 0 1.09-.22l6.29-6.3 6.29 6.3a1 1 0 0 0 1.64-.33 1 1 0 0 0-.22-1.09L13.41 12Z'/%3E%3C/svg%3E")
|
||||
center / 50% no-repeat;
|
||||
background-color: var(--sl-color-text-accent);
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__results > * + * {
|
||||
margin-top: var(--sl-search-result-spacing);
|
||||
}
|
||||
#starlight__search .pagefind-ui__result {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-nested {
|
||||
position: relative;
|
||||
padding: var(--sl-search-result-nested-pad-block) var(--sl-search-result-pad-inline-end);
|
||||
padding-inline-start: var(--sl-search-result-pad-inline-start);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)),
|
||||
#starlight__search .pagefind-ui__result-nested {
|
||||
position: relative;
|
||||
background-color: var(--sl-color-black);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)):hover,
|
||||
#starlight__search
|
||||
.pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)):focus-within,
|
||||
#starlight__search .pagefind-ui__result-nested:hover,
|
||||
#starlight__search .pagefind-ui__result-nested:focus-within {
|
||||
outline: 1px solid var(--sl-color-accent-high);
|
||||
}
|
||||
|
||||
#starlight__search
|
||||
.pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)):focus-within,
|
||||
#starlight__search .pagefind-ui__result-nested:focus-within {
|
||||
background-color: var(--sl-color-accent-low);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-thumb,
|
||||
#starlight__search .pagefind-ui__result-inner {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-inner > :first-child {
|
||||
border-radius: var(--sl-search-corners) var(--sl-search-corners) 0 0;
|
||||
}
|
||||
#starlight__search .pagefind-ui__result-inner > :last-child {
|
||||
border-radius: 0 0 var(--sl-search-corners) var(--sl-search-corners);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-inner > .pagefind-ui__result-title {
|
||||
padding: var(--sl-search-result-pad-block) var(--sl-search-result-pad-inline-end);
|
||||
padding-inline-start: var(--sl-search-result-pad-inline-start);
|
||||
}
|
||||
#starlight__search .pagefind-ui__result-inner > .pagefind-ui__result-title::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: var(--sl-search-page-icon-inline-start);
|
||||
width: var(--sl-search-page-icon-size);
|
||||
background: var(--sl-color-gray-3);
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='currentColor' viewBox='0 0 24 24'%3E%3Cpath d='M9 10h1a1 1 0 1 0 0-2H9a1 1 0 0 0 0 2Zm0 2a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2H9Zm11-3V8l-6-6a1 1 0 0 0-1 0H7a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V9Zm-6-4 3 3h-2a1 1 0 0 1-1-1V5Zm4 14a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5v3a3 3 0 0 0 3 3h3v9Zm-3-3H9a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2Z'/%3E%3C/svg%3E")
|
||||
center no-repeat;
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='currentColor' viewBox='0 0 24 24'%3E%3Cpath d='M9 10h1a1 1 0 1 0 0-2H9a1 1 0 0 0 0 2Zm0 2a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2H9Zm11-3V8l-6-6a1 1 0 0 0-1 0H7a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V9Zm-6-4 3 3h-2a1 1 0 0 1-1-1V5Zm4 14a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5v3a3 3 0 0 0 3 3h3v9Zm-3-3H9a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2Z'/%3E%3C/svg%3E")
|
||||
center no-repeat;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-inner {
|
||||
align-items: stretch;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-link {
|
||||
position: unset;
|
||||
--pagefind-ui-text: var(--sl-color-white);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-link:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-nested .pagefind-ui__result-link::before {
|
||||
content: unset;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-nested::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: var(--sl-search-tree-diagram-inline-start);
|
||||
width: var(--sl-search-tree-diagram-size);
|
||||
background: var(--sl-color-gray-4);
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' viewBox='0 0 16 1000' preserveAspectRatio='xMinYMin slice'%3E%3Cpath d='M8 0v1000m6-988H8'/%3E%3C/svg%3E")
|
||||
0% 0% / 100% no-repeat;
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' viewBox='0 0 16 1000' preserveAspectRatio='xMinYMin slice'%3E%3Cpath d='M8 0v1000m6-988H8'/%3E%3C/svg%3E")
|
||||
0% 0% / 100% no-repeat;
|
||||
}
|
||||
#starlight__search .pagefind-ui__result-nested:last-of-type::before {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 16 16'%3E%3Cpath d='M8 0v12m6 0H8'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 16 16'%3E%3Cpath d='M8 0v12m6 0H8'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* Flip page and tree icons around the vertical axis when in an RTL layout. */
|
||||
[dir='rtl'] .pagefind-ui__result-title::before,
|
||||
[dir='rtl'] .pagefind-ui__result-nested::before {
|
||||
transform: matrix(-1, 0, 0, 1, 0, 0);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-excerpt {
|
||||
font-size: calc(1rem * var(--pagefind-ui-scale));
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
#starlight__search mark {
|
||||
color: var(--sl-color-gray-2);
|
||||
background-color: transparent;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__filter-value::before {
|
||||
border-color: var(--sl-color-text-invert);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-tags {
|
||||
background-color: var(--sl-color-black);
|
||||
margin-top: 0;
|
||||
padding: var(--sl-search-result-nested-pad-block) var(--sl-search-result-pad-inline-end);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,170 +0,0 @@
|
||||
---
|
||||
// Theme Toggle Component - 3 state slider: Auto | Dark | Light
|
||||
---
|
||||
|
||||
<div class="theme-toggle-container">
|
||||
<button
|
||||
id="theme-toggle"
|
||||
class="theme-toggle"
|
||||
aria-label="Toggle theme"
|
||||
title="Change theme"
|
||||
>
|
||||
<span class="theme-icon moon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="theme-icon auto" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 2v10l4.5 4.5"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="theme-icon sun" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<line x1="12" y1="1" x2="12" y2="3"/>
|
||||
<line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/>
|
||||
<line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="theme-slider"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// Move theme toggle to body level to escape any stacking contexts
|
||||
const container = document.querySelector('.theme-toggle-container');
|
||||
if (container && container.parentElement !== document.body) {
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'awesome-copilot-theme';
|
||||
const toggle = document.getElementById('theme-toggle');
|
||||
const html = document.documentElement;
|
||||
|
||||
const themes = ['dark', 'auto', 'light'];
|
||||
const icons = ['moon', 'auto', 'sun'];
|
||||
|
||||
function getThemeIndex() {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored && themes.includes(stored)) {
|
||||
return themes.indexOf(stored);
|
||||
}
|
||||
// Default to light theme
|
||||
return 2;
|
||||
}
|
||||
|
||||
function applyTheme(index: number) {
|
||||
const theme = themes[index];
|
||||
if (theme === 'auto') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
html.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
|
||||
} else {
|
||||
html.setAttribute('data-theme', theme);
|
||||
}
|
||||
|
||||
// Move slider
|
||||
const slider = toggle?.querySelector('.theme-slider') as HTMLElement;
|
||||
if (slider) {
|
||||
slider.style.transform = `translateX(${index * 100}%)`;
|
||||
}
|
||||
|
||||
// Highlight active icon
|
||||
const themeToggle = document.querySelector('.theme-toggle');
|
||||
themeToggle?.setAttribute('data-active', String(index));
|
||||
}
|
||||
|
||||
function cycleTheme() {
|
||||
const currentIndex = getThemeIndex();
|
||||
const nextIndex = (currentIndex + 1) % themes.length;
|
||||
localStorage.setItem(STORAGE_KEY, themes[nextIndex]);
|
||||
applyTheme(nextIndex);
|
||||
}
|
||||
|
||||
// Initialize
|
||||
applyTheme(getThemeIndex());
|
||||
|
||||
// Click handler
|
||||
toggle?.addEventListener('click', cycleTheme);
|
||||
|
||||
// Keyboard shortcut
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'L') {
|
||||
e.preventDefault();
|
||||
cycleTheme();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for system theme changes
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (localStorage.getItem(STORAGE_KEY) === 'auto') {
|
||||
applyTheme(1); // auto
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style is:global>
|
||||
.theme-toggle-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
display: flex;
|
||||
position: relative;
|
||||
width: 108px;
|
||||
height: 36px;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 20px;
|
||||
background: var(--color-bg-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.theme-toggle:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 30px;
|
||||
border-radius: 16px;
|
||||
color: var(--color-text-muted);
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.theme-icon.moon { color: #9898a6; }
|
||||
.theme-icon.auto { color: #9898a6; }
|
||||
.theme-icon.sun { color: #9898a6; }
|
||||
|
||||
.theme-toggle[data-active="0"] .moon,
|
||||
.theme-toggle[data-active="1"] .auto,
|
||||
.theme-toggle[data-active="2"] .sun {
|
||||
color: var(--color-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.theme-slider {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,166 @@
|
||||
import { DownloadIcon, MarkGithubIcon, PlusIcon } from "@primer/octicons-react";
|
||||
import React from "react";
|
||||
|
||||
import { ActionMenu, Button } from "@primer/react-brand";
|
||||
|
||||
import {
|
||||
DetailChassis,
|
||||
type DetailSibling,
|
||||
type DetailTocItem,
|
||||
} from "./DetailChassis";
|
||||
import { ResourceMeta } from "./ResourceMeta";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import styles from "./styles/dotnet-upgrade.module.css";
|
||||
|
||||
export type AgentDetailItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
model?: string | string[];
|
||||
tools?: string[];
|
||||
handoffs?: (string | { label?: string; agent?: string })[];
|
||||
mcpServers?: string[];
|
||||
path: string;
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
export type AgentDetailProps = {
|
||||
item: AgentDetailItem;
|
||||
/** Rendered, sanitized markdown body with heading ids already stamped. */
|
||||
markdownHtml: string;
|
||||
toc: DetailTocItem[];
|
||||
vscodeUrl: string;
|
||||
insidersUrl: string;
|
||||
githubUrl: string;
|
||||
downloadUrl: string;
|
||||
rawMarkdown: string;
|
||||
lastUpdated?: string | null;
|
||||
previous?: DetailSibling;
|
||||
next?: DetailSibling;
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
};
|
||||
|
||||
export function AgentDetail({
|
||||
item,
|
||||
markdownHtml,
|
||||
toc,
|
||||
vscodeUrl,
|
||||
insidersUrl,
|
||||
githubUrl,
|
||||
downloadUrl,
|
||||
rawMarkdown,
|
||||
lastUpdated,
|
||||
previous,
|
||||
next,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
}: AgentDetailProps) {
|
||||
const models = Array.isArray(item.model)
|
||||
? item.model
|
||||
: item.model
|
||||
? [item.model]
|
||||
: [];
|
||||
|
||||
// Handoffs are recorded as `{ label, agent }` records; the chip shows the
|
||||
// human label, falling back to the agent id.
|
||||
const handoffs = (item.handoffs ?? [])
|
||||
.map((handoff) =>
|
||||
typeof handoff === "string"
|
||||
? handoff
|
||||
: (handoff.label ?? handoff.agent ?? ""),
|
||||
)
|
||||
.filter(Boolean);
|
||||
|
||||
const handleCopyMarkdown = React.useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(rawMarkdown);
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
}, [rawMarkdown]);
|
||||
|
||||
const install = (
|
||||
<>
|
||||
<ActionMenu mode="split-button" menuAlignment="start">
|
||||
<ActionMenu.Button
|
||||
as="a"
|
||||
href={vscodeUrl}
|
||||
variant="primary"
|
||||
leadingVisual={PlusIcon}
|
||||
>
|
||||
Install
|
||||
</ActionMenu.Button>
|
||||
<ActionMenu.Overlay aria-label={`Install the ${item.title} agent`}>
|
||||
<ActionMenu.Item as="a" href={vscodeUrl}>
|
||||
Install in VS Code
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item as="a" href={insidersUrl}>
|
||||
Install in VS Code Insiders
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item as="a" href={downloadUrl} download>
|
||||
Download file
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item onClick={handleCopyMarkdown}>
|
||||
Copy markdown
|
||||
</ActionMenu.Item>
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
<Button
|
||||
as="a"
|
||||
href={githubUrl}
|
||||
variant="secondary"
|
||||
className={styles.iconButton}
|
||||
aria-label={`View the ${item.title} agent on GitHub`}
|
||||
>
|
||||
<MarkGithubIcon size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href={downloadUrl}
|
||||
variant="secondary"
|
||||
download
|
||||
className={styles.iconButton}
|
||||
aria-label={`Download the ${item.title} agent file`}
|
||||
>
|
||||
<DownloadIcon size={16} />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<DetailChassis
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
breadcrumbs={[
|
||||
{ label: "Agents", href: pageHref("agents") },
|
||||
{ label: item.title },
|
||||
]}
|
||||
install={install}
|
||||
toc={toc}
|
||||
sidebar={
|
||||
<ResourceMeta
|
||||
kicker="Agent details"
|
||||
groups={[
|
||||
{ label: "Models", items: models },
|
||||
{ label: "Tools", items: item.tools ?? [] },
|
||||
{ label: "Handoffs", items: handoffs },
|
||||
{ label: "MCP servers", items: item.mcpServers ?? [] },
|
||||
]}
|
||||
lastUpdated={lastUpdated}
|
||||
sourceUrl={githubUrl}
|
||||
/>
|
||||
}
|
||||
previous={previous}
|
||||
next={next}
|
||||
currentPage="agents"
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
>
|
||||
<section className={styles.articleSection}>
|
||||
<div dangerouslySetInnerHTML={{ __html: markdownHtml }} />
|
||||
</section>
|
||||
</DetailChassis>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
import { ChevronDownIcon, DownloadIcon, MarkGithubIcon } from "@primer/octicons-react";
|
||||
import {
|
||||
ActionMenu,
|
||||
Box,
|
||||
Button,
|
||||
CTABanner,
|
||||
Card,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
Grid,
|
||||
Heading,
|
||||
Pagination,
|
||||
Section,
|
||||
Stack,
|
||||
Text,
|
||||
useTheme,
|
||||
} from "@primer/react-brand";
|
||||
import { clsx } from "clsx";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { PageShell } from "./PageShell";
|
||||
import { daysSince, toggleValue, updatedBuckets, updatedBucketOf } from "./catalogFilters";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import styles from "./styles/agents.module.css";
|
||||
|
||||
const CONTRIBUTE_URL =
|
||||
"https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md";
|
||||
const REQUEST_URL = "https://github.com/github/awesome-copilot/issues/new";
|
||||
|
||||
const RAW_BASE = "https://raw.githubusercontent.com/github/awesome-copilot/main";
|
||||
const GITHUB_BASE = "https://github.com/github/awesome-copilot/blob/main";
|
||||
const INSTALL_REDIRECT = "https://aka.ms/awesome-copilot/install/agent";
|
||||
|
||||
/** An agent record as emitted into public/data/agents.json. */
|
||||
export type AgentItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
model?: unknown;
|
||||
tools?: string[];
|
||||
path: string;
|
||||
filename: string;
|
||||
lastUpdated: string;
|
||||
};
|
||||
|
||||
/** Filter facets emitted alongside the items in public/data/agents.json. */
|
||||
export type AgentFilters = {
|
||||
models?: unknown[];
|
||||
tools?: string[];
|
||||
};
|
||||
|
||||
export const agentSourceUrl = (agent: AgentItem) => `${GITHUB_BASE}/${agent.path}`;
|
||||
|
||||
export const downloadUrl = (agent: AgentItem) => `${RAW_BASE}/${agent.path}`;
|
||||
|
||||
/**
|
||||
* VS Code deep link, matching the scheme used by the agent detail pages
|
||||
* (`src/lib/detail-page.ts`): an `aka.ms` redirect wrapping the editor URI.
|
||||
*/
|
||||
export const installUrl = (agent: AgentItem, insiders = false) => {
|
||||
const editor = insiders ? "vscode-insiders" : "vscode";
|
||||
const innerUrl = `${editor}:chat-agent/install?url=${encodeURIComponent(
|
||||
downloadUrl(agent),
|
||||
)}`;
|
||||
return `${INSTALL_REDIRECT}?url=${encodeURIComponent(innerUrl)}`;
|
||||
};
|
||||
|
||||
const agentDetailHref = (agent: AgentItem) => pageHref(`agent/${agent.id}`);
|
||||
|
||||
/** `model` may be a single name or a list of accepted models. */
|
||||
const modelsOf = (agent: AgentItem): string[] => flattenNames(agent.model);
|
||||
|
||||
function flattenNames(value: unknown): string[] {
|
||||
if (typeof value === "string") return [value];
|
||||
if (Array.isArray(value)) return value.flatMap(flattenNames);
|
||||
return [];
|
||||
}
|
||||
|
||||
const uniqueSorted = (values: string[]) =>
|
||||
Array.from(new Set(values)).sort((a, b) => a.localeCompare(b));
|
||||
|
||||
type FilterGroupId = "model" | "tools" | "updated";
|
||||
|
||||
type FilterState = Record<FilterGroupId, string[]>;
|
||||
|
||||
const emptyFilters: FilterState = { model: [], tools: [], updated: [] };
|
||||
|
||||
const PAGE_SIZE = 6;
|
||||
|
||||
/**
|
||||
* The Agents catalog, ported from the design prototype's `agents.tsx`. The
|
||||
* prototype's hardcoded array and hardcoded model/capability options are
|
||||
* replaced by build-time data; the layout, filters, and interactions are
|
||||
* unchanged.
|
||||
*/
|
||||
export function AgentsCatalog({
|
||||
agents,
|
||||
filters: dataFilters,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
}: {
|
||||
agents: AgentItem[];
|
||||
filters?: AgentFilters;
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
}) {
|
||||
const { colorMode } = useTheme();
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<FilterState>(emptyFilters);
|
||||
|
||||
const filterGroups = useMemo<
|
||||
{ id: FilterGroupId; label: string; options: string[] }[]
|
||||
>(() => {
|
||||
const models = dataFilters?.models
|
||||
? uniqueSorted(flattenNames(dataFilters.models))
|
||||
: uniqueSorted(agents.flatMap(modelsOf));
|
||||
const tools = dataFilters?.tools
|
||||
? uniqueSorted(dataFilters.tools)
|
||||
: uniqueSorted(agents.flatMap((agent) => agent.tools ?? []));
|
||||
return [
|
||||
{ id: "model", label: "Model", options: models },
|
||||
{ id: "tools", label: "Tools", options: tools },
|
||||
{
|
||||
id: "updated",
|
||||
label: "Last updated",
|
||||
options: updatedBuckets.map((bucket) => bucket.label),
|
||||
},
|
||||
];
|
||||
}, [agents, dataFilters]);
|
||||
|
||||
const sortedAgents = useMemo(() => {
|
||||
const copy = agents.filter((agent) => {
|
||||
const models = modelsOf(agent);
|
||||
const modelOk =
|
||||
filters.model.length === 0 ||
|
||||
filters.model.some((model) => models.includes(model));
|
||||
const toolsOk =
|
||||
filters.tools.length === 0 ||
|
||||
filters.tools.every((tool) => (agent.tools ?? []).includes(tool));
|
||||
const updatedOk =
|
||||
filters.updated.length === 0 ||
|
||||
filters.updated.includes(updatedBucketOf(daysSince(agent.lastUpdated)));
|
||||
return modelOk && toolsOk && updatedOk;
|
||||
});
|
||||
return copy.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}, [agents, filters]);
|
||||
|
||||
const toggleFilter = (groupId: FilterGroupId, option: string) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
[groupId]: toggleValue(prev[groupId], option),
|
||||
}));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters(emptyFilters);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const activeFilterCount =
|
||||
filters.model.length + filters.tools.length + filters.updated.length;
|
||||
const hasActiveFilters = activeFilterCount > 0;
|
||||
const pageCount = Math.max(1, Math.ceil(sortedAgents.length / PAGE_SIZE));
|
||||
const page = Math.min(currentPage, pageCount);
|
||||
const visibleAgents = sortedAgents.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
styles={styles}
|
||||
currentPage="agents"
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
searchAriaLabel="Search agents"
|
||||
>
|
||||
<Box className={styles.hero}>
|
||||
<div className={styles.heroInner}>
|
||||
<svg
|
||||
className={styles.heroIcon}
|
||||
width="36"
|
||||
height="36"
|
||||
viewBox="0 0 96 96"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M48 53L23 72"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M47.9998 53L72.9998 72"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M48.01 53L48.01 26"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M87 91C91.9706 91 96 86.9706 96 82C96 77.0294 91.9706 73 87 73C82.0294 73 78 77.0294 78 82C78 86.9706 82.0294 91 87 91Z"
|
||||
fill="var(--base-color-scale-gray-5)"
|
||||
/>
|
||||
<path
|
||||
d="M48 18C52.9706 18 57 13.9706 57 9C57 4.02944 52.9706 0 48 0C43.0294 0 39 4.02944 39 9C39 13.9706 43.0294 18 48 18Z"
|
||||
fill="var(--base-color-scale-gray-5)"
|
||||
/>
|
||||
<path
|
||||
d="M9 91C13.9706 91 18 86.9706 18 82C18 77.0294 13.9706 73 9 73C4.02944 73 0 77.0294 0 82C0 86.9706 4.02944 91 9 91Z"
|
||||
fill="var(--base-color-scale-gray-5)"
|
||||
/>
|
||||
</svg>
|
||||
<Heading as="h1" size="3" className={styles.heroHeading}>
|
||||
Agents
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted" className={styles.heroText}>
|
||||
Specialized agents that enhance GitHub Copilot for specific
|
||||
technologies, workflows, and domains — contributed and curated by
|
||||
the community.
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Section id="catalog" paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box className={styles.catalog}>
|
||||
<aside className={styles.filterNav} aria-label="Filter agents">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.filterToggle}
|
||||
aria-expanded={mobileFiltersOpen}
|
||||
onClick={() => setMobileFiltersOpen((open) => !open)}
|
||||
>
|
||||
<span>
|
||||
Filters{hasActiveFilters ? ` (${activeFilterCount})` : ""}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
size={16}
|
||||
className={clsx(
|
||||
styles.filterToggleChevron,
|
||||
mobileFiltersOpen && styles.filterToggleChevronOpen,
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.filterBody,
|
||||
!mobileFiltersOpen && styles.filterBodyCollapsed,
|
||||
)}
|
||||
>
|
||||
{filterGroups.map((group) => (
|
||||
<div className={styles.filterGroup} key={group.id}>
|
||||
<Text as="h2" size="100" className={styles.filterHeading}>
|
||||
{group.label}
|
||||
</Text>
|
||||
<div className={styles.filterOptions}>
|
||||
{group.options.map((option) => (
|
||||
<div className={styles.filterOption} key={option}>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={filters[group.id].includes(option)}
|
||||
onChange={() => toggleFilter(group.id, option)}
|
||||
/>
|
||||
<FormControl.Label>{option}</FormControl.Label>
|
||||
</FormControl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{hasActiveFilters ? (
|
||||
<div className={styles.filterActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
hasArrow={false}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear all ({activeFilterCount})
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Box className={styles.catalogMain}>
|
||||
<Box className={styles.gridFrame} data-mode={colorMode}>
|
||||
<Box className={styles.gridContent}>
|
||||
<Grid
|
||||
className={styles.threeUp}
|
||||
columnGap="none"
|
||||
rowGap="none"
|
||||
enableGutters={false}
|
||||
>
|
||||
{visibleAgents.map((agent) => (
|
||||
<Grid.Column
|
||||
key={agent.id}
|
||||
span={{ xsmall: 12, medium: 6, large: 6 }}
|
||||
className={styles.col}
|
||||
>
|
||||
<Box className={clsx(styles.item, styles.itemHover)}>
|
||||
<Card
|
||||
href={agentDetailHref(agent)}
|
||||
fullWidth
|
||||
ctaVariant="none"
|
||||
backgroundColor="none"
|
||||
className={styles.card}
|
||||
>
|
||||
<Card.Heading as="h2">{agent.title}</Card.Heading>
|
||||
<Card.Description>
|
||||
<span className={styles.cardDescText}>
|
||||
{agent.description}
|
||||
</span>
|
||||
</Card.Description>
|
||||
</Card>
|
||||
<div className={styles.cardActions}>
|
||||
<ActionMenu mode="split-button" menuAlignment="start">
|
||||
<ActionMenu.Button
|
||||
as="a"
|
||||
href={installUrl(agent)}
|
||||
variant="primary"
|
||||
>
|
||||
Install
|
||||
</ActionMenu.Button>
|
||||
<ActionMenu.Overlay
|
||||
aria-label={`Install ${agent.title} to your editor`}
|
||||
>
|
||||
<ActionMenu.Item as="a" href={installUrl(agent, false)}>
|
||||
VS Code
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item as="a" href={installUrl(agent, true)}>
|
||||
VS Code Insiders
|
||||
</ActionMenu.Item>
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
<Button
|
||||
as="a"
|
||||
href={downloadUrl(agent)}
|
||||
variant="secondary"
|
||||
download
|
||||
aria-label={`Download ${agent.title} agent file`}
|
||||
className={styles.iconButton}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href={agentSourceUrl(agent)}
|
||||
variant="secondary"
|
||||
aria-label={`View ${agent.title} agent on GitHub`}
|
||||
className={styles.iconButton}
|
||||
>
|
||||
<MarkGithubIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</Grid.Column>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{sortedAgents.length === 0 ? (
|
||||
<Box className={styles.emptyState}>
|
||||
<Text as="p" variant="muted">
|
||||
No agents match the selected filters.
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
<div className={styles.rule} aria-hidden="true" />
|
||||
|
||||
<Box className={styles.paginationRow}>
|
||||
<Stack direction="horizontal" justifyContent="center" padding="none">
|
||||
<Pagination
|
||||
className={styles.pagination}
|
||||
pageCount={pageCount}
|
||||
currentPage={page}
|
||||
onPageChange={(e, n) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(n);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<div className={styles.rule} aria-hidden="true" />
|
||||
|
||||
<Box className={styles.ctaFrame}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box className={styles.ctaFrameInner}>
|
||||
<CTABanner align="center" backgroundColor="subtle">
|
||||
<CTABanner.Heading>
|
||||
Can’t find the agent you need?
|
||||
</CTABanner.Heading>
|
||||
<CTABanner.Description>
|
||||
This library is community-built. Share an agent you use—or
|
||||
request one—to help developers ship faster with GitHub
|
||||
Copilot.
|
||||
</CTABanner.Description>
|
||||
<CTABanner.ButtonGroup>
|
||||
<Button as="a" href={CONTRIBUTE_URL}>
|
||||
Submit an agent
|
||||
</Button>
|
||||
<Button as="a" href={REQUEST_URL}>
|
||||
Request an agent
|
||||
</Button>
|
||||
</CTABanner.ButtonGroup>
|
||||
</CTABanner>
|
||||
</Box>
|
||||
</Section>
|
||||
</Box>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
type CommunityIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export function CommunityIcon({ size = 64 }: CommunityIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Community"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M31.999 0.294922C49.5094 0.295276 63.7048 14.4906 63.7051 32.001C63.705 49.5115 49.5095 63.7067 31.999 63.707C14.4882 63.707 0.293059 49.5117 0.292969 32.001C0.293013 29.0498 0.697785 26.1932 1.45215 23.4824C1.9826 24.6649 2.64702 25.7739 3.42578 26.791C3.18471 28.121 3.03461 29.4826 2.98145 30.8691C3.18557 30.7421 3.42639 30.669 3.68457 30.6689H7.62207C10.2268 32.355 13.3313 33.3349 16.665 33.335C19.999 33.3349 23.104 32.3552 25.709 30.6689H43.1084C42.9723 26.4828 42.1725 22.3445 40.7324 18.4053H34.4043C33.9397 18.4051 33.5306 18.1672 33.292 17.8066C33.3174 17.4303 33.332 17.0507 33.332 16.668C33.332 16.5413 33.329 16.415 33.3262 16.2891C33.5685 15.9561 33.961 15.7394 34.4043 15.7393H39.6475C37.6975 11.4105 34.9796 7.45605 31.6221 4.05762C30.9351 4.75295 30.2749 5.47159 29.6426 6.21191C29.0801 5.51473 28.4629 4.86371 27.7969 4.26562C28.1273 3.88376 28.4634 3.50633 28.8076 3.13574C28.1279 3.21003 27.4558 3.3103 26.791 3.43066C25.7726 2.65054 24.6618 1.98518 23.4775 1.4541C26.1891 0.699263 29.0468 0.294922 31.999 0.294922ZM42.3301 48.7783C40.3944 53.2545 37.6991 57.3684 34.3613 60.9443C43.1709 60.2349 50.87 55.5949 55.7031 48.7783H42.3301ZM8.29297 48.7783C12.9739 55.38 20.3433 59.9397 28.8057 60.8652C25.5023 57.3079 22.8333 53.2211 20.9121 48.7783H8.29297ZM23.8359 48.7783C25.7666 52.9062 28.3983 56.6838 31.6221 59.9473C34.8458 56.6838 37.4767 52.9062 39.4072 48.7783H23.8359ZM2.98145 33.1328C3.1615 37.8318 4.45885 42.2435 6.61426 46.1123H19.8652C18.4089 42.0006 17.5984 37.6923 17.4658 33.335H3.68457C3.42621 33.3349 3.18567 33.26 2.98145 33.1328ZM20.1338 33.335C20.2759 37.7035 21.145 42.0194 22.7061 46.1123H40.5371C42.0981 42.0194 42.9673 37.7035 43.1094 33.335H20.1338ZM61.0137 33.1328C60.8095 33.2597 60.5695 33.335 60.3115 33.335H45.7773C45.6447 37.6923 44.8342 42.0006 43.3779 46.1123H57.3828C59.538 42.2436 60.8336 37.8316 61.0137 33.1328ZM43.5586 18.4053C44.9024 22.3616 45.6493 26.493 45.7764 30.6689H60.3115C60.5693 30.6689 60.8096 30.7425 61.0137 30.8691C60.8415 26.3779 59.6502 22.1486 57.6631 18.4053H43.5586ZM34.3604 3.05664C37.7925 6.73256 40.5485 10.9762 42.4951 15.5986C42.5147 15.6452 42.5323 15.6926 42.5518 15.7393H56.0605C51.2544 8.64143 43.3906 3.78357 34.3604 3.05664Z"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
<path
|
||||
d="M31.9987 16.6673C31.9986 25.1356 25.1336 32.0006 16.6654 32.0007C8.19701 32.0007 1.33212 25.1356 1.33203 16.6673C1.33203 8.19891 8.19696 1.33398 16.6654 1.33398C25.1337 1.33407 31.9987 8.19897 31.9987 16.6673Z"
|
||||
stroke="var(--brand-color-text-default)"
|
||||
strokeWidth="2.66667"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M12.1084 18.2175C15.2297 17.6921 18.418 17.7009 21.5361 18.2449C24.4973 18.7616 26.86 21.005 27.5303 23.9353L28.1826 26.7888C27.6491 27.3955 27.0694 27.9604 26.4473 28.4763C26.0427 28.3242 25.72 27.9789 25.6162 27.5261L24.9307 24.529C24.4997 22.6459 22.9812 21.2051 21.0781 20.8728C18.2579 20.3808 15.3739 20.3722 12.5508 20.8474L12.4062 20.8708C10.412 21.2066 8.8173 22.712 8.36621 24.6833L7.71582 27.5261C7.61198 27.9793 7.28787 28.3244 6.88281 28.4763C6.26111 27.9606 5.68164 27.3961 5.14844 26.7898L5.7666 24.0896C6.45828 21.0656 8.90482 18.7569 11.9639 18.2419L12.1084 18.2175Z"
|
||||
fill="var(--brand-color-text-default)"
|
||||
/>
|
||||
<rect
|
||||
x="12.6641"
|
||||
y="7.33398"
|
||||
width="8"
|
||||
height="8"
|
||||
rx="4"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import styles from "./styles/ContributorsHoverCard.module.css";
|
||||
import { CommunityIcon } from "./CommunityIcon";
|
||||
|
||||
type ContributorsHoverCardProps = {
|
||||
size?: number;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export function ContributorsHoverCard({
|
||||
size = 56,
|
||||
href,
|
||||
}: ContributorsHoverCardProps) {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<a
|
||||
href={href}
|
||||
className={styles.trigger}
|
||||
aria-label="Meet the contributors"
|
||||
>
|
||||
<CommunityIcon size={size} />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { HeartFillIcon } from "@primer/octicons-react";
|
||||
|
||||
import styles from "./styles/ContributorsNavButton.module.css";
|
||||
|
||||
type ContributorsNavButtonProps = {
|
||||
href: string;
|
||||
current?: boolean;
|
||||
/** Live contributor count, injected from build-time data. */
|
||||
total?: number;
|
||||
};
|
||||
|
||||
export function ContributorsNavButton({
|
||||
href,
|
||||
current = false,
|
||||
total = 0,
|
||||
}: ContributorsNavButtonProps) {
|
||||
return (
|
||||
<a
|
||||
className={styles.trigger}
|
||||
href={href}
|
||||
aria-current={current ? "page" : undefined}
|
||||
aria-label={`Meet all ${total.toLocaleString()} contributors`}
|
||||
>
|
||||
<span className={styles.heart} aria-hidden="true">
|
||||
<HeartFillIcon size={14} />
|
||||
</span>
|
||||
<span className={styles.countLabel}>{total.toLocaleString()}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ArrowUpIcon } from "@primer/octicons-react";
|
||||
import { Avatar, Box, Button, Heading, Text } from "@primer/react-brand";
|
||||
import { clsx } from "clsx";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { PageShell } from "./PageShell";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import contributorsStyles from "./styles/contributorsPage.module.css";
|
||||
import styles from "./styles/styles.module.css";
|
||||
|
||||
const CONTRIBUTING_URL =
|
||||
"https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md";
|
||||
|
||||
/** A contributor as recorded in the repository's `.all-contributorsrc`. */
|
||||
export type Contributor = {
|
||||
login: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
profileUrl: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The contributors page, ported from the design prototype's `contributors.tsx`.
|
||||
* The prototype's static `contributors.ts` array is replaced by build-time data
|
||||
* read from `.all-contributorsrc`; the layout and interactions are unchanged.
|
||||
*/
|
||||
export function ContributorsPage({
|
||||
contributors,
|
||||
searchIndex,
|
||||
}: {
|
||||
contributors: Contributor[];
|
||||
searchIndex?: SearchItem[];
|
||||
}) {
|
||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
const total = contributors.length;
|
||||
|
||||
useEffect(() => {
|
||||
const updateBackToTop = () => setShowBackToTop(window.scrollY > 200);
|
||||
updateBackToTop();
|
||||
window.addEventListener("scroll", updateBackToTop, { passive: true });
|
||||
return () => window.removeEventListener("scroll", updateBackToTop);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
? "auto"
|
||||
: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
styles={styles}
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={total}
|
||||
contributorsCurrent
|
||||
searchAriaLabel="Search the library"
|
||||
>
|
||||
<Box className={contributorsStyles.hero}>
|
||||
<div className={contributorsStyles.heroInner}>
|
||||
<Heading as="h1" size="3" className={contributorsStyles.heroHeading}>
|
||||
Our contributors
|
||||
</Heading>
|
||||
<Text
|
||||
as="p"
|
||||
size="300"
|
||||
variant="muted"
|
||||
className={contributorsStyles.heroText}
|
||||
>
|
||||
Thank you to the {total.toLocaleString()} people who have shared
|
||||
ideas, examples, and improvements with Awesome Copilot.
|
||||
</Text>
|
||||
<Button as="a" href={CONTRIBUTING_URL} variant="primary">
|
||||
Become a contributor
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<section className={contributorsStyles.section} aria-label="Contributors">
|
||||
<div className={contributorsStyles.sectionInner}>
|
||||
<ul className={contributorsStyles.grid}>
|
||||
{contributors.map((contributor) => (
|
||||
<li
|
||||
className={contributorsStyles.gridCell}
|
||||
key={contributor.login}
|
||||
>
|
||||
<a
|
||||
className={contributorsStyles.contributorLink}
|
||||
href={contributor.profileUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Avatar
|
||||
src={contributor.avatarUrl}
|
||||
alt=""
|
||||
size={64}
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className={contributorsStyles.contributorName}>
|
||||
{contributor.name}
|
||||
</span>
|
||||
<span className={contributorsStyles.contributorLogin}>
|
||||
@{contributor.login}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
contributorsStyles.backToTop,
|
||||
showBackToTop && contributorsStyles.backToTopVisible,
|
||||
)}
|
||||
onClick={scrollToTop}
|
||||
aria-label="Back to top"
|
||||
aria-hidden={!showBackToTop}
|
||||
tabIndex={showBackToTop ? 0 : -1}
|
||||
>
|
||||
<ArrowUpIcon size={24} />
|
||||
</button>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Heading, Text } from "@primer/react-brand";
|
||||
|
||||
import styles from "./styles/github-copilot-app.module.css";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import {
|
||||
LearningArticleLayout,
|
||||
type TocSection,
|
||||
} from "./LearningArticleLayout";
|
||||
|
||||
export type RecipeLink = {
|
||||
label: string;
|
||||
href: string;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
export type CookbookRecipeCard = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
author?: { name: string; href: string };
|
||||
tags: string[];
|
||||
links: RecipeLink[];
|
||||
};
|
||||
|
||||
export type CookbookSection = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
recipes: CookbookRecipeCard[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Cookbook index.
|
||||
*
|
||||
* The prototype hardcoded two sections ("GitHub Copilot SDK" and "Community
|
||||
* Samples"); here they are derived from `public/data/samples.json` so new
|
||||
* cookbooks appear without a code change. The article chassis owns its own
|
||||
* shell, hero and footer, so it replaces `PageShell` rather than nesting in it.
|
||||
*/
|
||||
export function CookbookIndex({
|
||||
sections,
|
||||
searchIndex = [],
|
||||
contributorsTotal = 0,
|
||||
}: {
|
||||
sections: CookbookSection[];
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
}) {
|
||||
const tocSections: TocSection[] = sections.map((section) => ({
|
||||
id: section.id,
|
||||
label: section.name,
|
||||
}));
|
||||
|
||||
return (
|
||||
<LearningArticleLayout
|
||||
pageHref={pageHref}
|
||||
currentPage="cookbook"
|
||||
breadcrumbLabel="Cookbook"
|
||||
heroTitle="Cookbook"
|
||||
heroSubtitle="Code samples, recipes, and hands-on examples for building with GitHub Copilot — ready to copy into your own projects and adapt."
|
||||
tocSections={tocSections}
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<section
|
||||
key={section.id}
|
||||
id={section.id}
|
||||
className={styles.articleSection}
|
||||
>
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
{section.name}
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted">
|
||||
{section.description}
|
||||
</Text>
|
||||
{section.recipes.map((recipe) => (
|
||||
<div key={recipe.id} className={styles.promptGroup}>
|
||||
<span className={styles.promptLabel}>{recipe.title}</span>
|
||||
{recipe.badge ? (
|
||||
<Text as="p" size="300" variant="muted">
|
||||
{recipe.badge}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text as="p" size="300" variant="muted">
|
||||
{recipe.description}
|
||||
</Text>
|
||||
{recipe.author ? (
|
||||
<Text as="p" size="300" variant="muted">
|
||||
by{" "}
|
||||
<a href={recipe.author.href} target="_blank" rel="noopener">
|
||||
{recipe.author.name}
|
||||
</a>
|
||||
</Text>
|
||||
) : null}
|
||||
<ul className={styles.checkList}>
|
||||
{recipe.tags.map((tag) => (
|
||||
<li key={tag}>{tag}</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul className={styles.checkList}>
|
||||
{recipe.links.map((link) => (
|
||||
<li key={link.label}>
|
||||
<a
|
||||
href={link.href}
|
||||
target={link.external ? "_blank" : undefined}
|
||||
rel={link.external ? "noopener" : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</LearningArticleLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import { DownloadIcon, FileDirectoryIcon, MarkGithubIcon } from "@primer/octicons-react";
|
||||
import { clsx } from "clsx";
|
||||
import React from "react";
|
||||
|
||||
import { Button, Text } from "@primer/react-brand";
|
||||
|
||||
import { DetailChassis, type DetailTocItem } from "./DetailChassis";
|
||||
import { ResourceMeta } from "./ResourceMeta";
|
||||
import {
|
||||
SyntaxHighlightedCode,
|
||||
detectCodeLanguage,
|
||||
type CodeLanguage,
|
||||
} from "./SyntaxHighlightedCode";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import styles from "./styles/dotnet-upgrade.module.css";
|
||||
import fileStyles from "./styles/skill-files.module.css";
|
||||
|
||||
export type RecipeFile = {
|
||||
/** Repo-relative path, e.g. `cookbook/copilot-sdk/nodejs/error-handling.md`. */
|
||||
path: string;
|
||||
/** File name shown on the tab. */
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type CookbookRecipeProps = {
|
||||
cookbookName: string;
|
||||
recipeName: string;
|
||||
recipeDescription: string;
|
||||
languageName: string;
|
||||
languageIcon?: string;
|
||||
tags: string[];
|
||||
files: RecipeFile[];
|
||||
/** Path of the file rendered server-side (the recipe's markdown doc). */
|
||||
primaryPath: string;
|
||||
/** Rendered, sanitized markdown for `primaryPath`. */
|
||||
markdownHtml: string;
|
||||
rawMarkdown: string;
|
||||
toc: DetailTocItem[];
|
||||
/** `https://github.com/.../blob/main` — per-file links are built from this. */
|
||||
githubBlobBase: string;
|
||||
/** `https://raw.githubusercontent.com/.../main` — lazy file fetches. */
|
||||
rawBase: string;
|
||||
lastUpdated?: string | null;
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
};
|
||||
|
||||
type FileKind = "markdown" | "code" | "text";
|
||||
|
||||
const CODE_LANGUAGES: Record<string, CodeLanguage> = {
|
||||
ts: "tsx",
|
||||
tsx: "tsx",
|
||||
js: "tsx",
|
||||
jsx: "tsx",
|
||||
mjs: "tsx",
|
||||
cs: "tsx",
|
||||
go: "tsx",
|
||||
java: "tsx",
|
||||
py: "bash",
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
ps1: "bash",
|
||||
json: "json",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
html: "markup",
|
||||
xml: "markup",
|
||||
};
|
||||
|
||||
function extensionOf(path: string): string {
|
||||
return path.split(".").pop()?.toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function kindOf(path: string): FileKind {
|
||||
const ext = extensionOf(path);
|
||||
if (ext === "md" || ext === "markdown") return "markdown";
|
||||
if (ext in CODE_LANGUAGES) return "code";
|
||||
return "text";
|
||||
}
|
||||
|
||||
function encodeRepoPath(filePath: string): string {
|
||||
return filePath.split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookbook recipe detail route.
|
||||
*
|
||||
* A recipe has one doc and (usually) one runnable example per language, so the
|
||||
* content region is the same file switcher used by skills: the doc is rendered
|
||||
* at build time, the example is fetched from raw.githubusercontent.com on first
|
||||
* selection and then cached. The `#file=<path>` deep-link convention from the
|
||||
* previous file browser is preserved so existing shared links keep working.
|
||||
*/
|
||||
export function CookbookRecipe({
|
||||
cookbookName,
|
||||
recipeName,
|
||||
recipeDescription,
|
||||
languageName,
|
||||
languageIcon,
|
||||
tags,
|
||||
files,
|
||||
primaryPath,
|
||||
markdownHtml,
|
||||
rawMarkdown,
|
||||
toc,
|
||||
githubBlobBase,
|
||||
rawBase,
|
||||
lastUpdated,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
}: CookbookRecipeProps) {
|
||||
const [activePath, setActivePath] = React.useState(primaryPath);
|
||||
const [contents, setContents] = React.useState<Record<string, string>>({});
|
||||
const [status, setStatus] = React.useState<"idle" | "loading" | "error">(
|
||||
"idle",
|
||||
);
|
||||
|
||||
const active = files.find((file) => file.path === activePath) ?? files[0];
|
||||
const isPrimary = active?.path === primaryPath;
|
||||
const kind = active ? kindOf(active.path) : "text";
|
||||
|
||||
React.useEffect(() => {
|
||||
const fromHash = () => {
|
||||
const match = /^#file=(.+)$/.exec(window.location.hash);
|
||||
if (!match) return;
|
||||
let wanted: string | undefined;
|
||||
try {
|
||||
wanted = decodeURIComponent(match[1]);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (files.some((file) => file.path === wanted)) setActivePath(wanted);
|
||||
};
|
||||
fromHash();
|
||||
window.addEventListener("hashchange", fromHash);
|
||||
return () => window.removeEventListener("hashchange", fromHash);
|
||||
}, [files]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active || isPrimary) return;
|
||||
if (contents[active.path] !== undefined) {
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setStatus("loading");
|
||||
fetch(`${rawBase}/${encodeRepoPath(active.path)}`)
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(String(response.status));
|
||||
return response.text();
|
||||
})
|
||||
.then((text) => {
|
||||
if (cancelled) return;
|
||||
setContents((current) => ({ ...current, [active.path]: text }));
|
||||
setStatus("idle");
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStatus("error");
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active, isPrimary, rawBase, contents]);
|
||||
|
||||
const selectFile = React.useCallback((path: string) => {
|
||||
setActivePath(path);
|
||||
const hash = `#file=${encodeURIComponent(path)}`;
|
||||
if (window.location.hash !== hash) history.replaceState(null, "", hash);
|
||||
}, []);
|
||||
|
||||
const activeGithubUrl = active
|
||||
? `${githubBlobBase}/${encodeRepoPath(active.path)}`
|
||||
: `${githubBlobBase}/${encodeRepoPath(primaryPath)}`;
|
||||
const activeRawUrl = active
|
||||
? `${rawBase}/${encodeRepoPath(active.path)}`
|
||||
: undefined;
|
||||
|
||||
const install = (
|
||||
<Button
|
||||
as="a"
|
||||
href={activeGithubUrl}
|
||||
variant="secondary"
|
||||
className={styles.iconButton}
|
||||
aria-label={`View the ${recipeName} recipe on GitHub`}
|
||||
>
|
||||
<MarkGithubIcon size={16} />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<DetailChassis
|
||||
title={recipeName}
|
||||
description={recipeDescription}
|
||||
breadcrumbs={[
|
||||
{ label: "Cookbook", href: pageHref("cookbook") },
|
||||
{ label: cookbookName, href: pageHref("cookbook") },
|
||||
{ label: `${recipeName} (${languageName})` },
|
||||
]}
|
||||
install={install}
|
||||
/* Anchors only exist while the recipe doc is on screen. */
|
||||
toc={isPrimary ? toc : []}
|
||||
sidebar={
|
||||
<ResourceMeta
|
||||
kicker="Recipe details"
|
||||
groups={[
|
||||
{ label: "Cookbook", items: [cookbookName] },
|
||||
{
|
||||
label: "Language",
|
||||
items: [
|
||||
languageIcon ? `${languageIcon} ${languageName}` : languageName,
|
||||
],
|
||||
},
|
||||
{ label: "Tags", items: tags },
|
||||
{
|
||||
label: "Files",
|
||||
items: [`${files.length} file${files.length === 1 ? "" : "s"}`],
|
||||
},
|
||||
]}
|
||||
lastUpdated={lastUpdated}
|
||||
sourceUrl={`${githubBlobBase}/${encodeRepoPath(primaryPath)}`}
|
||||
/>
|
||||
}
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
>
|
||||
<section className={styles.articleSection}>
|
||||
{files.length > 1 ? (
|
||||
<>
|
||||
<span className={styles.codeLabel}>
|
||||
<FileDirectoryIcon size={16} /> Files in this recipe
|
||||
</span>
|
||||
<ul
|
||||
className={fileStyles.fileTabs}
|
||||
aria-label="Files in this recipe"
|
||||
>
|
||||
{files.map((file) => (
|
||||
<li key={file.path}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
fileStyles.fileTab,
|
||||
file.path === activePath && fileStyles.fileTabActive,
|
||||
)}
|
||||
aria-current={file.path === activePath ? "true" : undefined}
|
||||
onClick={() => selectFile(file.path)}
|
||||
>
|
||||
{file.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{active ? (
|
||||
<div className={fileStyles.fileBar}>
|
||||
<span className={fileStyles.filePath}>{active.name}</span>
|
||||
<div className={fileStyles.fileActions}>
|
||||
<Button
|
||||
as="a"
|
||||
href={activeGithubUrl}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className={styles.iconButton}
|
||||
aria-label={`View ${active.name} on GitHub`}
|
||||
>
|
||||
<MarkGithubIcon size={16} />
|
||||
</Button>
|
||||
{activeRawUrl ? (
|
||||
<Button
|
||||
as="a"
|
||||
href={activeRawUrl}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
download
|
||||
className={styles.iconButton}
|
||||
aria-label={`Download ${active.name}`}
|
||||
>
|
||||
<DownloadIcon size={16} />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<RecipeFileView
|
||||
active={active ?? null}
|
||||
isPrimary={isPrimary}
|
||||
kind={kind}
|
||||
markdownHtml={markdownHtml}
|
||||
rawMarkdown={rawMarkdown}
|
||||
text={active ? contents[active.path] : undefined}
|
||||
status={status}
|
||||
githubUrl={activeGithubUrl}
|
||||
/>
|
||||
</section>
|
||||
</DetailChassis>
|
||||
);
|
||||
}
|
||||
|
||||
function RecipeFileView({
|
||||
active,
|
||||
isPrimary,
|
||||
kind,
|
||||
markdownHtml,
|
||||
rawMarkdown,
|
||||
text,
|
||||
status,
|
||||
githubUrl,
|
||||
}: {
|
||||
active: RecipeFile | null;
|
||||
isPrimary: boolean;
|
||||
kind: FileKind;
|
||||
markdownHtml: string;
|
||||
rawMarkdown: string;
|
||||
text?: string;
|
||||
status: "idle" | "loading" | "error";
|
||||
githubUrl: string;
|
||||
}) {
|
||||
const [markdown, setMarkdown] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isPrimary || kind !== "markdown" || text === undefined) {
|
||||
setMarkdown(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void Promise.all([
|
||||
import("marked"),
|
||||
import("../../lib/sanitize-html"),
|
||||
import("../../lib/markdown-a11y"),
|
||||
]).then(([{ marked }, { sanitizeHtml }, { enhanceMarkdownA11y }]) => {
|
||||
if (cancelled) return;
|
||||
setMarkdown(
|
||||
enhanceMarkdownA11y(
|
||||
sanitizeHtml(marked.parse(text, { async: false }) as string),
|
||||
),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isPrimary, kind, text]);
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
if (isPrimary) {
|
||||
return markdownHtml ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: markdownHtml }} />
|
||||
) : (
|
||||
<pre tabIndex={0} className={styles.codeBlock}>
|
||||
<code>{rawMarkdown}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<Text as="p" size="200" variant="muted">
|
||||
Loading {active.name}…
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error" || text === undefined) {
|
||||
return (
|
||||
<Text as="p" size="200" variant="muted">
|
||||
Couldn't load this file. <a href={githubUrl}>View it on GitHub</a>.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "markdown") {
|
||||
return markdown ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: markdown }} />
|
||||
) : (
|
||||
<Text as="p" size="200" variant="muted">
|
||||
Rendering {active.name}…
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "code") {
|
||||
const language =
|
||||
CODE_LANGUAGES[extensionOf(active.path)] ?? detectCodeLanguage(text);
|
||||
return (
|
||||
<SyntaxHighlightedCode
|
||||
className={styles.codeBlock}
|
||||
code={text}
|
||||
language={language}
|
||||
lineClassName={styles.codeLine}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<pre tabIndex={0} className={styles.codeBlock}>
|
||||
<code>{text}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import {
|
||||
BookIcon,
|
||||
BrowserIcon,
|
||||
ChecklistIcon,
|
||||
CopilotIcon,
|
||||
MarkGithubIcon,
|
||||
PlugIcon,
|
||||
ZapIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { clsx } from "clsx";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CTABanner,
|
||||
Grid,
|
||||
Hero,
|
||||
Image,
|
||||
Section,
|
||||
Stack,
|
||||
Token,
|
||||
useTheme,
|
||||
} from "@primer/react-brand";
|
||||
|
||||
import styles from "./styles/styles.module.css";
|
||||
import brandDivider from "./brand-divider-copilot-sitting.webp";
|
||||
import { LargeFooter } from "./LargeFooter";
|
||||
import { ContributorsHoverCard } from "./ContributorsHoverCard";
|
||||
import { ContributorsNavButton } from "./ContributorsNavButton";
|
||||
import { LearningIcon } from "./LearningIcon";
|
||||
import type { PrototypePageProps } from "./pageHref";
|
||||
import { getAwesomeCopilotNavLinks } from "./navigation";
|
||||
import { TopNav } from "./TopNav";
|
||||
import { SkipLink } from "./SkipLink";
|
||||
import { TopNavSearch } from "./TopNavSearch";
|
||||
|
||||
const REPO_URL = "https://github.com/github/awesome-copilot";
|
||||
const CONTRIBUTING_URL =
|
||||
"https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md";
|
||||
|
||||
type ResourceIcon = React.ComponentType<{
|
||||
size?: number | "small" | "medium" | "large";
|
||||
}>;
|
||||
|
||||
type Resource = {
|
||||
icon: ResourceIcon;
|
||||
name: string;
|
||||
count?: string;
|
||||
description: string;
|
||||
page?: string;
|
||||
anchor?: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
const resources: Resource[] = [
|
||||
{
|
||||
icon: CopilotIcon,
|
||||
name: "Agents",
|
||||
count: "220",
|
||||
description:
|
||||
"Custom agents that give Copilot specialized skills for your projects.",
|
||||
page: "agents",
|
||||
id: "agents",
|
||||
},
|
||||
{
|
||||
icon: ChecklistIcon,
|
||||
name: "Instructions",
|
||||
count: "189",
|
||||
description:
|
||||
"Coding standards and best practices that steer Copilot's output.",
|
||||
page: "instructions",
|
||||
id: "instructions",
|
||||
},
|
||||
{
|
||||
icon: ZapIcon,
|
||||
name: "Skills",
|
||||
count: "386",
|
||||
description:
|
||||
"Self-contained folders that bundle instructions and resources together.",
|
||||
page: "skills",
|
||||
id: "skills",
|
||||
},
|
||||
{
|
||||
icon: PlugIcon,
|
||||
name: "Plugins",
|
||||
count: "110",
|
||||
description:
|
||||
"Curated plugins, grouped by theme, that extend what Copilot can do.",
|
||||
page: "plugins",
|
||||
id: "plugins",
|
||||
},
|
||||
{
|
||||
icon: BrowserIcon,
|
||||
name: "Extensions",
|
||||
count: "14",
|
||||
description:
|
||||
"Interactive canvas extensions that enrich the Copilot app experience.",
|
||||
page: "extensions",
|
||||
id: "extensions",
|
||||
},
|
||||
{
|
||||
icon: BookIcon,
|
||||
name: "Playbook",
|
||||
count: "27",
|
||||
description:
|
||||
"Articles and guides for getting the most from every agent and skill.",
|
||||
page: "learning-hub-copilot-app",
|
||||
id: "learning-hub-copilot-app",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AwesomeCopilot({ pageHref }: PrototypePageProps) {
|
||||
const { colorMode } = useTheme();
|
||||
const subNavLinks = getAwesomeCopilotNavLinks(pageHref);
|
||||
const internalHref = ({ page, anchor }: { page?: string; anchor?: string }) =>
|
||||
page ? pageHref(page) : `#${anchor}`;
|
||||
|
||||
return (
|
||||
<Box className={styles.page} backgroundColor="default" data-mode={colorMode}>
|
||||
<SkipLink />
|
||||
<header className={styles.topBar}>
|
||||
<nav className={styles.topBarInner} aria-label="Primary">
|
||||
<a href={pageHref()} className={styles.subNavTitle}>
|
||||
<MarkGithubIcon size={20} />
|
||||
Awesome GitHub Copilot
|
||||
</a>
|
||||
<TopNav
|
||||
styles={styles}
|
||||
links={subNavLinks}
|
||||
pageHref={pageHref}
|
||||
/>
|
||||
<div className={styles.topBarActions}>
|
||||
<TopNavSearch
|
||||
pageHref={pageHref}
|
||||
styles={styles}
|
||||
inputAriaLabel="Search the library"
|
||||
/>
|
||||
<ContributorsNavButton href={pageHref("contributors")} />
|
||||
<Button as="a" href={CONTRIBUTING_URL} variant="subtle" size="small">
|
||||
Contribute
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content" tabIndex={-1}>
|
||||
<Box className={styles.heroFrame}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box className={styles.heroFrameInner}>
|
||||
<Stack
|
||||
direction="vertical"
|
||||
alignItems="center"
|
||||
gap="normal"
|
||||
padding="none"
|
||||
>
|
||||
<div className={styles.heroRiseIcon}>
|
||||
<ContributorsHoverCard
|
||||
size={56}
|
||||
href={pageHref("contributors")}
|
||||
/>
|
||||
</div>
|
||||
<Hero align="center">
|
||||
<Hero.Heading>
|
||||
The community library for GitHub Copilot
|
||||
</Hero.Heading>
|
||||
<Hero.Description>
|
||||
Discover reusable agents, skills, instructions, hooks, and
|
||||
tools built by developers to help you ship faster with
|
||||
Copilot.
|
||||
</Hero.Description>
|
||||
<Hero.PrimaryAction href={REPO_URL}>
|
||||
Explore repository
|
||||
</Hero.PrimaryAction>
|
||||
<Hero.SecondaryAction href={CONTRIBUTING_URL}>
|
||||
Become a contributor
|
||||
</Hero.SecondaryAction>
|
||||
</Hero>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Section>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
id="resources"
|
||||
className={styles.cardGridFrame}
|
||||
marginBlockEnd={{ narrow: 24, wide: 80 }}
|
||||
>
|
||||
<Box className={styles.cardGridContent}>
|
||||
<Grid columnGap="none" rowGap="none" enableGutters={false}>
|
||||
{resources.map((item) => (
|
||||
<Grid.Column
|
||||
key={item.name}
|
||||
span={{ xsmall: 12, small: 6, xlarge: 4 }}
|
||||
className={clsx(
|
||||
styles.cardGridColumn,
|
||||
styles.cardGridColumnArrowHover,
|
||||
)}
|
||||
>
|
||||
<Box className={styles.cardGridItem} id={item.id}>
|
||||
<Card
|
||||
href={internalHref(item)}
|
||||
fullWidth
|
||||
ctaVariant="arrow"
|
||||
ctaText={`Explore ${item.name}`}
|
||||
backgroundColor="none"
|
||||
className={styles.resourceCard}
|
||||
>
|
||||
<Card.Heading as="h2" size="5">
|
||||
<span className={styles.cardHeadingRow}>
|
||||
<span>{item.name}</span>
|
||||
{item.count ? (
|
||||
<Token variant="default">{item.count}</Token>
|
||||
) : null}
|
||||
</span>
|
||||
</Card.Heading>
|
||||
<Card.Description>{item.description}</Card.Description>
|
||||
</Card>
|
||||
</Box>
|
||||
</Grid.Column>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box className={styles.ctaFrame}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box id="learning-hub" className={styles.ctaFrameInner}>
|
||||
<CTABanner align="center" hasGridLines>
|
||||
<CTABanner.Logo>
|
||||
<LearningIcon size={64} />
|
||||
</CTABanner.Logo>
|
||||
<CTABanner.Heading as="h2" size="3">
|
||||
Master GitHub Copilot
|
||||
</CTABanner.Heading>
|
||||
<CTABanner.Description>
|
||||
Read the documentation to learn every feature and
|
||||
workflow, then head to GitHub’s YouTube channel for videos,
|
||||
demos, and talks.
|
||||
</CTABanner.Description>
|
||||
<CTABanner.ButtonGroup>
|
||||
<Button
|
||||
as="a"
|
||||
href="https://docs.github.com/en/copilot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read the docs
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href="https://www.youtube.com/@GitHub/featured"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Watch on YouTube
|
||||
</Button>
|
||||
</CTABanner.ButtonGroup>
|
||||
</CTABanner>
|
||||
</Box>
|
||||
</Section>
|
||||
</Box>
|
||||
|
||||
<Box className={styles.dividerFrame}>
|
||||
<Section paddingBlockStart="normal" paddingBlockEnd="none">
|
||||
<Stack justifyContent="center" padding="none">
|
||||
<Image
|
||||
src={brandDivider}
|
||||
alt=""
|
||||
width={1230}
|
||||
height={157}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ display: "block", width: "100%", height: "auto" }}
|
||||
/>
|
||||
</Stack>
|
||||
</Section>
|
||||
</Box>
|
||||
|
||||
</main>
|
||||
|
||||
<LargeFooter />
|
||||
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import { ArrowLeftIcon, ArrowRightIcon, ArrowUpIcon } from "@primer/octicons-react";
|
||||
import { clsx } from "clsx";
|
||||
import React from "react";
|
||||
|
||||
import { Box, Breadcrumbs, Heading, Section, Text } from "@primer/react-brand";
|
||||
|
||||
import { PageShell } from "./PageShell";
|
||||
import type { AwesomeCopilotPage } from "./navigation";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import styles from "./styles/dotnet-upgrade.module.css";
|
||||
import {
|
||||
useAgentDetailHeroPin,
|
||||
useAgentDetailProgress,
|
||||
} from "./useAgentDetailScroll";
|
||||
|
||||
export type DetailCrumb = {
|
||||
label: string;
|
||||
/** Omitted for the trailing (current) crumb. */
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export type DetailTocItem = { id: string; label: string };
|
||||
|
||||
export type DetailSibling = { label: string; href: string };
|
||||
|
||||
export type DetailChassisProps = {
|
||||
/** Resource name — the hero `<h1>`. */
|
||||
title: string;
|
||||
/** One-line summary under the hero title. */
|
||||
description?: string;
|
||||
/** Trail rendered in the hero. The last entry is marked as the current page. */
|
||||
breadcrumbs: DetailCrumb[];
|
||||
/** Hero action row: install split-button, source/download buttons, etc. */
|
||||
install?: React.ReactNode;
|
||||
/** Extra hero content rendered under the description (e.g. `applyTo` tokens). */
|
||||
heroExtras?: React.ReactNode;
|
||||
/** "In this article" entries; ids must exist in the content region. */
|
||||
toc?: DetailTocItem[];
|
||||
/** Extra sidebar panels rendered under the table of contents. */
|
||||
sidebar?: React.ReactNode;
|
||||
previous?: DetailSibling;
|
||||
next?: DetailSibling;
|
||||
/** Catalog tab to mark current in the top nav. */
|
||||
currentPage?: AwesomeCopilotPage;
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
/** Main content region — markdown body, capability lists, file trees, … */
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const TWO_COLUMN_QUERY = "(min-width: 75rem)";
|
||||
|
||||
/**
|
||||
* Shared shell for every resource detail route.
|
||||
*
|
||||
* Reproduces the designers' `agent.tsx` layout: breadcrumbs and a sticky hero
|
||||
* carrying a reading-progress rule, a two-column body whose sidebar holds the
|
||||
* in-page table of contents, sibling navigation, and a back-to-top control —
|
||||
* all inside the site-wide `PageShell` chrome.
|
||||
*
|
||||
* Everything type-specific (install affordances, metadata panels, the content
|
||||
* region itself) arrives through props so the five resource types can differ
|
||||
* without forking this file.
|
||||
*/
|
||||
export function DetailChassis({
|
||||
title,
|
||||
description,
|
||||
breadcrumbs,
|
||||
install,
|
||||
heroExtras,
|
||||
toc = [],
|
||||
sidebar,
|
||||
previous,
|
||||
next,
|
||||
currentPage,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
children,
|
||||
}: DetailChassisProps) {
|
||||
const contentScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const [showBackToTop, setShowBackToTop] = React.useState(false);
|
||||
const [heroBurst, setHeroBurst] = React.useState(false);
|
||||
const [activeSection, setActiveSection] = React.useState(toc[0]?.id ?? "");
|
||||
|
||||
const lastSectionId = toc[toc.length - 1]?.id ?? "";
|
||||
|
||||
useAgentDetailHeroPin(contentScrollRef, {
|
||||
hero: styles.hero,
|
||||
heroInner: styles.heroInner,
|
||||
heroContent: styles.heroContent,
|
||||
});
|
||||
const scrollToTop = useAgentDetailProgress(
|
||||
contentScrollRef,
|
||||
lastSectionId,
|
||||
setShowBackToTop,
|
||||
setHeroBurst,
|
||||
);
|
||||
|
||||
useScrollHostLayout(contentScrollRef);
|
||||
useScrollSpy(contentScrollRef, toc, setActiveSection);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
styles={styles}
|
||||
currentPage={currentPage}
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
>
|
||||
<div className={styles.scrollHost} ref={contentScrollRef}>
|
||||
<Box as="section" className={styles.hero}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<div className={styles.heroInner}>
|
||||
<div className={styles.heroBreadcrumbs}>
|
||||
<Breadcrumbs>
|
||||
{breadcrumbs.map((crumb, index) => {
|
||||
const selected = index === breadcrumbs.length - 1;
|
||||
return (
|
||||
<Breadcrumbs.Item
|
||||
key={`${crumb.label}-${index}`}
|
||||
href={crumb.href ?? "#"}
|
||||
selected={selected}
|
||||
>
|
||||
{index === 0 ? (
|
||||
<ArrowLeftIcon
|
||||
size={16}
|
||||
className={styles.heroBreadcrumbBackIcon}
|
||||
/>
|
||||
) : null}
|
||||
{crumb.label}
|
||||
</Breadcrumbs.Item>
|
||||
);
|
||||
})}
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
<div className={styles.heroContent}>
|
||||
<Heading as="h1" size="4">
|
||||
{title}
|
||||
</Heading>
|
||||
{description ? (
|
||||
<Text
|
||||
as="p"
|
||||
size="200"
|
||||
variant="muted"
|
||||
className={clsx(
|
||||
styles.heroDescription,
|
||||
styles.heroDescriptionClamp,
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
{heroExtras}
|
||||
{install ? (
|
||||
<div className={styles.heroActions}>{install}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
<div
|
||||
className={styles.progressRider}
|
||||
data-burst={heroBurst ? "true" : undefined}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className={styles.progressDuck} />
|
||||
<span className={styles.confetti}>
|
||||
{Array.from({ length: 14 }).map((_, i) => (
|
||||
<i key={i} className={styles.confettiPiece} />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box as="section" className={styles.body}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<div className={styles.bodyInner}>
|
||||
<div className={styles.layout}>
|
||||
<article className={styles.contentCol}>{children}</article>
|
||||
|
||||
<aside className={styles.sidebarCol}>
|
||||
<div className={styles.sidebarSticky}>
|
||||
{toc.length > 0 ? (
|
||||
<div className={styles.sidebarSection}>
|
||||
<div className={styles.sidebarSummary}>
|
||||
<span className={styles.sidebarKicker}>
|
||||
In this article
|
||||
</span>
|
||||
</div>
|
||||
<nav className={styles.toc} aria-label="In this article">
|
||||
<ul className={styles.tocList}>
|
||||
{toc.map((section) => (
|
||||
<li key={section.id}>
|
||||
<a
|
||||
href={`#${section.id}`}
|
||||
className={clsx(
|
||||
styles.tocLink,
|
||||
activeSection === section.id &&
|
||||
styles.tocLinkActive,
|
||||
)}
|
||||
aria-current={
|
||||
activeSection === section.id
|
||||
? "true"
|
||||
: undefined
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
document
|
||||
.getElementById(section.id)
|
||||
?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
setActiveSection(section.id);
|
||||
}}
|
||||
>
|
||||
{section.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
) : null}
|
||||
{sidebar}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{previous || next ? (
|
||||
<aside className={styles.nextUp} aria-label="More like this">
|
||||
{previous ? (
|
||||
<div className={styles.nextUpMain}>
|
||||
<div className={styles.nextUpKickerRow}>
|
||||
<span className={styles.nextUpKicker}>Previous</span>
|
||||
</div>
|
||||
<a href={previous.href} className={styles.nextUpLink}>
|
||||
<ArrowLeftIcon
|
||||
size={16}
|
||||
className={styles.nextUpArrow}
|
||||
/>
|
||||
{previous.label}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{next ? (
|
||||
<div className={styles.nextUpMain}>
|
||||
<div className={styles.nextUpKickerRow}>
|
||||
<span className={styles.nextUpKicker}>Up next</span>
|
||||
</div>
|
||||
<a href={next.href} className={styles.nextUpLink}>
|
||||
{next.label}
|
||||
<ArrowRightIcon
|
||||
size={16}
|
||||
className={styles.nextUpArrow}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
</Section>
|
||||
</Box>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
styles.backToTop,
|
||||
showBackToTop && styles.backToTopVisible,
|
||||
)}
|
||||
onClick={scrollToTop}
|
||||
aria-label="Back to top"
|
||||
aria-hidden={!showBackToTop}
|
||||
tabIndex={showBackToTop ? 0 : -1}
|
||||
>
|
||||
<ArrowUpIcon size={24} />
|
||||
</button>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The prototype nested the scroll region directly inside `.page`; under
|
||||
* `PageShell` it sits one level deeper, inside the `<main>` landmark. Make that
|
||||
* landmark a transparent flex pass-through so the measured 100vh scroll region
|
||||
* still resolves, and mirror the hero-pin flag the scroll hook writes onto it
|
||||
* back up to `.page`, which is what the stylesheet keys off.
|
||||
*/
|
||||
function useScrollHostLayout(
|
||||
contentScrollRef: React.RefObject<HTMLDivElement | null>,
|
||||
) {
|
||||
React.useLayoutEffect(() => {
|
||||
const host = contentScrollRef.current;
|
||||
const landmark = host?.parentElement;
|
||||
if (!host || !landmark) return;
|
||||
|
||||
landmark.style.display = "flex";
|
||||
landmark.style.flexDirection = "column";
|
||||
landmark.style.flex = "1 1 auto";
|
||||
landmark.style.minHeight = "0";
|
||||
|
||||
const page = landmark.parentElement;
|
||||
if (!page) return;
|
||||
|
||||
// The scroll hooks write their measurements to the scroll host's parent.
|
||||
// Republish them on `.page`, which the stylesheet's selectors key off.
|
||||
const mirror = () => {
|
||||
const pin = landmark.dataset.heroPin;
|
||||
if (pin === undefined) delete page.dataset.heroPin;
|
||||
else page.dataset.heroPin = pin;
|
||||
for (const name of Array.from(landmark.style)) {
|
||||
if (name.startsWith("--dotnet-")) {
|
||||
page.style.setProperty(name, landmark.style.getPropertyValue(name));
|
||||
}
|
||||
}
|
||||
};
|
||||
mirror();
|
||||
const observer = new MutationObserver(mirror);
|
||||
observer.observe(landmark, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-hero-pin", "style"],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, [contentScrollRef]);
|
||||
}
|
||||
|
||||
/** Highlight the section the reader is currently in, below the pinned hero. */
|
||||
function useScrollSpy(
|
||||
contentScrollRef: React.RefObject<HTMLDivElement | null>,
|
||||
toc: DetailTocItem[],
|
||||
setActiveSection: React.Dispatch<React.SetStateAction<string>>,
|
||||
) {
|
||||
React.useEffect(() => {
|
||||
if (toc.length === 0) return;
|
||||
const scroller = contentScrollRef.current;
|
||||
if (!scroller) return;
|
||||
|
||||
let observer: IntersectionObserver | null = null;
|
||||
const build = () => {
|
||||
observer?.disconnect();
|
||||
const internal = window.matchMedia(TWO_COLUMN_QUERY).matches;
|
||||
const root = internal ? scroller : null;
|
||||
const viewport = internal ? scroller.clientHeight : window.innerHeight;
|
||||
const hero = scroller.querySelector<HTMLElement>(`.${styles.hero}`);
|
||||
const pinned = internal && hero ? hero.getBoundingClientRect().height : 0;
|
||||
const bottomMargin = Math.round(Math.max(0, viewport - pinned) * 0.65);
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
|
||||
if (visible[0]) setActiveSection(visible[0].target.id);
|
||||
},
|
||||
{
|
||||
root,
|
||||
rootMargin: `-${Math.round(pinned)}px 0px -${bottomMargin}px 0px`,
|
||||
threshold: 0,
|
||||
},
|
||||
);
|
||||
toc.forEach((section) => {
|
||||
const el = document.getElementById(section.id);
|
||||
if (el) observer?.observe(el);
|
||||
});
|
||||
};
|
||||
|
||||
build();
|
||||
window.addEventListener("resize", build);
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", build);
|
||||
};
|
||||
}, [contentScrollRef, toc, setActiveSection]);
|
||||
}
|
||||
|
||||
const slugify = (text: string) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.replace(/&[a-z]+;|&#\d+;/g, " ")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
/**
|
||||
* Derive the in-page table of contents from rendered markdown, stamping a
|
||||
* stable `id` onto every `<h2>` so the TOC links and scroll-spy have anchors.
|
||||
*
|
||||
* Pure and deterministic, so the ids match between the build-time render and
|
||||
* client hydration.
|
||||
*/
|
||||
export function buildDetailToc(markdownHtml: string): {
|
||||
html: string;
|
||||
toc: DetailTocItem[];
|
||||
} {
|
||||
if (!markdownHtml) return { html: markdownHtml, toc: [] };
|
||||
|
||||
const toc: DetailTocItem[] = [];
|
||||
const used = new Map<string, number>();
|
||||
|
||||
const html = markdownHtml.replace(
|
||||
/<h2([^>]*)>([\s\S]*?)<\/h2>/g,
|
||||
(match, attrs: string, inner: string) => {
|
||||
const label = inner
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.trim();
|
||||
if (!label) return match;
|
||||
|
||||
const existing = /\bid="([^"]+)"/.exec(attrs)?.[1];
|
||||
let id = existing ?? slugify(label);
|
||||
if (!id) return match;
|
||||
if (!existing) {
|
||||
const seen = used.get(id) ?? 0;
|
||||
used.set(id, seen + 1);
|
||||
if (seen > 0) id = `${id}-${seen}`;
|
||||
}
|
||||
|
||||
toc.push({ id, label });
|
||||
const nextAttrs = existing ? attrs : ` id="${id}"${attrs}`;
|
||||
return `<h2${nextAttrs}>${inner}</h2>`;
|
||||
},
|
||||
);
|
||||
|
||||
return { html, toc };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
type DuckIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export function DuckIcon({ size = 20 }: DuckIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={(size * 24) / 20}
|
||||
height={size}
|
||||
viewBox="0 0 24 20"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.9419 0.185999C14.0817 0.210106 14.3422 0.284161 14.4809 0.324989C15.4436 0.602816 16.3335 1.08858 17.0879 1.74796C18.0687 2.61398 18.8004 3.78037 19.0733 5.06617C19.1486 5.42059 19.1728 5.69573 19.1954 6.05493C19.9042 6.47282 21.2113 6.27391 21.9755 6.03128C22.1864 5.96426 22.3835 5.87623 22.609 5.82457C22.9887 5.73766 23.181 6.00716 23.0683 6.35954C22.8734 6.87106 22.4723 7.209 21.9887 7.44922C22.0113 7.61957 21.9944 7.75015 21.9255 7.90632C21.8264 8.1276 21.6661 8.31595 21.4636 8.44922C20.9188 8.80345 19.9075 8.88595 19.274 9.03595C19.0611 9.08642 18.8846 9.11739 18.6748 9.20055C18.6978 9.47621 18.8244 9.88883 18.8762 10.1806C19.0379 11.0898 19.1697 11.9834 19.1072 12.9082C18.9938 14.5853 18.2813 16.165 17.0022 17.2761C15.9222 18.2142 13.9922 19.1159 12.5665 19.2698C11.87 19.3451 10.4034 19.2249 9.68457 19.173C9.05239 19.1217 8.35927 19.0723 7.74129 18.9418C7.30365 18.8532 6.89972 18.6599 6.50045 18.479C4.66739 17.6484 3.37572 16.1673 2.36863 14.4611C2.08521 13.9811 1.77842 13.4407 1.45576 12.9745C1.30397 12.7523 1.14169 12.5375 0.969497 12.3307C0.72442 12.0346 0.393951 11.6642 0.218548 11.3288C0.062196 11.0344 -0.0130532 10.7038 0.000509405 10.3708C0.0178241 10.013 0.140805 9.70545 0.40611 9.45815C0.865218 9.03028 1.65451 9.10023 2.2494 9.13424C2.46611 8.81411 2.77456 8.62505 3.1679 8.62069C3.29024 8.61925 3.46079 8.64125 3.56847 8.59399C3.83534 8.47705 3.99394 8.29079 4.27756 8.18418C4.76965 7.99925 5.45031 8.23112 5.90525 8.44309C5.91815 8.08944 5.83512 7.70184 5.75448 7.35757C5.54612 6.46771 5.44183 5.76672 5.63291 4.851C5.95146 3.32439 6.75911 2.08198 8.07214 1.22922C9.88251 0.0534421 11.8538 -0.241944 13.9419 0.185999Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
LinkExternalIcon,
|
||||
MarkGithubIcon,
|
||||
PlusIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import React from "react";
|
||||
|
||||
import { Button, Token } from "@primer/react-brand";
|
||||
|
||||
import {
|
||||
DetailChassis,
|
||||
type DetailSibling,
|
||||
type DetailTocItem,
|
||||
} from "./DetailChassis";
|
||||
import { ResourceMeta } from "./ResourceMeta";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import styles from "./styles/dotnet-upgrade.module.css";
|
||||
// The framed media block and the install command bar are prototype treatments
|
||||
// that live in the Copilot app page's stylesheet; reused here verbatim.
|
||||
import appStyles from "./styles/github-copilot-app.module.css";
|
||||
|
||||
export type ExtensionDetailImage = { url: string; alt: string };
|
||||
|
||||
export type ExtensionDetailItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
version?: string | null;
|
||||
pluginName?: string | null;
|
||||
external?: boolean;
|
||||
externalSource?: string | null;
|
||||
keywords?: string[];
|
||||
author?: { name?: string; url?: string } | null;
|
||||
};
|
||||
|
||||
export type ExtensionDetailProps = {
|
||||
item: ExtensionDetailItem;
|
||||
/** Preview screenshots, largest/primary first. */
|
||||
images: ExtensionDetailImage[];
|
||||
/** Rendered, sanitized README with heading ids already stamped. */
|
||||
markdownHtml: string;
|
||||
toc: DetailTocItem[];
|
||||
/** `ghapp://` deep link — absent for external extensions. */
|
||||
installUrl?: string | null;
|
||||
/** Copilot CLI command — absent for external extensions. */
|
||||
installCommand?: string | null;
|
||||
/** Repository the extension is published from. */
|
||||
sourceUrl?: string | null;
|
||||
lastUpdated?: string | null;
|
||||
previous?: DetailSibling;
|
||||
next?: DetailSibling;
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
};
|
||||
|
||||
const PREVIEW_SECTION_ID = "preview";
|
||||
|
||||
/** Copyable `copilot plugin install …` line, matching the prototype install bar. */
|
||||
function InstallCommand({ command }: { command: string }) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const timer = React.useRef<number | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => () => window.clearTimeout(timer.current), []);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard?.writeText(command).catch(() => undefined);
|
||||
setCopied(true);
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={appStyles.installBar}>
|
||||
<code className={appStyles.installCommand}>{command}</code>
|
||||
<button
|
||||
type="button"
|
||||
className={appStyles.installCopy}
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? "Copied to clipboard" : "Copy install command"}
|
||||
>
|
||||
{copied ? <CheckIcon size={16} /> : <CopyIcon size={16} />}
|
||||
<span>{copied ? "Copied" : "Copy"}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail route for a canvas extension.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function ExtensionDetail({
|
||||
item,
|
||||
images,
|
||||
markdownHtml,
|
||||
toc,
|
||||
installUrl,
|
||||
installCommand,
|
||||
sourceUrl,
|
||||
lastUpdated,
|
||||
previous,
|
||||
next,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
}: ExtensionDetailProps) {
|
||||
const hasPreview = images.length > 0;
|
||||
|
||||
const install = (
|
||||
<>
|
||||
{installUrl ? (
|
||||
<Button
|
||||
as="a"
|
||||
href={installUrl}
|
||||
variant="primary"
|
||||
hasArrow={false}
|
||||
leadingVisual={PlusIcon}
|
||||
>
|
||||
Open in Copilot app
|
||||
</Button>
|
||||
) : null}
|
||||
{sourceUrl ? (
|
||||
<Button
|
||||
as="a"
|
||||
href={sourceUrl}
|
||||
variant={installUrl ? "secondary" : "primary"}
|
||||
hasArrow={false}
|
||||
leadingVisual={installUrl ? undefined : LinkExternalIcon}
|
||||
className={installUrl ? styles.iconButton : undefined}
|
||||
aria-label={
|
||||
installUrl ? `View ${item.name} on GitHub` : undefined
|
||||
}
|
||||
>
|
||||
{installUrl ? <MarkGithubIcon size={16} /> : "View source repository"}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const heroExtras =
|
||||
item.version || item.external ? (
|
||||
<div className={styles.metaValues}>
|
||||
{item.version ? <Token text={`v${item.version}`} /> : null}
|
||||
{item.external ? <Token text="Externally hosted" /> : null}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const fullToc = hasPreview
|
||||
? [{ id: PREVIEW_SECTION_ID, label: "Preview" }, ...toc]
|
||||
: toc;
|
||||
|
||||
return (
|
||||
<DetailChassis
|
||||
title={item.name}
|
||||
description={item.description}
|
||||
breadcrumbs={[
|
||||
{ label: "Extensions", href: pageHref("extensions") },
|
||||
{ label: item.name },
|
||||
]}
|
||||
install={install}
|
||||
heroExtras={heroExtras}
|
||||
toc={fullToc}
|
||||
sidebar={
|
||||
<ResourceMeta
|
||||
kicker="Extension details"
|
||||
groups={[
|
||||
{ label: "Keywords", items: item.keywords ?? [] },
|
||||
{
|
||||
label: "Source",
|
||||
items: item.externalSource ? [item.externalSource] : [],
|
||||
},
|
||||
]}
|
||||
author={item.author?.name ?? null}
|
||||
lastUpdated={lastUpdated}
|
||||
sourceUrl={sourceUrl ?? undefined}
|
||||
/>
|
||||
}
|
||||
previous={previous}
|
||||
next={next}
|
||||
currentPage="extensions"
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
>
|
||||
{hasPreview ? (
|
||||
<section id={PREVIEW_SECTION_ID} className={styles.articleSection}>
|
||||
<h2 className={styles.articleHeading}>Preview</h2>
|
||||
{images.map((image) => (
|
||||
<figure key={image.url} className={appStyles.videoFigure}>
|
||||
<img
|
||||
className={appStyles.gif}
|
||||
src={image.url}
|
||||
alt={image.alt}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
width={1280}
|
||||
height={720}
|
||||
/>
|
||||
</figure>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{installUrl || installCommand ? (
|
||||
<section className={styles.articleSection}>
|
||||
<h2 className={styles.articleHeading}>Install</h2>
|
||||
{installCommand ? <InstallCommand command={installCommand} /> : null}
|
||||
{installUrl ? (
|
||||
<p>
|
||||
Or{" "}
|
||||
<a href={installUrl}>open {item.name} directly in the Copilot app</a>
|
||||
.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
) : sourceUrl ? (
|
||||
<section className={styles.articleSection}>
|
||||
<h2 className={styles.articleHeading}>Install</h2>
|
||||
<p>
|
||||
{item.name} is hosted outside this repository. Follow the
|
||||
installation steps in its{" "}
|
||||
<a href={sourceUrl}>source repository</a>.
|
||||
</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{markdownHtml ? (
|
||||
<section className={styles.articleSection}>
|
||||
<div dangerouslySetInnerHTML={{ __html: markdownHtml }} />
|
||||
</section>
|
||||
) : null}
|
||||
</DetailChassis>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
CopyIcon,
|
||||
CheckIcon,
|
||||
MarkGithubIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { clsx } from "clsx";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CTABanner,
|
||||
Card,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
Grid,
|
||||
Heading,
|
||||
Pagination,
|
||||
Section,
|
||||
Stack,
|
||||
Text,
|
||||
useTheme,
|
||||
} from "@primer/react-brand";
|
||||
|
||||
import { ExtensionsIcon } from "./ExtensionsIcon";
|
||||
import { PageShell } from "./PageShell";
|
||||
import { TypingText } from "./TypingText";
|
||||
import {
|
||||
daysSince,
|
||||
toggleValue,
|
||||
updatedBuckets,
|
||||
updatedBucketOf,
|
||||
} from "./catalogFilters";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import styles from "./styles/extensions.module.css";
|
||||
|
||||
const CONTRIBUTE_URL =
|
||||
"https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md";
|
||||
|
||||
const REQUEST_URL = "https://github.com/github/awesome-copilot/issues/new";
|
||||
|
||||
/** An extension record as emitted into public/data/extensions.json. */
|
||||
export type ExtensionItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
lastUpdated: string;
|
||||
imageUrl?: string | null;
|
||||
installUrl?: string | null;
|
||||
installCommand?: string | null;
|
||||
sourceUrl?: string | null;
|
||||
external?: boolean;
|
||||
author?: { name?: string; url?: string } | null;
|
||||
keywords?: string[];
|
||||
};
|
||||
|
||||
/** Where the "view on GitHub" action points: an external extension carries its
|
||||
* own source repo, an in-repo one is covered by the install URL. */
|
||||
const extensionSourceUrl = (ext: ExtensionItem) =>
|
||||
ext.sourceUrl ?? ext.installUrl ?? undefined;
|
||||
|
||||
const uniqueSorted = (values: string[]) =>
|
||||
Array.from(new Set(values)).sort((a, b) => a.localeCompare(b));
|
||||
|
||||
type FilterGroupId = "author" | "updated" | "keyword";
|
||||
|
||||
const FILTER_COLLAPSE_LIMIT = 10;
|
||||
|
||||
type FilterState = Record<FilterGroupId, string[]>;
|
||||
|
||||
const emptyFilters: FilterState = { author: [], updated: [], keyword: [] };
|
||||
|
||||
const PAGE_SIZE = 6;
|
||||
|
||||
function CopyInstallButton({
|
||||
ext,
|
||||
command,
|
||||
onCopied,
|
||||
}: {
|
||||
ext: ExtensionItem;
|
||||
command: string;
|
||||
onCopied: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timer = React.useRef<number | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => () => window.clearTimeout(timer.current), []);
|
||||
|
||||
const onCopy = () => {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(command).catch(() => undefined);
|
||||
}
|
||||
setCopied(true);
|
||||
onCopied();
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
hasArrow={false}
|
||||
onClick={onCopy}
|
||||
aria-label={
|
||||
copied
|
||||
? `Copied install command for ${ext.name}`
|
||||
: `Copy install command for ${ext.name}`
|
||||
}
|
||||
className={styles.iconButton}
|
||||
>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The canvas extensions catalog, ported from the design prototype's
|
||||
* `extensions.tsx`. The prototype's hardcoded array is replaced by build-time
|
||||
* data; the layout, filters, and interactions are unchanged.
|
||||
*/
|
||||
export function ExtensionsCatalog({
|
||||
extensions,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
}: {
|
||||
extensions: ExtensionItem[];
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
}) {
|
||||
const { colorMode } = useTheme();
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimer = React.useRef<number | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => () => window.clearTimeout(copyTimer.current), []);
|
||||
|
||||
const showCopiedToast = React.useCallback(() => {
|
||||
setCopied(true);
|
||||
window.clearTimeout(copyTimer.current);
|
||||
copyTimer.current = window.setTimeout(() => setCopied(false), 2000);
|
||||
}, []);
|
||||
|
||||
const [filters, setFilters] = useState<FilterState>(emptyFilters);
|
||||
const [expandedGroups, setExpandedGroups] = useState<
|
||||
Record<FilterGroupId, boolean>
|
||||
>({ author: false, updated: false, keyword: false });
|
||||
|
||||
const toggleGroupExpanded = (groupId: FilterGroupId) => {
|
||||
setExpandedGroups((prev) => ({ ...prev, [groupId]: !prev[groupId] }));
|
||||
};
|
||||
|
||||
const filterGroups = useMemo(() => {
|
||||
const authorOptions = uniqueSorted(
|
||||
extensions
|
||||
.map((ext) => ext.author?.name)
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
);
|
||||
const keywordOptions = uniqueSorted(
|
||||
extensions.flatMap((ext) => ext.keywords ?? []),
|
||||
);
|
||||
const updatedOptions = updatedBuckets
|
||||
.map((bucket) => bucket.label)
|
||||
.filter((label) =>
|
||||
extensions.some(
|
||||
(ext) => updatedBucketOf(daysSince(ext.lastUpdated)) === label,
|
||||
),
|
||||
);
|
||||
return [
|
||||
{ id: "author" as const, label: "Author", options: authorOptions },
|
||||
{ id: "updated" as const, label: "Last updated", options: updatedOptions },
|
||||
{ id: "keyword" as const, label: "Keyword", options: keywordOptions },
|
||||
];
|
||||
}, [extensions]);
|
||||
|
||||
const sortedExtensions = useMemo(() => {
|
||||
const copy = extensions.filter((ext) => {
|
||||
const authorOk =
|
||||
filters.author.length === 0 ||
|
||||
(ext.author?.name !== undefined &&
|
||||
filters.author.includes(ext.author.name));
|
||||
const updatedOk =
|
||||
filters.updated.length === 0 ||
|
||||
filters.updated.includes(updatedBucketOf(daysSince(ext.lastUpdated)));
|
||||
const keywordOk =
|
||||
filters.keyword.length === 0 ||
|
||||
filters.keyword.every((keyword) =>
|
||||
(ext.keywords ?? []).includes(keyword),
|
||||
);
|
||||
return authorOk && updatedOk && keywordOk;
|
||||
});
|
||||
copy.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return copy;
|
||||
}, [extensions, filters]);
|
||||
|
||||
const toggleFilter = (groupId: FilterGroupId, option: string) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
[groupId]: toggleValue(prev[groupId], option),
|
||||
}));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters(emptyFilters);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const activeFilterCount =
|
||||
filters.author.length + filters.updated.length + filters.keyword.length;
|
||||
const hasActiveFilters = activeFilterCount > 0;
|
||||
const pageCount = Math.max(1, Math.ceil(sortedExtensions.length / PAGE_SIZE));
|
||||
const page = Math.min(currentPage, pageCount);
|
||||
const visibleExtensions = sortedExtensions.slice(
|
||||
(page - 1) * PAGE_SIZE,
|
||||
page * PAGE_SIZE,
|
||||
);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
styles={styles}
|
||||
currentPage="extensions"
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
searchAriaLabel="Search extensions"
|
||||
>
|
||||
<Box className={styles.hero}>
|
||||
<div className={styles.heroInner}>
|
||||
<span className={styles.heroIcon} aria-hidden="true">
|
||||
<ExtensionsIcon size={36} />
|
||||
</span>
|
||||
<Heading as="h1" size="3" className={styles.heroHeading}>
|
||||
Canvas Extensions
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted" className={styles.heroText}>
|
||||
Canvas extensions that bring interactive GitHub Copilot app
|
||||
experiences to life — contributed and curated by the community.
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Section id="catalog" paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box className={styles.catalog}>
|
||||
<aside className={styles.filterNav} aria-label="Filter extensions">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.filterToggle}
|
||||
aria-expanded={mobileFiltersOpen}
|
||||
onClick={() => setMobileFiltersOpen((open) => !open)}
|
||||
>
|
||||
<span>
|
||||
Filters{hasActiveFilters ? ` (${activeFilterCount})` : ""}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
size={16}
|
||||
className={clsx(
|
||||
styles.filterToggleChevron,
|
||||
mobileFiltersOpen && styles.filterToggleChevronOpen,
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.filterBody,
|
||||
!mobileFiltersOpen && styles.filterBodyCollapsed,
|
||||
)}
|
||||
>
|
||||
{filterGroups.map((group) => {
|
||||
const isExpandable =
|
||||
group.options.length > FILTER_COLLAPSE_LIMIT;
|
||||
const expanded = expandedGroups[group.id];
|
||||
const visibleOptions =
|
||||
isExpandable && !expanded
|
||||
? group.options.slice(0, FILTER_COLLAPSE_LIMIT)
|
||||
: group.options;
|
||||
return (
|
||||
<div className={styles.filterGroup} key={group.id}>
|
||||
<Text as="h2" size="100" className={styles.filterHeading}>
|
||||
{group.label}
|
||||
</Text>
|
||||
<div className={styles.filterOptions}>
|
||||
{visibleOptions.map((option) => (
|
||||
<div className={styles.filterOption} key={option}>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={filters[group.id].includes(option)}
|
||||
onChange={() => toggleFilter(group.id, option)}
|
||||
/>
|
||||
<FormControl.Label>{option}</FormControl.Label>
|
||||
</FormControl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{isExpandable ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.showMoreButton}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => toggleGroupExpanded(group.id)}
|
||||
>
|
||||
{expanded
|
||||
? "Show less"
|
||||
: `Show ${group.options.length - FILTER_COLLAPSE_LIMIT} more`}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hasActiveFilters ? (
|
||||
<div className={styles.filterActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
hasArrow={false}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear all ({activeFilterCount})
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Box className={styles.catalogMain}>
|
||||
<Box className={styles.gridFrame} data-mode={colorMode}>
|
||||
<Box className={styles.gridContent}>
|
||||
<Grid
|
||||
className={styles.threeUp}
|
||||
columnGap="none"
|
||||
rowGap="none"
|
||||
enableGutters={false}
|
||||
>
|
||||
{visibleExtensions.map((ext) => {
|
||||
const detailHref = pageHref(`extension/${ext.id}`);
|
||||
const sourceUrl = extensionSourceUrl(ext);
|
||||
return (
|
||||
<Grid.Column
|
||||
key={ext.id}
|
||||
span={{ xsmall: 12, medium: 6, large: 6 }}
|
||||
className={styles.col}
|
||||
>
|
||||
<Box className={clsx(styles.item, styles.itemHover)}>
|
||||
{ext.imageUrl ? (
|
||||
<a
|
||||
href={detailHref}
|
||||
className={styles.cardMedia}
|
||||
aria-label={`View ${ext.name}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<img
|
||||
className={styles.cardImage}
|
||||
src={ext.imageUrl}
|
||||
alt={`${ext.name} preview`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width={1280}
|
||||
height={720}
|
||||
/>
|
||||
</a>
|
||||
) : null}
|
||||
<Card
|
||||
href={detailHref}
|
||||
fullWidth
|
||||
ctaVariant="none"
|
||||
backgroundColor="none"
|
||||
className={styles.card}
|
||||
>
|
||||
<Card.Heading as="h2">{ext.name}</Card.Heading>
|
||||
<Card.Description>
|
||||
<span className={styles.cardDescText}>
|
||||
{ext.description}
|
||||
</span>
|
||||
</Card.Description>
|
||||
</Card>
|
||||
<div className={styles.cardActions}>
|
||||
{ext.installUrl ? (
|
||||
<Button
|
||||
as="a"
|
||||
href={ext.installUrl}
|
||||
variant="primary"
|
||||
hasArrow={false}
|
||||
>
|
||||
Open in Copilot app
|
||||
</Button>
|
||||
) : null}
|
||||
{ext.installCommand ? (
|
||||
<CopyInstallButton
|
||||
ext={ext}
|
||||
command={ext.installCommand}
|
||||
onCopied={showCopiedToast}
|
||||
/>
|
||||
) : null}
|
||||
{sourceUrl ? (
|
||||
<Button
|
||||
as="a"
|
||||
href={sourceUrl}
|
||||
variant="secondary"
|
||||
aria-label={`View ${ext.name} on GitHub`}
|
||||
className={styles.iconButton}
|
||||
>
|
||||
<MarkGithubIcon />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{ext.author?.name ? (
|
||||
<Text as="p" size="100" className={styles.cardBadge}>
|
||||
<TypingText
|
||||
text={ext.author.name}
|
||||
className={styles.cardBadgeName}
|
||||
/>
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
</Grid.Column>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{sortedExtensions.length === 0 ? (
|
||||
<Box className={styles.emptyState}>
|
||||
<Text as="p" variant="muted">
|
||||
No extensions match the selected filters.
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
<div className={styles.rule} aria-hidden="true" />
|
||||
|
||||
<Box className={styles.paginationRow}>
|
||||
<Stack direction="horizontal" justifyContent="center" padding="none">
|
||||
<Pagination
|
||||
className={styles.pagination}
|
||||
pageCount={pageCount}
|
||||
currentPage={page}
|
||||
onPageChange={(e, n) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(n);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<div className={styles.rule} aria-hidden="true" />
|
||||
|
||||
<Box className={styles.ctaFrame}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box className={styles.ctaFrameInner}>
|
||||
<CTABanner align="center" backgroundColor="subtle">
|
||||
<CTABanner.Heading>
|
||||
Don’t see what you’re looking for?
|
||||
</CTABanner.Heading>
|
||||
<CTABanner.Description>
|
||||
This collection is community-built. Share your own canvas
|
||||
extension
|
||||
<br />
|
||||
—or request one—to help others get more out of the
|
||||
GitHub Copilot app.
|
||||
</CTABanner.Description>
|
||||
<CTABanner.ButtonGroup>
|
||||
<Button as="a" href={CONTRIBUTE_URL}>
|
||||
Submit an extension
|
||||
</Button>
|
||||
<Button as="a" href={REQUEST_URL}>
|
||||
Request an extension
|
||||
</Button>
|
||||
</CTABanner.ButtonGroup>
|
||||
</CTABanner>
|
||||
</Box>
|
||||
</Section>
|
||||
</Box>
|
||||
|
||||
<div
|
||||
className={styles.toast}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-visible={copied ? "true" : undefined}
|
||||
>
|
||||
Install command copied!
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
type ExtensionsIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export function ExtensionsIcon({ size = 36 }: ExtensionsIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 96 96"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Canvas Extensions"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M36 56H18"
|
||||
stroke="var(--brand-color-text-default)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M90 40L6 40"
|
||||
stroke="var(--brand-color-text-muted)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M90 73L6 73"
|
||||
stroke="var(--brand-color-text-muted)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 23L48 90"
|
||||
stroke="var(--brand-color-text-muted)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M90 23L6 23"
|
||||
stroke="var(--brand-color-text-muted)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M6 82V14C6 9.58172 9.58172 6 14 6H82C86.4183 6 90 9.58172 90 14V82C90 86.4183 86.4183 90 82 90H14C9.58172 90 6 86.4183 6 82Z"
|
||||
stroke="var(--brand-color-text-default)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 71.5V41.5C48 40.6716 48.6716 40 49.5 40H88.5C89.3284 40 90 40.6716 90 41.5V71.5C90 72.3284 89.3284 73 88.5 73H49.5C48.6716 73 48 72.3284 48 71.5Z"
|
||||
stroke="var(--brand-color-text-default)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M69 65C73.9706 65 78 60.9706 78 56C78 51.0294 73.9706 47 69 47C64.0294 47 60 51.0294 60 56C60 60.9706 64.0294 65 69 65Z"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import {
|
||||
BookIcon,
|
||||
BrowserIcon,
|
||||
ChecklistIcon,
|
||||
CopilotIcon,
|
||||
PlugIcon,
|
||||
ZapIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { clsx } from "clsx";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CTABanner,
|
||||
Grid,
|
||||
Hero,
|
||||
Image,
|
||||
Section,
|
||||
Stack,
|
||||
Token,
|
||||
} from "@primer/react-brand";
|
||||
|
||||
import styles from "./styles/styles.module.css";
|
||||
import { ContributorsHoverCard } from "./ContributorsHoverCard";
|
||||
import { LearningIcon } from "./LearningIcon";
|
||||
import { PageShell } from "./PageShell";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
|
||||
const REPO_URL = "https://github.com/github/awesome-copilot";
|
||||
const CONTRIBUTING_URL =
|
||||
"https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md";
|
||||
|
||||
type ResourceIcon = React.ComponentType<{
|
||||
size?: number | "small" | "medium" | "large";
|
||||
}>;
|
||||
|
||||
type Resource = {
|
||||
icon: ResourceIcon;
|
||||
name: string;
|
||||
count?: string;
|
||||
description: string;
|
||||
page?: string;
|
||||
anchor?: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
/** Live counts injected at build time, replacing the prototype's hardcoded figures. */
|
||||
export type HomePageCounts = {
|
||||
agents: number;
|
||||
instructions: number;
|
||||
skills: number;
|
||||
plugins: number;
|
||||
extensions: number;
|
||||
playbook: number;
|
||||
};
|
||||
|
||||
export type HomePageProps = {
|
||||
counts: HomePageCounts;
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
};
|
||||
|
||||
const buildResources = (counts: HomePageCounts): Resource[] => [
|
||||
{
|
||||
icon: CopilotIcon,
|
||||
name: "Agents",
|
||||
count: String(counts.agents),
|
||||
description:
|
||||
"Custom agents that give Copilot specialized skills for your projects.",
|
||||
page: "agents",
|
||||
id: "agents",
|
||||
},
|
||||
{
|
||||
icon: ChecklistIcon,
|
||||
name: "Instructions",
|
||||
count: String(counts.instructions),
|
||||
description:
|
||||
"Coding standards and best practices that steer Copilot's output.",
|
||||
page: "instructions",
|
||||
id: "instructions",
|
||||
},
|
||||
{
|
||||
icon: ZapIcon,
|
||||
name: "Skills",
|
||||
count: String(counts.skills),
|
||||
description:
|
||||
"Self-contained folders that bundle instructions and resources together.",
|
||||
page: "skills",
|
||||
id: "skills",
|
||||
},
|
||||
{
|
||||
icon: PlugIcon,
|
||||
name: "Plugins",
|
||||
count: String(counts.plugins),
|
||||
description:
|
||||
"Curated plugins, grouped by theme, that extend what Copilot can do.",
|
||||
page: "plugins",
|
||||
id: "plugins",
|
||||
},
|
||||
{
|
||||
icon: BrowserIcon,
|
||||
name: "Extensions",
|
||||
count: String(counts.extensions),
|
||||
description:
|
||||
"Interactive canvas extensions that enrich the Copilot app experience.",
|
||||
page: "extensions",
|
||||
id: "extensions",
|
||||
},
|
||||
{
|
||||
icon: BookIcon,
|
||||
name: "Playbook",
|
||||
count: String(counts.playbook),
|
||||
description:
|
||||
"Articles and guides for getting the most from every agent and skill.",
|
||||
page: "learning-hub-copilot-app",
|
||||
id: "learning-hub-copilot-app",
|
||||
},
|
||||
];
|
||||
|
||||
export function HomePage({
|
||||
counts,
|
||||
searchIndex = [],
|
||||
contributorsTotal = 0,
|
||||
}: HomePageProps) {
|
||||
const resources = buildResources(counts);
|
||||
const internalHref = ({ page, anchor }: { page?: string; anchor?: string }) =>
|
||||
page ? pageHref(page) : `#${anchor}`;
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
styles={styles}
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
searchAriaLabel="Search the library"
|
||||
>
|
||||
<Box className={styles.heroFrame}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box className={styles.heroFrameInner}>
|
||||
<Stack
|
||||
direction="vertical"
|
||||
alignItems="center"
|
||||
gap="normal"
|
||||
padding="none"
|
||||
>
|
||||
<div className={styles.heroRiseIcon}>
|
||||
<ContributorsHoverCard
|
||||
size={56}
|
||||
href={pageHref("contributors")}
|
||||
/>
|
||||
</div>
|
||||
<Hero align="center">
|
||||
<Hero.Heading>
|
||||
The community library for GitHub Copilot
|
||||
</Hero.Heading>
|
||||
<Hero.Description>
|
||||
Discover reusable agents, skills, instructions, hooks, and
|
||||
tools built by developers to help you ship faster with
|
||||
Copilot.
|
||||
</Hero.Description>
|
||||
<Hero.PrimaryAction href={REPO_URL}>
|
||||
Explore repository
|
||||
</Hero.PrimaryAction>
|
||||
<Hero.SecondaryAction href={CONTRIBUTING_URL}>
|
||||
Become a contributor
|
||||
</Hero.SecondaryAction>
|
||||
</Hero>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Section>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
id="resources"
|
||||
className={styles.cardGridFrame}
|
||||
marginBlockEnd={{ narrow: 24, wide: 80 }}
|
||||
>
|
||||
<Box className={styles.cardGridContent}>
|
||||
<Grid columnGap="none" rowGap="none" enableGutters={false}>
|
||||
{resources.map((item) => (
|
||||
<Grid.Column
|
||||
key={item.name}
|
||||
span={{ xsmall: 12, small: 6, xlarge: 4 }}
|
||||
className={clsx(
|
||||
styles.cardGridColumn,
|
||||
styles.cardGridColumnArrowHover,
|
||||
)}
|
||||
>
|
||||
<Box className={styles.cardGridItem} id={item.id}>
|
||||
<Card
|
||||
href={internalHref(item)}
|
||||
fullWidth
|
||||
ctaVariant="arrow"
|
||||
ctaText={`Explore ${item.name}`}
|
||||
backgroundColor="none"
|
||||
className={styles.resourceCard}
|
||||
>
|
||||
<Card.Heading as="h2" size="5">
|
||||
<span className={styles.cardHeadingRow}>
|
||||
<span>{item.name}</span>
|
||||
{item.count ? (
|
||||
<Token variant="default">{item.count}</Token>
|
||||
) : null}
|
||||
</span>
|
||||
</Card.Heading>
|
||||
<Card.Description>{item.description}</Card.Description>
|
||||
</Card>
|
||||
</Box>
|
||||
</Grid.Column>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box className={styles.ctaFrame}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box id="learning-hub" className={styles.ctaFrameInner}>
|
||||
<CTABanner align="center" hasGridLines>
|
||||
<CTABanner.Logo>
|
||||
<LearningIcon size={64} />
|
||||
</CTABanner.Logo>
|
||||
<CTABanner.Heading as="h2" size="3">
|
||||
Master GitHub Copilot
|
||||
</CTABanner.Heading>
|
||||
<CTABanner.Description>
|
||||
Read the documentation to learn every feature and workflow, then
|
||||
head to GitHub’s YouTube channel for videos, demos, and
|
||||
talks.
|
||||
</CTABanner.Description>
|
||||
<CTABanner.ButtonGroup>
|
||||
<Button
|
||||
as="a"
|
||||
href="https://docs.github.com/en/copilot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read the docs
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href="https://www.youtube.com/@GitHub/featured"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Watch on YouTube
|
||||
</Button>
|
||||
</CTABanner.ButtonGroup>
|
||||
</CTABanner>
|
||||
</Box>
|
||||
</Section>
|
||||
</Box>
|
||||
|
||||
<Box className={styles.dividerFrame}>
|
||||
<Section paddingBlockStart="normal" paddingBlockEnd="none">
|
||||
<Stack justifyContent="center" padding="none">
|
||||
<Image
|
||||
src="/media/brand-divider-copilot-sitting.webp"
|
||||
alt=""
|
||||
width={1230}
|
||||
height={157}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ display: "block", width: "100%", height: "auto" }}
|
||||
/>
|
||||
</Stack>
|
||||
</Section>
|
||||
</Box>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { CheckIcon, CopyIcon } from "@primer/octicons-react";
|
||||
import React from "react";
|
||||
|
||||
import { ActionMenu, Button } from "@primer/react-brand";
|
||||
|
||||
import styles from "./styles/github-copilot-app.module.css";
|
||||
|
||||
type InstallOption = { id: string; label: string; command: string };
|
||||
|
||||
const INSTALL_OPTIONS: InstallOption[] = [
|
||||
{
|
||||
id: "script",
|
||||
label: "Install with script",
|
||||
command: "curl -fsSL https://gh.io/copilot-install | bash",
|
||||
},
|
||||
{
|
||||
id: "winget",
|
||||
label: "Install with WinGet",
|
||||
command: "winget install GitHub.Copilot",
|
||||
},
|
||||
{
|
||||
id: "homebrew",
|
||||
label: "Install with Homebrew",
|
||||
command: "brew install copilot-cli",
|
||||
},
|
||||
{
|
||||
id: "npm",
|
||||
label: "Install with npm",
|
||||
command: "npm install -g @github/copilot",
|
||||
},
|
||||
];
|
||||
|
||||
/** Package-manager selector + copyable install command, matching the install
|
||||
* bar on github.com/features/copilot/cli. */
|
||||
export function InstallCommandBar() {
|
||||
const [selectedId, setSelectedId] = React.useState("homebrew");
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const selected =
|
||||
INSTALL_OPTIONS.find((option) => option.id === selectedId) ??
|
||||
INSTALL_OPTIONS[0];
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(selected.command);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.installBar}>
|
||||
<ActionMenu
|
||||
selectionVariant="single"
|
||||
onSelect={(id) => setSelectedId(id)}
|
||||
>
|
||||
<ActionMenu.Button>{selected.label}</ActionMenu.Button>
|
||||
<ActionMenu.Overlay aria-label="Choose an install method">
|
||||
{INSTALL_OPTIONS.map((option) => (
|
||||
<ActionMenu.Item
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
selected={option.id === selectedId}
|
||||
>
|
||||
{option.label}
|
||||
</ActionMenu.Item>
|
||||
))}
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
<code className={styles.installCommand}>{selected.command}</code>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.installCopy}
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? "Copied to clipboard" : "Copy install command"}
|
||||
>
|
||||
{copied ? <CheckIcon size={16} /> : <CopyIcon size={16} />}
|
||||
<span>{copied ? "Copied" : "Copy"}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.installMobileCta}>
|
||||
<Button
|
||||
as="a"
|
||||
variant="primary"
|
||||
size="medium"
|
||||
href="https://github.com/github/copilot-cli"
|
||||
>
|
||||
Get Copilot CLI
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { DownloadIcon, MarkGithubIcon, PlusIcon } from "@primer/octicons-react";
|
||||
import React from "react";
|
||||
|
||||
import { ActionMenu, Button, Token } from "@primer/react-brand";
|
||||
|
||||
import {
|
||||
DetailChassis,
|
||||
type DetailSibling,
|
||||
type DetailTocItem,
|
||||
} from "./DetailChassis";
|
||||
import { ResourceMeta } from "./ResourceMeta";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import styles from "./styles/dotnet-upgrade.module.css";
|
||||
|
||||
export type InstructionDetailItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
applyTo?: string | string[] | null;
|
||||
applyToPatterns?: string[];
|
||||
extensions?: string[];
|
||||
path: string;
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
export type InstructionDetailProps = {
|
||||
item: InstructionDetailItem;
|
||||
/** Rendered, sanitized markdown body with heading ids already stamped. */
|
||||
markdownHtml: string;
|
||||
toc: DetailTocItem[];
|
||||
/** `chat-instructions` VS Code install URL (see src/lib/detail-page.ts). */
|
||||
vscodeUrl: string;
|
||||
insidersUrl: string;
|
||||
githubUrl: string;
|
||||
downloadUrl: string;
|
||||
rawMarkdown: string;
|
||||
lastUpdated?: string | null;
|
||||
previous?: DetailSibling;
|
||||
next?: DetailSibling;
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
};
|
||||
|
||||
export function InstructionDetail({
|
||||
item,
|
||||
markdownHtml,
|
||||
toc,
|
||||
vscodeUrl,
|
||||
insidersUrl,
|
||||
githubUrl,
|
||||
downloadUrl,
|
||||
rawMarkdown,
|
||||
lastUpdated,
|
||||
previous,
|
||||
next,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
}: InstructionDetailProps) {
|
||||
const applyTo = Array.isArray(item.applyTo)
|
||||
? item.applyTo
|
||||
: item.applyTo
|
||||
? [item.applyTo]
|
||||
: (item.applyToPatterns ?? []);
|
||||
|
||||
const handleCopyMarkdown = React.useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(rawMarkdown);
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
}, [rawMarkdown]);
|
||||
|
||||
const install = (
|
||||
<>
|
||||
<ActionMenu mode="split-button" menuAlignment="start">
|
||||
<ActionMenu.Button
|
||||
as="a"
|
||||
href={vscodeUrl}
|
||||
variant="primary"
|
||||
leadingVisual={PlusIcon}
|
||||
>
|
||||
Install
|
||||
</ActionMenu.Button>
|
||||
<ActionMenu.Overlay
|
||||
aria-label={`Install the ${item.title} instructions`}
|
||||
>
|
||||
<ActionMenu.Item as="a" href={vscodeUrl}>
|
||||
Install in VS Code
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item as="a" href={insidersUrl}>
|
||||
Install in VS Code Insiders
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item as="a" href={downloadUrl} download>
|
||||
Download file
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item onClick={handleCopyMarkdown}>
|
||||
Copy markdown
|
||||
</ActionMenu.Item>
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
<Button
|
||||
as="a"
|
||||
href={githubUrl}
|
||||
variant="secondary"
|
||||
className={styles.iconButton}
|
||||
aria-label={`View the ${item.title} instructions on GitHub`}
|
||||
>
|
||||
<MarkGithubIcon size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href={downloadUrl}
|
||||
variant="secondary"
|
||||
download
|
||||
className={styles.iconButton}
|
||||
aria-label={`Download the ${item.title} instructions file`}
|
||||
>
|
||||
<DownloadIcon size={16} />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
// The glob patterns decide when Copilot applies these instructions, so they
|
||||
// are promoted into the hero rather than left in the sidebar.
|
||||
const heroExtras =
|
||||
applyTo.length > 0 ? (
|
||||
<div className={styles.metaValues} aria-label="Applies to">
|
||||
{applyTo.map((pattern) => (
|
||||
<Token key={pattern} text={pattern} />
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<DetailChassis
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
breadcrumbs={[
|
||||
{ label: "Instructions", href: pageHref("instructions") },
|
||||
{ label: item.title },
|
||||
]}
|
||||
install={install}
|
||||
heroExtras={heroExtras}
|
||||
toc={toc}
|
||||
sidebar={
|
||||
<ResourceMeta
|
||||
kicker="Instruction details"
|
||||
groups={[
|
||||
{ label: "Applies to", items: applyTo },
|
||||
{ label: "Extensions", items: item.extensions ?? [] },
|
||||
]}
|
||||
lastUpdated={lastUpdated}
|
||||
sourceUrl={githubUrl}
|
||||
/>
|
||||
}
|
||||
previous={previous}
|
||||
next={next}
|
||||
currentPage="instructions"
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
>
|
||||
<section className={styles.articleSection}>
|
||||
<div dangerouslySetInnerHTML={{ __html: markdownHtml }} />
|
||||
</section>
|
||||
</DetailChassis>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
import { ChevronDownIcon, DownloadIcon, MarkGithubIcon } from "@primer/octicons-react";
|
||||
import {
|
||||
ActionMenu,
|
||||
Box,
|
||||
Button,
|
||||
CTABanner,
|
||||
Card,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
Grid,
|
||||
Heading,
|
||||
Pagination,
|
||||
Section,
|
||||
Stack,
|
||||
Text,
|
||||
useTheme,
|
||||
} from "@primer/react-brand";
|
||||
import { clsx } from "clsx";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { PageShell } from "./PageShell";
|
||||
import {
|
||||
daysSince,
|
||||
toggleValue,
|
||||
updatedBuckets,
|
||||
updatedBucketOf,
|
||||
} from "./catalogFilters";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import styles from "./styles/instructions.module.css";
|
||||
|
||||
const CONTRIBUTE_URL =
|
||||
"https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md";
|
||||
|
||||
const REQUEST_URL = "https://github.com/github/awesome-copilot/issues/new";
|
||||
|
||||
const RAW_BASE = "https://raw.githubusercontent.com/github/awesome-copilot/main";
|
||||
const GITHUB_BASE = "https://github.com/github/awesome-copilot/blob/main";
|
||||
|
||||
/** Sentinel emitted by the data generator for items with no file extensions. */
|
||||
const NO_EXTENSION = "(none)";
|
||||
|
||||
/** An instruction record as emitted into public/data/instructions.json. */
|
||||
export type InstructionItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
applyTo: string | null;
|
||||
applyToPatterns: string[];
|
||||
extensions: string[];
|
||||
path: string;
|
||||
filename: string;
|
||||
lastUpdated: string;
|
||||
};
|
||||
|
||||
export type InstructionFilters = {
|
||||
patterns: string[];
|
||||
extensions: string[];
|
||||
};
|
||||
|
||||
const instructionSourceUrl = (instruction: InstructionItem) =>
|
||||
`${GITHUB_BASE}/${instruction.path}`;
|
||||
|
||||
/**
|
||||
* VS Code deep link for instructions, matching the scheme used by the detail
|
||||
* pages (`src/lib/detail-page.ts`): the `chat-instructions` command wrapped in
|
||||
* the aka.ms redirector.
|
||||
*/
|
||||
const installUrl = (instruction: InstructionItem, insiders = false) => {
|
||||
const rawUrl = `${RAW_BASE}/${instruction.path}`;
|
||||
const editor = insiders ? "vscode-insiders" : "vscode";
|
||||
const innerUrl = `${editor}:chat-instructions/install?url=${encodeURIComponent(rawUrl)}`;
|
||||
return `https://aka.ms/awesome-copilot/install/instructions?url=${encodeURIComponent(innerUrl)}`;
|
||||
};
|
||||
|
||||
const downloadUrl = (instruction: InstructionItem) =>
|
||||
`${RAW_BASE}/${instruction.path}`;
|
||||
|
||||
type SortMode = "az" | "newest";
|
||||
|
||||
type FilterGroupId = "pattern" | "extension" | "updated";
|
||||
|
||||
type FilterState = Record<FilterGroupId, string[]>;
|
||||
|
||||
const emptyFilters: FilterState = { pattern: [], extension: [], updated: [] };
|
||||
|
||||
const PAGE_SIZE = 6;
|
||||
|
||||
/**
|
||||
* The Instructions catalog, ported from the design prototype's
|
||||
* `instructions.tsx`. The prototype's hardcoded array and filter options are
|
||||
* replaced by build-time data; the layout, filters, and interactions are
|
||||
* unchanged.
|
||||
*/
|
||||
export function InstructionsCatalog({
|
||||
instructions,
|
||||
filters: filterOptions,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
}: {
|
||||
instructions: InstructionItem[];
|
||||
filters?: InstructionFilters;
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
}) {
|
||||
const { colorMode } = useTheme();
|
||||
const [sortMode] = useState<SortMode>("az");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<FilterState>(emptyFilters);
|
||||
|
||||
const filterGroups = useMemo<
|
||||
{ id: FilterGroupId; label: string; options: string[] }[]
|
||||
>(
|
||||
() => [
|
||||
{
|
||||
id: "pattern",
|
||||
label: "Applies to",
|
||||
options: filterOptions?.patterns ?? [],
|
||||
},
|
||||
{
|
||||
id: "extension",
|
||||
label: "File extension",
|
||||
options: filterOptions?.extensions ?? [],
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
label: "Last updated",
|
||||
options: updatedBuckets.map((bucket) => bucket.label),
|
||||
},
|
||||
],
|
||||
[filterOptions],
|
||||
);
|
||||
|
||||
const sortedInstructions = useMemo(() => {
|
||||
const copy = instructions.filter((item) => {
|
||||
const patternOk =
|
||||
filters.pattern.length === 0 ||
|
||||
filters.pattern.some((pattern) => item.applyToPatterns.includes(pattern));
|
||||
const extensionOk =
|
||||
filters.extension.length === 0 ||
|
||||
filters.extension.some((ext) =>
|
||||
ext === NO_EXTENSION
|
||||
? item.extensions.length === 0
|
||||
: item.extensions.includes(ext),
|
||||
);
|
||||
const updatedOk =
|
||||
filters.updated.length === 0 ||
|
||||
filters.updated.includes(updatedBucketOf(daysSince(item.lastUpdated)));
|
||||
return patternOk && extensionOk && updatedOk;
|
||||
});
|
||||
if (sortMode === "newest") {
|
||||
copy.sort((a, b) => daysSince(a.lastUpdated) - daysSince(b.lastUpdated));
|
||||
} else {
|
||||
copy.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
return copy;
|
||||
}, [instructions, sortMode, filters]);
|
||||
|
||||
const toggleFilter = (groupId: FilterGroupId, option: string) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
[groupId]: toggleValue(prev[groupId], option),
|
||||
}));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters(emptyFilters);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const activeFilterCount =
|
||||
filters.pattern.length + filters.extension.length + filters.updated.length;
|
||||
const hasActiveFilters = activeFilterCount > 0;
|
||||
const pageCount = Math.max(
|
||||
1,
|
||||
Math.ceil(sortedInstructions.length / PAGE_SIZE),
|
||||
);
|
||||
const page = Math.min(currentPage, pageCount);
|
||||
const visibleInstructions = sortedInstructions.slice(
|
||||
(page - 1) * PAGE_SIZE,
|
||||
page * PAGE_SIZE,
|
||||
);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
styles={styles}
|
||||
currentPage="instructions"
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
searchAriaLabel="Search instructions"
|
||||
>
|
||||
<Box className={styles.hero}>
|
||||
<div className={styles.heroInner}>
|
||||
<svg
|
||||
className={styles.heroIcon}
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 96 96"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M26 18C26 21.3137 28.6863 24 32 24C35.3137 24 38 21.3137 38 18C38 14.6863 35.3137 12 32 12C28.6863 12 26 14.6863 26 18Z"
|
||||
fill="#96A199"
|
||||
/>
|
||||
<path
|
||||
d="M26 61C26 64.3137 28.6863 67 32 67C35.3137 67 38 64.3137 38 61C38 57.6863 35.3137 55 32 55C28.6863 55 26 57.6863 26 61Z"
|
||||
fill="#96A199"
|
||||
/>
|
||||
<path
|
||||
d="M2 51.75C2 48.0221 5.134 45 8.99999 45C12.866 45 16 48.0221 16 51.75"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M16 52C16 54 14.5 56 12.2991 57.5L2 64H17"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2 5L8.96802 2.48376C9.61985 2.24838 10.3077 2.7313 10.3077 3.42432V21"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M17 21H2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 2H55"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M65 2H83"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 14H60"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M60 14H72"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M72 14H94"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 26H76"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M76 26H94"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 44H55"
|
||||
stroke="#96A199"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M65 44H83"
|
||||
stroke="#96A199"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 56H60"
|
||||
stroke="#96A199"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M60 56H72"
|
||||
stroke="#96A199"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M72 56H94"
|
||||
stroke="#96A199"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 68H76"
|
||||
stroke="#96A199"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M76 68H94"
|
||||
stroke="#96A199"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 93H88"
|
||||
stroke="#96A199"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 81H94"
|
||||
stroke="#96A199"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<Heading as="h1" size="3" className={styles.heroHeading}>
|
||||
Instructions
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted" className={styles.heroText}>
|
||||
Community-curated standards and best practices that teach Copilot
|
||||
your team’s conventions.
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Section id="catalog" paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box className={styles.catalog}>
|
||||
<aside className={styles.filterNav} aria-label="Filter instructions">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.filterToggle}
|
||||
aria-expanded={mobileFiltersOpen}
|
||||
onClick={() => setMobileFiltersOpen((open) => !open)}
|
||||
>
|
||||
<span>
|
||||
Filters{hasActiveFilters ? ` (${activeFilterCount})` : ""}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
size={16}
|
||||
className={clsx(
|
||||
styles.filterToggleChevron,
|
||||
mobileFiltersOpen && styles.filterToggleChevronOpen,
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.filterBody,
|
||||
!mobileFiltersOpen && styles.filterBodyCollapsed,
|
||||
)}
|
||||
>
|
||||
{filterGroups.map((group) => (
|
||||
<div className={styles.filterGroup} key={group.id}>
|
||||
<Text as="h2" size="100" className={styles.filterHeading}>
|
||||
{group.label}
|
||||
</Text>
|
||||
<div className={styles.filterOptions}>
|
||||
{group.options.map((option) => (
|
||||
<div className={styles.filterOption} key={option}>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={filters[group.id].includes(option)}
|
||||
onChange={() => toggleFilter(group.id, option)}
|
||||
/>
|
||||
<FormControl.Label>{option}</FormControl.Label>
|
||||
</FormControl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{hasActiveFilters ? (
|
||||
<div className={styles.filterActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
hasArrow={false}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear all ({activeFilterCount})
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Box className={styles.catalogMain}>
|
||||
<Box className={styles.gridFrame} data-mode={colorMode}>
|
||||
<Box className={styles.gridContent}>
|
||||
<Grid
|
||||
className={styles.threeUp}
|
||||
columnGap="none"
|
||||
rowGap="none"
|
||||
enableGutters={false}
|
||||
>
|
||||
{visibleInstructions.map((item) => (
|
||||
<Grid.Column
|
||||
key={item.id}
|
||||
span={{ xsmall: 12, medium: 6, large: 6 }}
|
||||
className={styles.col}
|
||||
>
|
||||
<Box className={clsx(styles.item, styles.itemHover)}>
|
||||
<Card
|
||||
href={pageHref(`instruction/${item.id}`)}
|
||||
fullWidth
|
||||
ctaVariant="none"
|
||||
backgroundColor="none"
|
||||
className={styles.card}
|
||||
>
|
||||
<Card.Heading as="h2">{item.title}</Card.Heading>
|
||||
<Card.Description>
|
||||
<span className={styles.cardDescText}>
|
||||
{item.description}
|
||||
</span>
|
||||
</Card.Description>
|
||||
</Card>
|
||||
<div className={styles.cardActions}>
|
||||
<ActionMenu mode="split-button" menuAlignment="start">
|
||||
<ActionMenu.Button
|
||||
as="a"
|
||||
href={installUrl(item)}
|
||||
variant="primary"
|
||||
>
|
||||
Install
|
||||
</ActionMenu.Button>
|
||||
<ActionMenu.Overlay
|
||||
aria-label={`Install ${item.title} to your editor`}
|
||||
>
|
||||
<ActionMenu.Item
|
||||
as="a"
|
||||
href={installUrl(item, false)}
|
||||
>
|
||||
VS Code
|
||||
</ActionMenu.Item>
|
||||
<ActionMenu.Item
|
||||
as="a"
|
||||
href={installUrl(item, true)}
|
||||
>
|
||||
VS Code Insiders
|
||||
</ActionMenu.Item>
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
<Button
|
||||
as="a"
|
||||
href={downloadUrl(item)}
|
||||
variant="secondary"
|
||||
download
|
||||
aria-label={`Download ${item.title} instruction file`}
|
||||
className={styles.iconButton}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href={instructionSourceUrl(item)}
|
||||
variant="secondary"
|
||||
aria-label={`View ${item.title} instruction on GitHub`}
|
||||
className={styles.iconButton}
|
||||
>
|
||||
<MarkGithubIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</Grid.Column>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{sortedInstructions.length === 0 ? (
|
||||
<Box className={styles.emptyState}>
|
||||
<Text as="p" variant="muted">
|
||||
No instructions match the selected filters.
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
<div className={styles.rule} aria-hidden="true" />
|
||||
|
||||
<Box className={styles.paginationRow}>
|
||||
<Stack direction="horizontal" justifyContent="center" padding="none">
|
||||
<Pagination
|
||||
className={styles.pagination}
|
||||
pageCount={pageCount}
|
||||
currentPage={page}
|
||||
onPageChange={(e, n) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(n);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<div className={styles.rule} aria-hidden="true" />
|
||||
|
||||
<Box className={styles.ctaFrame}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box className={styles.ctaFrameInner}>
|
||||
<CTABanner align="center" backgroundColor="subtle">
|
||||
<CTABanner.Heading>
|
||||
Can’t find the instructions you need?
|
||||
</CTABanner.Heading>
|
||||
<CTABanner.Description>
|
||||
This library is community-built. Share an instruction file you
|
||||
use—or request one—
|
||||
<br />
|
||||
to help developers ship faster with GitHub Copilot.
|
||||
</CTABanner.Description>
|
||||
<CTABanner.ButtonGroup>
|
||||
<Button as="a" href={CONTRIBUTE_URL}>
|
||||
Submit an instruction
|
||||
</Button>
|
||||
<Button as="a" href={REQUEST_URL}>
|
||||
Request an instruction
|
||||
</Button>
|
||||
</CTABanner.ButtonGroup>
|
||||
</CTABanner>
|
||||
</Box>
|
||||
</Section>
|
||||
</Box>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { GlobeIcon } from "@primer/octicons-react";
|
||||
import { ActionMenu } from "@primer/react-brand";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
SITE_LOCALES,
|
||||
splitLocale,
|
||||
type SiteLocale,
|
||||
} from "./locales";
|
||||
import { pageHref } from "./pageHref";
|
||||
|
||||
const BASE = import.meta.env.BASE_URL ?? "/";
|
||||
|
||||
/** Strip the configured base path from a browser pathname. */
|
||||
function stripBase(pathname: string): string {
|
||||
const base = BASE.endsWith("/") ? BASE : `${BASE}/`;
|
||||
return pathname.startsWith(base)
|
||||
? pathname.slice(base.length)
|
||||
: pathname.replace(/^\//, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the equivalent URL for `locale`, keeping the rest of the current path,
|
||||
* the query string and the hash. Astro serves the default locale unprefixed,
|
||||
* so switching to it drops the prefix entirely.
|
||||
*/
|
||||
function localeHref(locale: string, pathname: string, suffix: string): string {
|
||||
const { rest } = splitLocale(stripBase(pathname));
|
||||
const target = locale === DEFAULT_LOCALE ? rest : `${locale}/${rest}`;
|
||||
const normalized = target.replace(/^\/+/, "").replace(/\/+$/, "");
|
||||
return `${normalized ? pageHref(normalized) : pageHref()}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Language picker for the top navigation.
|
||||
*
|
||||
* Replaces the Starlight `LanguageSelect.astro` override removed during the
|
||||
* migration. Locale switching is a pure path rewrite performed in the browser,
|
||||
* so a single statically rendered menu works on every page.
|
||||
*/
|
||||
export function LanguageSelect({
|
||||
locales = SITE_LOCALES,
|
||||
label = "Language",
|
||||
}: {
|
||||
locales?: SiteLocale[];
|
||||
label?: string;
|
||||
}) {
|
||||
const [current, setCurrent] = useState(DEFAULT_LOCALE);
|
||||
const [hrefs, setHrefs] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(
|
||||
locales.map((locale) => [
|
||||
locale.code,
|
||||
locale.code === DEFAULT_LOCALE ? pageHref() : pageHref(locale.code),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const { pathname, search, hash } = window.location;
|
||||
const suffix = `${search}${hash}`;
|
||||
setCurrent(splitLocale(stripBase(pathname)).locale);
|
||||
setHrefs(
|
||||
Object.fromEntries(
|
||||
locales.map((locale) => [
|
||||
locale.code,
|
||||
localeHref(locale.code, pathname, suffix),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}, [locales]);
|
||||
|
||||
const active =
|
||||
locales.find((locale) => locale.code === current) ?? locales[0];
|
||||
|
||||
return (
|
||||
<ActionMenu size="small" menuAlignment="end" selectionVariant="none">
|
||||
<ActionMenu.Button
|
||||
variant="subtle"
|
||||
size="small"
|
||||
leadingVisual={<GlobeIcon />}
|
||||
aria-label={`${label}: ${active.label}`}
|
||||
>
|
||||
{active.label}
|
||||
</ActionMenu.Button>
|
||||
<ActionMenu.Overlay aria-label={label}>
|
||||
{locales.map((locale) => (
|
||||
<ActionMenu.Item
|
||||
as="a"
|
||||
key={locale.code}
|
||||
href={hrefs[locale.code]}
|
||||
hrefLang={locale.code}
|
||||
lang={locale.code}
|
||||
aria-current={locale.code === current ? "true" : undefined}
|
||||
>
|
||||
{locale.label}
|
||||
</ActionMenu.Item>
|
||||
))}
|
||||
</ActionMenu.Overlay>
|
||||
</ActionMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MinimalFooter } from "@primer/react-brand";
|
||||
|
||||
export function LargeFooter() {
|
||||
return (
|
||||
<MinimalFooter logoHref="https://github.com" socialLinks={false}>
|
||||
<MinimalFooter.Link href="https://docs.github.com/site-policy/github-terms/github-terms-of-service">
|
||||
Terms
|
||||
</MinimalFooter.Link>
|
||||
<MinimalFooter.Link href="https://docs.github.com/site-policy/privacy-policies/github-general-privacy-statement">
|
||||
Privacy
|
||||
</MinimalFooter.Link>
|
||||
<MinimalFooter.Link href="https://docs.github.com">
|
||||
GitHub Docs
|
||||
</MinimalFooter.Link>
|
||||
<MinimalFooter.Link href="https://github.community">
|
||||
Community
|
||||
</MinimalFooter.Link>
|
||||
<MinimalFooter.Link href="https://support.github.com">
|
||||
Support
|
||||
</MinimalFooter.Link>
|
||||
</MinimalFooter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
ArrowUpIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
MarkGithubIcon,
|
||||
XIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { clsx } from "clsx";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Heading,
|
||||
Section,
|
||||
Text,
|
||||
useTheme,
|
||||
} from "@primer/react-brand";
|
||||
|
||||
import styles from "./styles/github-copilot-app.module.css";
|
||||
import { LargeFooter } from "./LargeFooter";
|
||||
import { TypingText } from "./TypingText";
|
||||
import type { PrototypePageProps } from "./pageHref";
|
||||
import { getAwesomeCopilotNavLinks } from "./navigation";
|
||||
import { TopNav } from "./TopNav";
|
||||
import { SkipLink } from "./SkipLink";
|
||||
import {
|
||||
type CodeLanguage,
|
||||
SyntaxHighlightedCode,
|
||||
} from "./SyntaxHighlightedCode";
|
||||
import { TopNavSearch } from "./TopNavSearch";
|
||||
import { ContributorsNavButton } from "./ContributorsNavButton";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
|
||||
const CONTRIBUTING_URL =
|
||||
"https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md";
|
||||
|
||||
// Minimum article reading strip (px) that must remain below a pinned hero. When
|
||||
// pinning the hero would leave less than this — because the viewport is short or
|
||||
// the hero is tall (e.g. the CLI page's install bar) — the hero is released to
|
||||
// scroll away instead of trapping the article in a narrow band.
|
||||
const MIN_HERO_READING = 464;
|
||||
|
||||
/** Copyable, syntax-highlighted code block for learning articles. */
|
||||
export function CopyBlock({
|
||||
code,
|
||||
label,
|
||||
language,
|
||||
}: {
|
||||
code: string;
|
||||
label?: string;
|
||||
language?: CodeLanguage;
|
||||
}) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.codeBlockWrap}>
|
||||
{label ? <span className={styles.codeLabel}>{label}</span> : null}
|
||||
<div className={styles.codeSurface}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.copyButton}
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? "Copied to clipboard" : "Copy code"}
|
||||
>
|
||||
{copied ? <CheckIcon size={16} /> : <CopyIcon size={16} />}
|
||||
</button>
|
||||
<SyntaxHighlightedCode
|
||||
className={styles.codeBlock}
|
||||
code={code}
|
||||
language={language}
|
||||
lineClassName={styles.codeLine}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Dismissible "Pro tip" callout in the article flow. */
|
||||
export function ProTip({ children }: { children: React.ReactNode }) {
|
||||
const [open, setOpen] = React.useState(true);
|
||||
if (!open) return null;
|
||||
return (
|
||||
<aside className={styles.proTip}>
|
||||
<div className={styles.proTipHeader}>
|
||||
<span className={styles.proTipMark}>
|
||||
<MarkGithubIcon size={20} />
|
||||
</span>
|
||||
<span className={styles.proTipTitle}>Pro tip</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.proTipClose}
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Dismiss pro tip"
|
||||
>
|
||||
<XIcon size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.proTipBody}>{children}</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export type TocSection = { id: string; label: string };
|
||||
|
||||
export type LearningArticleLayoutProps = {
|
||||
pageHref: PrototypePageProps["pageHref"];
|
||||
/** Page slug of this article, used for the current breadcrumb link. */
|
||||
currentPage: string;
|
||||
/** Short label for the current (selected) breadcrumb. */
|
||||
breadcrumbLabel: string;
|
||||
heroTitle: string;
|
||||
heroSubtitle: string;
|
||||
/** Type the hero title out one character at a time on load, with a blinking
|
||||
* terminal caret. Opt-in per page. */
|
||||
animateHeroTitle?: boolean;
|
||||
/** Optional primary CTA button in the hero. */
|
||||
heroCta?: { label: string; href: string };
|
||||
/** Optional custom content rendered in the hero below the description. */
|
||||
heroExtra?: React.ReactNode;
|
||||
/** Sections shown in the sticky "In this article" list; ids must match the
|
||||
* `id` on each `<section>` rendered in `children`. */
|
||||
tocSections: TocSection[];
|
||||
/** Site-wide search index, injected from build-time data. */
|
||||
searchIndex?: SearchItem[];
|
||||
/** Live contributor count for the nav button. */
|
||||
contributorsTotal?: number;
|
||||
/** Optional "Up next" band pinned to the footer's green line. */
|
||||
upNext?: { label: string; href: string };
|
||||
/** Article body — a sequence of `<section id=...>` blocks (and any ProTip). */
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function LearningArticleLayout({
|
||||
pageHref,
|
||||
currentPage,
|
||||
breadcrumbLabel,
|
||||
heroTitle,
|
||||
heroSubtitle,
|
||||
animateHeroTitle = false,
|
||||
heroCta,
|
||||
heroExtra,
|
||||
tocSections,
|
||||
searchIndex = [],
|
||||
contributorsTotal = 0,
|
||||
upNext,
|
||||
children,
|
||||
}: LearningArticleLayoutProps) {
|
||||
const { colorMode } = useTheme();
|
||||
const subNavLinks = getAwesomeCopilotNavLinks(pageHref);
|
||||
const contentScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const [showBackToTop, setShowBackToTop] = React.useState(false);
|
||||
const [heroBurst, setHeroBurst] = React.useState(false);
|
||||
const heroBurstRef = React.useRef(false);
|
||||
const [activeSection, setActiveSection] = React.useState(
|
||||
tocSections[0]?.id ?? "",
|
||||
);
|
||||
// Visible height of the hero once it pins to the top of the scroll region
|
||||
// (measured below). The scroll-spy detection zone is offset by this so a
|
||||
// section only becomes "active" once it clears the fixed hero.
|
||||
const [pinnedHeight, setPinnedHeight] = React.useState(0);
|
||||
|
||||
const scrollToTop = () => {
|
||||
contentScrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
// Scroll-spy: highlight the current section in the "In this article" list. The
|
||||
// pinned hero covers the top of the scroll region, so the detection zone is
|
||||
// pushed below it (top margin = hero height) and its lower bound tracks the
|
||||
// remaining visible area — reproducing "top third of what the reader can see".
|
||||
React.useEffect(() => {
|
||||
const scroller = contentScrollRef.current;
|
||||
if (!scroller) return;
|
||||
let observer: IntersectionObserver | null = null;
|
||||
const build = () => {
|
||||
observer?.disconnect();
|
||||
const viewport = scroller.clientHeight;
|
||||
const below = Math.max(0, viewport - pinnedHeight);
|
||||
const bottomMargin = Math.round(below * 0.65);
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort(
|
||||
(a, b) => a.boundingClientRect.top - b.boundingClientRect.top,
|
||||
);
|
||||
if (visible[0]) {
|
||||
setActiveSection(visible[0].target.id);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scroller,
|
||||
rootMargin: `-${Math.round(pinnedHeight)}px 0px -${bottomMargin}px 0px`,
|
||||
threshold: 0,
|
||||
},
|
||||
);
|
||||
tocSections.forEach((section) => {
|
||||
const el = document.getElementById(section.id);
|
||||
if (el) observer?.observe(el);
|
||||
});
|
||||
};
|
||||
build();
|
||||
window.addEventListener("resize", build);
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", build);
|
||||
};
|
||||
}, [tocSections, pinnedHeight]);
|
||||
|
||||
// Expose the scroll region's scrollbar width so the fixed top bar and hero can
|
||||
// inset by the same amount and keep every vertical gridline aligned.
|
||||
React.useEffect(() => {
|
||||
const el = contentScrollRef.current;
|
||||
if (!el) return;
|
||||
const page = el.parentElement;
|
||||
const setRegion = () => {
|
||||
const scrollbar = el.offsetWidth - el.clientWidth;
|
||||
page?.style.setProperty("--app-scrollbar", `${scrollbar}px`);
|
||||
// Align the progress line + duck to the framed content (between the outer
|
||||
// vertical gridlines) rather than the viewport edge: expose the frame's
|
||||
// left inset and width so the line and the duck start at the first
|
||||
// vertical line on load and end at the last one.
|
||||
const heroEl = page?.getElementsByClassName(styles.hero)[0];
|
||||
const frameEl = page?.getElementsByClassName(styles.heroInner)[0];
|
||||
const contentEl = page?.getElementsByClassName(styles.heroContent)[0];
|
||||
if (heroEl && frameEl) {
|
||||
const heroRect = heroEl.getBoundingClientRect();
|
||||
const frameRect = frameEl.getBoundingClientRect();
|
||||
const start = Math.round((frameRect.left - heroRect.left) * 100) / 100;
|
||||
const width = Math.round(frameRect.width * 100) / 100;
|
||||
page?.style.setProperty("--app-frame-start", `${start}px`);
|
||||
page?.style.setProperty("--app-frame-width", `${width}px`);
|
||||
// Split the hero into the breadcrumb band that scrolls off the top and
|
||||
// the body that pins below it. --app-hero-crumb is the negative sticky
|
||||
// offset (so the crumbs scroll out of view) and --app-hero-pinned is the
|
||||
// hero's remaining visible height (so the sticky TOC and in-page anchor
|
||||
// jumps clear the pinned hero). Both are parent-minus-child offsets, so
|
||||
// they stay correct whether the hero is in flow or stuck.
|
||||
if (contentEl) {
|
||||
const contentRect = contentEl.getBoundingClientRect();
|
||||
// Geometry of the hero split, independent of whether it is currently
|
||||
// pinned (both are parent-minus-child offsets): the breadcrumb band
|
||||
// that scrolls off the top, and the body that would pin below it.
|
||||
const crumbBand = Math.round((contentRect.top - heroRect.top) * 100) / 100;
|
||||
const contentBand = Math.round((heroRect.bottom - contentRect.top) * 100) / 100;
|
||||
// Only pin the hero in the two-column layout AND when doing so still
|
||||
// leaves a usable reading strip below it. On short viewports — or when
|
||||
// the hero is tall (e.g. the CLI install bar) — release it so the
|
||||
// article isn't squeezed into a narrow horizontal strip. Driven here
|
||||
// rather than by a fixed max-height media query so the threshold
|
||||
// adapts to each page's actual hero height.
|
||||
const twoColumn = window.matchMedia("(min-width: 75rem)").matches;
|
||||
const roomBelow = el.clientHeight - contentBand;
|
||||
const shouldPin = twoColumn && roomBelow >= MIN_HERO_READING;
|
||||
const crumb = shouldPin ? crumbBand : 0;
|
||||
const pinned = shouldPin ? contentBand : 0;
|
||||
if (page) page.dataset.heroPin = shouldPin ? "true" : "false";
|
||||
page?.style.setProperty("--app-hero-crumb", `${crumb}px`);
|
||||
page?.style.setProperty("--app-hero-pinned", `${pinned}px`);
|
||||
setPinnedHeight((prev) =>
|
||||
Math.abs(prev - pinned) > 0.5 ? pinned : prev,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
setRegion();
|
||||
window.addEventListener("resize", setRegion);
|
||||
// The hero's height settles after fonts load and the install bar lays out,
|
||||
// so re-measure whenever it resizes to keep --app-hero-crumb / -pinned exact
|
||||
// (the offsets are scroll-invariant, so observing while pinned is safe).
|
||||
const heroEl = page?.getElementsByClassName(styles.hero)[0];
|
||||
let observer: ResizeObserver | undefined;
|
||||
if (heroEl && typeof ResizeObserver !== "undefined") {
|
||||
observer = new ResizeObserver(() => setRegion());
|
||||
observer.observe(heroEl);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("resize", setRegion);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Drive the hero's green bottom line as a reading-progress indicator. The
|
||||
// "finish line" is the scroll position where the last article title reaches
|
||||
// the reading zone, so the line fills to the very last vertical gridline
|
||||
// exactly when the reader arrives at that title, and the duck bursts into
|
||||
// confetti at the end of the line. An ease-out mapping keeps the line visibly
|
||||
// moving from the very first scroll.
|
||||
React.useEffect(() => {
|
||||
const el = contentScrollRef.current;
|
||||
if (!el) return;
|
||||
const page = el.parentElement;
|
||||
const lastId = tocSections[tocSections.length - 1]?.id;
|
||||
let lastHeadingEl: HTMLElement | null = null;
|
||||
const setProgress = () => {
|
||||
if (!lastHeadingEl && lastId) {
|
||||
const sectionEl = document.getElementById(lastId);
|
||||
lastHeadingEl =
|
||||
(sectionEl?.querySelector("h2") as HTMLElement | null) ?? sectionEl;
|
||||
}
|
||||
const rootRect = el.getBoundingClientRect();
|
||||
let finish = el.scrollHeight - el.clientHeight;
|
||||
if (lastHeadingEl) {
|
||||
const headingRect = lastHeadingEl.getBoundingClientRect();
|
||||
const headingOffset = headingRect.top - rootRect.top + el.scrollTop;
|
||||
finish = Math.max(1, headingOffset - rootRect.height * 0.6);
|
||||
}
|
||||
const progress = finish > 0 ? Math.min(1, el.scrollTop / finish) : 0;
|
||||
const eased = Math.max(progress > 0 ? Math.pow(progress, 0.5) : 0, 0.055);
|
||||
page?.style.setProperty("--app-progress", String(eased));
|
||||
setShowBackToTop(el.scrollTop > 200);
|
||||
const reached = progress >= 1;
|
||||
if (reached !== heroBurstRef.current) {
|
||||
heroBurstRef.current = reached;
|
||||
setHeroBurst(reached);
|
||||
}
|
||||
};
|
||||
setProgress();
|
||||
el.addEventListener("scroll", setProgress, { passive: true });
|
||||
window.addEventListener("resize", setProgress);
|
||||
return () => {
|
||||
el.removeEventListener("scroll", setProgress);
|
||||
window.removeEventListener("resize", setProgress);
|
||||
};
|
||||
}, [tocSections]);
|
||||
|
||||
return (
|
||||
<Box className={styles.page} backgroundColor="default" data-mode={colorMode}>
|
||||
<SkipLink />
|
||||
<header className={styles.topBar}>
|
||||
<nav className={styles.topBarInner} aria-label="Primary">
|
||||
<a href={pageHref()} className={styles.subNavTitle}>
|
||||
<MarkGithubIcon size={20} />
|
||||
Awesome GitHub Copilot
|
||||
</a>
|
||||
<TopNav
|
||||
styles={styles}
|
||||
links={subNavLinks}
|
||||
contributorsHref={pageHref("contributors")}
|
||||
contributorsTotal={contributorsTotal}
|
||||
searchIndex={searchIndex}
|
||||
searchAriaLabel="Search the library"
|
||||
/>
|
||||
<div className={styles.topBarActions}>
|
||||
<TopNavSearch
|
||||
index={searchIndex}
|
||||
styles={styles}
|
||||
inputAriaLabel="Search the library"
|
||||
/>
|
||||
<ContributorsNavButton
|
||||
href={pageHref("contributors")}
|
||||
total={contributorsTotal}
|
||||
/>
|
||||
<Button as="a" href={CONTRIBUTING_URL} variant="subtle" size="small">
|
||||
Contribute
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div className={styles.scrollHost} ref={contentScrollRef}>
|
||||
<main id="main-content" tabIndex={-1}>
|
||||
<Box as="section" className={styles.hero}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<div className={styles.heroInner}>
|
||||
<div className={styles.heroBreadcrumbs}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item href={pageHref("learning-hub-copilot-app")}>
|
||||
<ArrowLeftIcon
|
||||
size={16}
|
||||
className={styles.heroBreadcrumbBackIcon}
|
||||
/>
|
||||
Copilot Playbook
|
||||
</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Item href={pageHref(currentPage)} selected>
|
||||
{breadcrumbLabel}
|
||||
</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
<div className={styles.heroContent}>
|
||||
<Heading as="h1" size="4">
|
||||
{animateHeroTitle ? (
|
||||
<TypingText
|
||||
text={heroTitle}
|
||||
speedMs={60}
|
||||
caret
|
||||
caretClassName={styles.heroTitleCaret}
|
||||
/>
|
||||
) : (
|
||||
heroTitle
|
||||
)}
|
||||
</Heading>
|
||||
<Text
|
||||
as="p"
|
||||
size="200"
|
||||
variant="muted"
|
||||
className={clsx(
|
||||
styles.heroDescription,
|
||||
animateHeroTitle && styles.heroReveal,
|
||||
animateHeroTitle && styles.heroRevealDescription,
|
||||
)}
|
||||
>
|
||||
{heroSubtitle}
|
||||
</Text>
|
||||
{heroExtra ? (
|
||||
<div
|
||||
className={clsx(
|
||||
styles.heroExtra,
|
||||
animateHeroTitle && styles.heroReveal,
|
||||
animateHeroTitle && styles.heroRevealExtra,
|
||||
)}
|
||||
>
|
||||
{heroExtra}
|
||||
</div>
|
||||
) : null}
|
||||
{heroCta ? (
|
||||
<div
|
||||
className={clsx(
|
||||
styles.heroActions,
|
||||
animateHeroTitle && styles.heroReveal,
|
||||
animateHeroTitle && styles.heroRevealActions,
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
as="a"
|
||||
href={heroCta.href}
|
||||
variant="primary"
|
||||
size="medium"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{heroCta.label}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
<div
|
||||
className={styles.progressRider}
|
||||
data-burst={heroBurst ? "true" : undefined}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className={styles.progressDuck} />
|
||||
<span className={styles.confetti}>
|
||||
{Array.from({ length: 14 }).map((_, i) => (
|
||||
<i key={i} className={styles.confettiPiece} />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box as="section" className={styles.body}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<div className={styles.bodyInner}>
|
||||
<div className={styles.layout}>
|
||||
<article className={styles.contentCol}>{children}</article>
|
||||
|
||||
<aside className={styles.sidebarCol}>
|
||||
<div className={styles.sidebarSticky}>
|
||||
<div className={styles.sidebarSection}>
|
||||
<div className={styles.sidebarSummary}>
|
||||
<span className={styles.sidebarKicker}>
|
||||
In this article
|
||||
</span>
|
||||
</div>
|
||||
<nav className={styles.toc} aria-label="In this article">
|
||||
<ul className={styles.tocList}>
|
||||
{tocSections.map((section) => (
|
||||
<li key={section.id}>
|
||||
<a
|
||||
href={`#${section.id}`}
|
||||
className={clsx(
|
||||
styles.tocLink,
|
||||
activeSection === section.id &&
|
||||
styles.tocLinkActive,
|
||||
)}
|
||||
aria-current={
|
||||
activeSection === section.id
|
||||
? "true"
|
||||
: undefined
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
document
|
||||
.getElementById(section.id)
|
||||
?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
setActiveSection(section.id);
|
||||
}}
|
||||
>
|
||||
{section.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{upNext ? (
|
||||
<aside className={styles.nextUp} aria-label="Up next">
|
||||
<div className={styles.nextUpMain}>
|
||||
<div className={styles.nextUpKickerRow}>
|
||||
<span className={styles.nextUpKicker}>Up next</span>
|
||||
</div>
|
||||
<a href={upNext.href} className={styles.nextUpLink}>
|
||||
{upNext.label}
|
||||
<ArrowRightIcon size={16} className={styles.nextUpArrow} />
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
</Section>
|
||||
</Box>
|
||||
</main>
|
||||
<LargeFooter />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
styles.backToTop,
|
||||
showBackToTop && styles.backToTopVisible,
|
||||
)}
|
||||
onClick={scrollToTop}
|
||||
aria-label="Back to top"
|
||||
aria-hidden={!showBackToTop}
|
||||
tabIndex={showBackToTop ? 0 : -1}
|
||||
>
|
||||
<ArrowUpIcon size={24} />
|
||||
</button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
type LearningIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export function LearningIcon({ size = 64 }: LearningIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={(size * 64) / 69}
|
||||
height={size}
|
||||
viewBox="0 0 64 69"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Learning resources"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12 1C5.64625 1 0.5 6.14625 0.5 12.5C0.5 17.5887 3.79187 21.8869 8.36312 23.4106C8.93812 23.5112 9.15375 23.1662 9.15375 22.8644C9.15375 22.5912 9.13938 21.6856 9.13938 20.7225C6.25 21.2544 5.5025 20.0181 5.2725 19.3712C5.14313 19.0406 4.5825 18.02 4.09375 17.7469C3.69125 17.5312 3.11625 16.9994 4.07938 16.985C4.985 16.9706 5.63188 17.8187 5.8475 18.1637C6.8825 19.9031 8.53563 19.4144 9.19688 19.1125C9.2975 18.365 9.59938 17.8619 9.93 17.5744C7.37125 17.2869 4.6975 16.295 4.6975 11.8962C4.6975 10.6456 5.14312 9.61062 5.87625 8.80562C5.76125 8.51812 5.35875 7.33937 5.99125 5.75812C5.99125 5.75812 6.95438 5.45625 9.15375 6.93687C10.0738 6.67812 11.0513 6.54875 12.0288 6.54875C13.0063 6.54875 13.9838 6.67812 14.9038 6.93687C17.1031 5.44187 18.0662 5.75812 18.0662 5.75812C18.6987 7.33937 18.2962 8.51812 18.1812 8.80562C18.9144 9.61062 19.36 10.6312 19.36 11.8962C19.36 16.3094 16.6719 17.2869 14.1131 17.5744C14.53 17.9337 14.8894 18.6237 14.8894 19.7019C14.8894 21.24 14.875 22.4762 14.875 22.8644C14.875 23.1662 15.0906 23.5256 15.6656 23.4106C20.2081 21.8869 23.5 17.5744 23.5 12.5C23.5 6.14625 18.3538 1 12 1Z"
|
||||
fill="var(--brand-color-text-default)"
|
||||
/>
|
||||
<path
|
||||
d="M32 23C32.7363 23 33.3328 23.5968 33.333 24.333V51.4863L33.4385 51.3994C36.0742 49.2031 39.3972 48 42.8281 48H54.667C55.4031 48.0002 55.9998 48.5969 56 49.333C56 50.0693 55.4032 50.6658 54.667 50.666H42.8281C40.0211 50.666 37.302 51.6504 35.1455 53.4473L32.8535 55.3574C32.8218 55.3839 32.7885 55.4076 32.7549 55.4307C32.75 55.434 32.7451 55.4372 32.7402 55.4404C32.7208 55.4534 32.7006 55.4647 32.6807 55.4766C32.6697 55.4831 32.6595 55.4909 32.6484 55.4971C32.6428 55.5002 32.6375 55.5038 32.6318 55.5068C32.628 55.5089 32.624 55.5107 32.6201 55.5127C32.6003 55.5231 32.5798 55.5317 32.5596 55.541C32.5471 55.5468 32.535 55.5532 32.5225 55.5586C32.5207 55.5593 32.5193 55.5608 32.5176 55.5615C32.5124 55.5637 32.5071 55.5653 32.502 55.5674C32.4885 55.5728 32.4746 55.577 32.4609 55.582C32.4414 55.5893 32.4222 55.5972 32.4023 55.6035C32.3997 55.6044 32.3972 55.6056 32.3945 55.6064C32.3877 55.6086 32.3808 55.6103 32.374 55.6123C32.3702 55.6134 32.3662 55.6141 32.3623 55.6152C32.3403 55.6214 32.3181 55.6268 32.2959 55.6318C32.2851 55.6343 32.2745 55.6375 32.2637 55.6396C32.2598 55.6404 32.2558 55.6409 32.252 55.6416C32.2131 55.6491 32.1741 55.6562 32.1348 55.6602C32.1309 55.6605 32.1269 55.6598 32.123 55.6602C32.1117 55.6612 32.1003 55.6614 32.0889 55.6621C32.0592 55.6641 32.0297 55.666 32 55.666C31.9703 55.666 31.9408 55.6641 31.9111 55.6621C31.8994 55.6613 31.8877 55.6612 31.876 55.6602C31.8524 55.658 31.8291 55.6538 31.8057 55.6504C31.7845 55.6473 31.7632 55.6448 31.7422 55.6406C31.7409 55.6404 31.7395 55.6409 31.7383 55.6406C31.7171 55.6364 31.6966 55.6302 31.6758 55.625C31.6582 55.6206 31.6405 55.6165 31.623 55.6113C31.6172 55.6096 31.6113 55.6082 31.6055 55.6064C31.5985 55.6043 31.5919 55.6009 31.585 55.5986C31.5538 55.5884 31.5227 55.5779 31.4922 55.5654C31.4906 55.5648 31.4889 55.5642 31.4873 55.5635C31.4857 55.5628 31.484 55.5622 31.4824 55.5615C31.4761 55.5589 31.4701 55.5555 31.4639 55.5527C31.4332 55.5393 31.4027 55.5256 31.373 55.5098C31.3594 55.5025 31.3464 55.4941 31.333 55.4863C31.308 55.4719 31.2829 55.4576 31.2588 55.4414C31.2534 55.4378 31.2485 55.4334 31.2432 55.4297C31.2102 55.4069 31.1776 55.3834 31.1465 55.3574L28.8545 53.4473C26.698 51.6504 23.9789 50.666 21.1719 50.666H9.33301C8.59678 50.6658 8 50.0693 8 49.333C8.00018 48.5969 8.59689 48.0002 9.33301 48H21.1719C24.6028 48 27.9258 49.2031 30.5615 51.3994L30.667 51.4863V24.333C30.6672 23.5968 31.2637 23 32 23Z"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
<path
|
||||
d="M1.3335 25.3333V54.6666C1.3335 56.1394 2.52741 57.3333 4.00016 57.3333H17.7918C19.9718 57.3333 21.9743 58.5348 23.0002 60.4583C23.6157 61.6124 24.8172 62.3333 26.1252 62.3333H37.8752C39.1831 62.3333 40.3846 61.6124 41.0002 60.4583C42.026 58.5348 44.0285 57.3333 46.2085 57.3333H60.0002C61.4729 57.3333 62.6668 56.1394 62.6668 54.6666V7.33329C62.6668 5.86053 61.4729 4.66663 60.0002 4.66663H47.3335C39.0002 4.66663 33.3335 9.30283 32.0002 15.3333C31.1196 12.0798 29.6491 9.72241 27.555 7.99996"
|
||||
stroke="var(--brand-color-text-default)"
|
||||
strokeWidth="2.66667"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { MarkGithubIcon } from "@primer/octicons-react";
|
||||
import { Box, Button, ThemeProvider, useTheme } from "@primer/react-brand";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { ContributorsNavButton } from "./ContributorsNavButton";
|
||||
import { LanguageSelect } from "./LanguageSelect";
|
||||
import { LargeFooter } from "./LargeFooter";
|
||||
import { SkipLink } from "./SkipLink";
|
||||
import { TopNav } from "./TopNav";
|
||||
import { TopNavSearch } from "./TopNavSearch";
|
||||
import { getAwesomeCopilotNavLinks, type AwesomeCopilotPage } from "./navigation";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
|
||||
const CONTRIBUTING_URL =
|
||||
"https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md";
|
||||
|
||||
export type PageShellProps = {
|
||||
/** Scoped CSS module for the page, providing the topBar/subNav class names. */
|
||||
styles: Record<string, string | undefined>;
|
||||
/** Catalog page to mark as current in the nav. */
|
||||
currentPage?: AwesomeCopilotPage;
|
||||
/** Site-wide search index, injected from build-time data. */
|
||||
searchIndex?: SearchItem[];
|
||||
/** Live contributor count for the nav button. */
|
||||
contributorsTotal?: number;
|
||||
searchAriaLabel?: string;
|
||||
contributorsCurrent?: boolean;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared chrome for every page: skip link, the combined top navigation
|
||||
* (brand mark, Resources menu, Playbook tab, search, contributors, contribute),
|
||||
* the `<main>` landmark, and the footer.
|
||||
*
|
||||
* The prototype repeated this block verbatim at the top of each page; it is
|
||||
* extracted here so pages only own their content.
|
||||
*/
|
||||
export function PageShell({
|
||||
styles,
|
||||
currentPage,
|
||||
searchIndex = [],
|
||||
contributorsTotal = 0,
|
||||
searchAriaLabel = "Search the library",
|
||||
contributorsCurrent = false,
|
||||
children,
|
||||
}: PageShellProps) {
|
||||
return (
|
||||
<ThemeProvider colorMode="auto">
|
||||
<PageShellBody
|
||||
styles={styles}
|
||||
currentPage={currentPage}
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
searchAriaLabel={searchAriaLabel}
|
||||
contributorsCurrent={contributorsCurrent}
|
||||
>
|
||||
{children}
|
||||
</PageShellBody>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner shell, rendered beneath the ThemeProvider so it can read the resolved
|
||||
* colour mode. The prototype mirrors that mode onto `data-mode`, which its CSS
|
||||
* modules key their light/dark treatments off.
|
||||
*/
|
||||
function PageShellBody({
|
||||
styles,
|
||||
currentPage,
|
||||
searchIndex = [],
|
||||
contributorsTotal = 0,
|
||||
searchAriaLabel = "Search the library",
|
||||
contributorsCurrent = false,
|
||||
children,
|
||||
}: PageShellProps) {
|
||||
const { colorMode } = useTheme();
|
||||
const subNavLinks = getAwesomeCopilotNavLinks(pageHref, currentPage);
|
||||
const contributorsHref = pageHref("contributors");
|
||||
|
||||
return (
|
||||
<Box className={styles.page} backgroundColor="default" data-mode={colorMode}>
|
||||
<SkipLink />
|
||||
<header className={styles.topBar}>
|
||||
<nav className={styles.topBarInner} aria-label="Primary">
|
||||
<a href={pageHref()} className={styles.subNavTitle}>
|
||||
<MarkGithubIcon size={24} />
|
||||
Awesome GitHub Copilot
|
||||
</a>
|
||||
<TopNav
|
||||
styles={styles}
|
||||
links={subNavLinks}
|
||||
contributorsHref={contributorsHref}
|
||||
contributorsTotal={contributorsTotal}
|
||||
searchIndex={searchIndex}
|
||||
contributorsCurrent={contributorsCurrent}
|
||||
searchAriaLabel={searchAriaLabel}
|
||||
/>
|
||||
<div className={styles.topBarActions}>
|
||||
<TopNavSearch
|
||||
index={searchIndex}
|
||||
styles={styles}
|
||||
inputAriaLabel={searchAriaLabel}
|
||||
/>
|
||||
<ContributorsNavButton
|
||||
href={contributorsHref}
|
||||
current={contributorsCurrent}
|
||||
total={contributorsTotal}
|
||||
/>
|
||||
<LanguageSelect />
|
||||
<Button as="a" href={CONTRIBUTING_URL} variant="subtle" size="small">
|
||||
Contribute
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main-content" tabIndex={-1}>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<LargeFooter />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { AlertIcon, MarkGithubIcon } from "@primer/octicons-react";
|
||||
|
||||
import {
|
||||
LearningArticleLayout,
|
||||
type TocSection,
|
||||
} from "./LearningArticleLayout";
|
||||
import type {
|
||||
ArticleBlock,
|
||||
ArticleSection,
|
||||
CalloutKind,
|
||||
} from "../../lib/playbook-article";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import styles from "./styles/github-copilot-app.module.css";
|
||||
|
||||
const CALLOUT_TITLES: Record<CalloutKind, string> = {
|
||||
note: "Note",
|
||||
tip: "Pro tip",
|
||||
caution: "Caution",
|
||||
};
|
||||
|
||||
/**
|
||||
* Static sibling of `ProTip`. Article bodies are prerendered, so this variant
|
||||
* reuses the same prototype classes and drops the dismiss control that would
|
||||
* require client state.
|
||||
*/
|
||||
function Callout({ kind, html }: { kind: CalloutKind; html: string }) {
|
||||
return (
|
||||
<aside className={styles.proTip}>
|
||||
<div className={styles.proTipHeader}>
|
||||
<span className={styles.proTipMark}>
|
||||
{kind === "caution" ? (
|
||||
<AlertIcon size={20} />
|
||||
) : (
|
||||
<MarkGithubIcon size={20} />
|
||||
)}
|
||||
</span>
|
||||
<span className={styles.proTipTitle}>{CALLOUT_TITLES[kind]}</span>
|
||||
</div>
|
||||
<div
|
||||
className={styles.proTipBody}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Block({ block, index }: { block: ArticleBlock; index: number }) {
|
||||
if (block.type === "callout") {
|
||||
return <Callout key={index} kind={block.kind} html={block.html} />;
|
||||
}
|
||||
return <div key={index} dangerouslySetInnerHTML={{ __html: block.html }} />;
|
||||
}
|
||||
|
||||
export function PlaybookArticleBody({
|
||||
slug,
|
||||
breadcrumbLabel,
|
||||
title,
|
||||
description,
|
||||
sections,
|
||||
tocSections,
|
||||
searchIndex = [],
|
||||
contributorsTotal = 0,
|
||||
}: {
|
||||
/** Site path of this article, e.g. `learning-hub/agentic-workflows`. */
|
||||
slug: string;
|
||||
breadcrumbLabel: string;
|
||||
title: string;
|
||||
description: string;
|
||||
sections: ArticleSection[];
|
||||
tocSections: TocSection[];
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
}) {
|
||||
return (
|
||||
<LearningArticleLayout
|
||||
pageHref={pageHref}
|
||||
currentPage={slug}
|
||||
breadcrumbLabel={breadcrumbLabel}
|
||||
heroTitle={title}
|
||||
heroSubtitle={description}
|
||||
tocSections={tocSections}
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<section
|
||||
key={section.id}
|
||||
id={section.id}
|
||||
className={styles.articleSection}
|
||||
>
|
||||
{section.blocks.map((block, index) => (
|
||||
<Block key={index} block={block} index={index} />
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</LearningArticleLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
type PlaybookIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export function PlaybookIcon({ size = 36 }: PlaybookIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 96 96"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Copilot Playbook"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#playbook-icon-clip)">
|
||||
<path
|
||||
d="M48 23C50 12.9543 59.9543 6 71 6H90C92.2091 6 94 7.79086 94 10V81C94 83.2091 92.2091 85 90 85H72.5C68.0769 85 63.9581 87.061 61.3028 90.4876C60.3979 91.6553 59.0772 92.5 57.6 92.5H38.4C36.9228 92.5 35.6021 91.6553 34.6972 90.4876C32.0419 87.061 27.9231 85 23.5 85H6C3.79086 85 2 83.2091 2 81V43.5"
|
||||
stroke="var(--brand-color-text-default)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M15.3384 24.9263C10.8911 24.3872 7.75781 21.1865 7.75781 17.0425C7.75781 15.3579 8.36426 13.5386 9.375 12.3257C8.93701 11.2139 9.00439 8.85547 9.50977 7.87842C10.8574 7.70996 12.6768 8.41748 13.7549 9.39453C15.0352 8.99023 16.3828 8.78809 18.0337 8.78809C19.6846 8.78809 21.0322 8.99023 22.2451 9.36084C23.2896 8.41748 25.1426 7.70996 26.4902 7.87842C26.9619 8.78809 27.0293 11.1465 26.5913 12.292C27.6694 13.5723 28.2422 15.2905 28.2422 17.0425C28.2422 21.1865 25.1089 24.3198 20.5942 24.8926C21.7397 25.6338 22.5146 27.251 22.5146 29.104V32.6079C22.5146 33.6187 23.3569 34.1914 24.3677 33.7871C30.4658 31.4624 35.25 25.3643 35.25 17.8174C35.25 8.28271 27.501 0.5 17.9663 0.5C8.43164 0.5 0.75 8.28271 0.75 17.8174C0.75 25.2969 5.50049 31.4961 11.9019 33.8208C12.8115 34.1577 13.6875 33.5513 13.6875 32.6416V29.9463C13.2158 30.1484 12.6094 30.2832 12.0703 30.2832C9.84668 30.2832 8.53272 29.0703 7.58936 26.813C7.21875 25.9033 6.81445 25.3643 6.03955 25.2632C5.63525 25.2295 5.50049 25.061 5.50049 24.8589C5.50049 24.4546 6.17432 24.1514 6.84814 24.1514C7.8252 24.1514 8.66748 24.7578 9.54346 26.0044C10.2173 26.9814 10.9248 27.4194 11.7671 27.4194C12.6094 27.4194 13.1484 27.1162 13.9233 26.3413C14.4961 25.7686 14.9341 25.2632 15.3384 24.9263Z"
|
||||
fill="var(--brand-color-text-default)"
|
||||
/>
|
||||
<path
|
||||
d="M48 35.5L48 80.5"
|
||||
stroke="var(--brand-color-text-muted)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 80.5L44.5627 77.6356C40.9684 74.6403 36.4377 73 31.759 73H14"
|
||||
stroke="var(--brand-color-text-muted)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M48 80.5L51.4373 77.6356C55.0316 74.6403 59.5623 73 64.241 73H82"
|
||||
stroke="var(--brand-color-text-muted)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="playbook-icon-clip">
|
||||
<rect width="96" height="96" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,859 @@
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
ArrowUpRightIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { clsx } from "clsx";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
Grid,
|
||||
Heading,
|
||||
Image,
|
||||
Pagination,
|
||||
Section,
|
||||
Stack,
|
||||
Text,
|
||||
useTheme,
|
||||
} from "@primer/react-brand";
|
||||
|
||||
import styles from "./styles/learning-hub-copilot-app.module.css";
|
||||
import { DuckIcon } from "./DuckIcon";
|
||||
import { PlaybookIcon } from "./PlaybookIcon";
|
||||
import { PageShell } from "./PageShell";
|
||||
import { ScrambleText } from "./ScrambleText";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
|
||||
type Topic =
|
||||
| "Getting started"
|
||||
| "Agents & Skills"
|
||||
| "Customization"
|
||||
| "Automation"
|
||||
| "Extensions & MCP"
|
||||
| "CLI";
|
||||
type Kind = "Guide" | "Tutorial" | "Terminology" | "Example";
|
||||
|
||||
/** Article frontmatter projected out of the `docs` collection at build time. */
|
||||
export type PlaybookArticle = {
|
||||
/** Collection entry id, e.g. `learning-hub/agentic-workflows`. */
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
tags: string[];
|
||||
/** ISO date string; the collection stores a Date, serialised across the island boundary. */
|
||||
lastUpdated?: string;
|
||||
};
|
||||
|
||||
type Entry = PlaybookArticle & {
|
||||
topic: Topic;
|
||||
kind: Kind;
|
||||
isNew?: boolean;
|
||||
};
|
||||
|
||||
// The prototype hardcoded a topic/kind per article. Real frontmatter only
|
||||
// carries free-form `tags`, so the two facets are derived from them; the first
|
||||
// matching tag wins, in the order listed here.
|
||||
const TOPIC_BY_TAG: [string, Topic][] = [
|
||||
["cli", "CLI"],
|
||||
["mcp", "Extensions & MCP"],
|
||||
["canvases", "Extensions & MCP"],
|
||||
["canvas-extensions", "Extensions & MCP"],
|
||||
["extensions", "Extensions & MCP"],
|
||||
["plugins", "Extensions & MCP"],
|
||||
["agents", "Agents & Skills"],
|
||||
["subagents", "Agents & Skills"],
|
||||
["skills", "Agents & Skills"],
|
||||
["orchestration", "Agents & Skills"],
|
||||
["coding-agent", "Automation"],
|
||||
["automation", "Automation"],
|
||||
["automations", "Automation"],
|
||||
["workflows", "Automation"],
|
||||
["hooks", "Automation"],
|
||||
["github-actions", "Automation"],
|
||||
["customization", "Customization"],
|
||||
["instructions", "Customization"],
|
||||
["configuration", "Customization"],
|
||||
];
|
||||
|
||||
const KIND_BY_TAG: [string, Kind][] = [
|
||||
["glossary", "Terminology"],
|
||||
["terminology", "Terminology"],
|
||||
["reference", "Terminology"],
|
||||
["examples", "Example"],
|
||||
["tutorial", "Tutorial"],
|
||||
["setup", "Tutorial"],
|
||||
["desktop", "Tutorial"],
|
||||
];
|
||||
|
||||
function deriveTopic(tags: string[]): Topic {
|
||||
for (const [tag, topic] of TOPIC_BY_TAG) {
|
||||
if (tags.includes(tag)) return topic;
|
||||
}
|
||||
return "Getting started";
|
||||
}
|
||||
|
||||
function deriveKind(tags: string[]): Kind {
|
||||
for (const [tag, kind] of KIND_BY_TAG) {
|
||||
if (tags.includes(tag)) return kind;
|
||||
}
|
||||
return "Guide";
|
||||
}
|
||||
|
||||
type FilterGroupId = "topic" | "kind";
|
||||
|
||||
// The prototype also offered a "Level" facet. No article frontmatter records a
|
||||
// level, so the group is omitted rather than populated with invented values.
|
||||
const filterGroups: { id: FilterGroupId; label: string; options: string[] }[] = [
|
||||
{
|
||||
id: "topic",
|
||||
label: "Topic",
|
||||
options: [
|
||||
"Getting started",
|
||||
"Agents & Skills",
|
||||
"Customization",
|
||||
"Automation",
|
||||
"Extensions & MCP",
|
||||
"CLI",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
label: "Content type",
|
||||
options: ["Guide", "Tutorial", "Terminology", "Example"],
|
||||
},
|
||||
];
|
||||
|
||||
type FilterState = Record<FilterGroupId, string[]>;
|
||||
|
||||
const emptyFilters: FilterState = {
|
||||
topic: [],
|
||||
kind: [],
|
||||
};
|
||||
|
||||
const recommendedCards = [
|
||||
{
|
||||
id: "desktop-app",
|
||||
page: "github-copilot-app",
|
||||
labels: [] as string[],
|
||||
title: "Desktop App",
|
||||
description:
|
||||
"Explore the GitHub Copilot app — a control center for directing multiple agents in parallel. Perfect for agent-native development and parallel work with isolated worktrees.",
|
||||
},
|
||||
{
|
||||
id: "reference",
|
||||
page: "github-copilot-terminology-glossary",
|
||||
labels: [] as string[],
|
||||
title: "Terminology",
|
||||
description:
|
||||
"Quick-lookup resources to keep handy while you work. Browse the GitHub Copilot Terminology Glossary for definitions of common terms and concepts.",
|
||||
},
|
||||
{
|
||||
id: "terminal",
|
||||
page: "cli-for-beginners",
|
||||
labels: [] as string[],
|
||||
title: "Copilot CLI",
|
||||
description:
|
||||
"Looking for a guided path into GitHub Copilot from the terminal? Explore the Copilot CLI for Beginners with a text-based experience or the YouTube video series.",
|
||||
},
|
||||
{
|
||||
id: "fundamentals",
|
||||
page: "what-are-agents-skills-instructions",
|
||||
labels: [] as string[],
|
||||
title: "Fundamentals",
|
||||
description:
|
||||
"Essential concepts to tailor GitHub Copilot beyond its default experience. Start with What are Agents, Skills, and Instructions and work through the full track to master every customization primitive. For delegation and orchestration patterns, continue with Agents and Subagents.",
|
||||
},
|
||||
{
|
||||
id: "automations",
|
||||
page: "using-automations-in-copilot-app",
|
||||
labels: [] as string[],
|
||||
title: "Automations",
|
||||
description:
|
||||
"Start with Using Automations in the GitHub Copilot app for templates, setup guidance, and real examples.",
|
||||
},
|
||||
{
|
||||
id: "canvases",
|
||||
page: "working-with-canvas-extensions",
|
||||
labels: [] as string[],
|
||||
title: "Canvases",
|
||||
description:
|
||||
"Learn Working with Canvas Extensions to create and evolve interactive canvases with /create-canvas.",
|
||||
},
|
||||
];
|
||||
|
||||
export function PlaybookIndex({
|
||||
articles,
|
||||
searchIndex = [],
|
||||
contributorsTotal = 0,
|
||||
}: {
|
||||
articles: PlaybookArticle[];
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
}) {
|
||||
return (
|
||||
<PageShell
|
||||
styles={styles}
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
searchAriaLabel="Search the Copilot Playbook"
|
||||
>
|
||||
<PlaybookIndexBody articles={articles} searchIndex={searchIndex} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaybookIndexBody({
|
||||
articles,
|
||||
searchIndex,
|
||||
}: {
|
||||
articles: PlaybookArticle[];
|
||||
searchIndex: SearchItem[];
|
||||
}) {
|
||||
const { colorMode } = useTheme();
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [heroSearchFocused, setHeroSearchFocused] = useState(false);
|
||||
const [heroActiveIndex, setHeroActiveIndex] = useState(-1);
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
const heroSearchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const entries = useMemo<Entry[]>(() => {
|
||||
const newest = articles.reduce<string | undefined>((latest, article) => {
|
||||
if (!article.lastUpdated) return latest;
|
||||
return !latest || article.lastUpdated > latest
|
||||
? article.lastUpdated
|
||||
: latest;
|
||||
}, undefined);
|
||||
return articles.map((article) => ({
|
||||
...article,
|
||||
topic: deriveTopic(article.tags),
|
||||
kind: deriveKind(article.tags),
|
||||
isNew: newest !== undefined && article.lastUpdated === newest,
|
||||
}));
|
||||
}, [articles]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === "/" &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey
|
||||
) {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const isTyping =
|
||||
target?.tagName === "INPUT" ||
|
||||
target?.tagName === "TEXTAREA" ||
|
||||
target?.isContentEditable === true;
|
||||
if (!isTyping) {
|
||||
event.preventDefault();
|
||||
heroSearchInputRef.current?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
const REC_PER_PAGE = 3;
|
||||
const recPageCount = Math.ceil(recommendedCards.length / REC_PER_PAGE);
|
||||
const [recPage, setRecPage] = React.useState(0);
|
||||
const changeRecPage = (direction: 1 | -1) => {
|
||||
setRecPage((page) => (page + direction + recPageCount) % recPageCount);
|
||||
};
|
||||
const visibleRecCards = recommendedCards.slice(
|
||||
recPage * REC_PER_PAGE,
|
||||
recPage * REC_PER_PAGE + REC_PER_PAGE,
|
||||
);
|
||||
|
||||
const [filters, setFilters] = useState<FilterState>(emptyFilters);
|
||||
const [sortOrder, setSortOrder] = useState<"newest" | "oldest">("newest");
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
const query = searchTerm.trim().toLowerCase();
|
||||
return entries.filter((entry) => {
|
||||
const topicOk =
|
||||
filters.topic.length === 0 || filters.topic.includes(entry.topic);
|
||||
const kindOk =
|
||||
filters.kind.length === 0 || filters.kind.includes(entry.kind);
|
||||
const searchOk =
|
||||
query.length === 0 ||
|
||||
[entry.title, entry.description, entry.topic, entry.kind, ...entry.tags]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
return topicOk && kindOk && searchOk;
|
||||
});
|
||||
}, [entries, filters, searchTerm]);
|
||||
|
||||
const sortedEntries = useMemo(() => {
|
||||
return sortOrder === "oldest"
|
||||
? [...filteredEntries].reverse()
|
||||
: filteredEntries;
|
||||
}, [filteredEntries, sortOrder]);
|
||||
|
||||
const articleMatches = useMemo(() => {
|
||||
const query = searchTerm.trim().toLowerCase();
|
||||
if (query.length === 0) return [];
|
||||
return entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.title.toLowerCase().includes(query) ||
|
||||
entry.description.toLowerCase().includes(query),
|
||||
)
|
||||
.slice(0, 5);
|
||||
}, [entries, searchTerm]);
|
||||
|
||||
const resourceMatches = useMemo(() => {
|
||||
const query = searchTerm.trim().toLowerCase();
|
||||
if (query.length === 0) return [];
|
||||
return searchIndex
|
||||
.filter(
|
||||
(resource) =>
|
||||
resource.title.toLowerCase().includes(query) ||
|
||||
resource.description.toLowerCase().includes(query) ||
|
||||
resource.category.toLowerCase().includes(query),
|
||||
)
|
||||
.slice(0, 5);
|
||||
}, [searchIndex, searchTerm]);
|
||||
|
||||
const trimmedSearch = searchTerm.trim();
|
||||
const heroSearchOpen = heroSearchFocused && trimmedSearch.length > 0;
|
||||
const hasSearchResults =
|
||||
articleMatches.length > 0 || resourceMatches.length > 0;
|
||||
|
||||
const heroOptionCount = articleMatches.length + resourceMatches.length;
|
||||
|
||||
// A fresh query re-orders the list, so drop any stale highlight.
|
||||
React.useEffect(() => {
|
||||
setHeroActiveIndex(-1);
|
||||
}, [trimmedSearch]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (heroActiveIndex < 0) return;
|
||||
document
|
||||
.getElementById(`hero-search-option-${heroActiveIndex}`)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}, [heroActiveIndex]);
|
||||
|
||||
const handleResultsKeyDown = (
|
||||
event: React.KeyboardEvent<HTMLInputElement>,
|
||||
optionCount: number,
|
||||
activeIndex: number,
|
||||
setActiveIndex: React.Dispatch<React.SetStateAction<number>>,
|
||||
optionIdPrefix: string,
|
||||
) => {
|
||||
if (optionCount === 0) return;
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
setActiveIndex((i) => (i + 1) % optionCount);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
setActiveIndex((i) => (i <= 0 ? optionCount - 1 : i - 1));
|
||||
break;
|
||||
case "Home":
|
||||
event.preventDefault();
|
||||
setActiveIndex(0);
|
||||
break;
|
||||
case "End":
|
||||
event.preventDefault();
|
||||
setActiveIndex(optionCount - 1);
|
||||
break;
|
||||
case "Enter":
|
||||
if (activeIndex >= 0) {
|
||||
event.preventDefault();
|
||||
document.getElementById(`${optionIdPrefix}-${activeIndex}`)?.click();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFilter = (groupId: FilterGroupId, option: string) => {
|
||||
setFilters((prev) => {
|
||||
const current = prev[groupId];
|
||||
const next = current.includes(option)
|
||||
? current.filter((value) => value !== option)
|
||||
: [...current, option];
|
||||
return { ...prev, [groupId]: next };
|
||||
});
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters(emptyFilters);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const activeFilterCount = filters.topic.length + filters.kind.length;
|
||||
const hasActiveFilters = activeFilterCount > 0;
|
||||
|
||||
const pageSize = 6;
|
||||
const pageCount = Math.max(1, Math.ceil(sortedEntries.length / pageSize));
|
||||
const page = Math.min(currentPage, pageCount);
|
||||
const visibleEntries = sortedEntries.slice(
|
||||
(page - 1) * pageSize,
|
||||
page * pageSize,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box className={styles.hero}>
|
||||
<div className={styles.heroInner}>
|
||||
<div className={styles.heroTop}>
|
||||
<span className={styles.heroIcon} aria-hidden="true">
|
||||
<PlaybookIcon size={44} />
|
||||
</span>
|
||||
<Heading as="h1" size="3" className={styles.heroHeading}>
|
||||
Copilot Playbook
|
||||
</Heading>
|
||||
</div>
|
||||
<label className={styles.heroSearch}>
|
||||
<input
|
||||
type="text"
|
||||
ref={heroSearchInputRef}
|
||||
className={styles.heroSearchInput}
|
||||
aria-label="Search the Copilot Playbook"
|
||||
role="combobox"
|
||||
aria-expanded={heroSearchOpen}
|
||||
aria-controls="hero-search-results"
|
||||
aria-activedescendant={
|
||||
heroSearchOpen && heroActiveIndex >= 0
|
||||
? `hero-search-option-${heroActiveIndex}`
|
||||
: undefined
|
||||
}
|
||||
autoComplete="off"
|
||||
value={searchTerm}
|
||||
onFocus={() => setHeroSearchFocused(true)}
|
||||
onBlur={() => setHeroSearchFocused(false)}
|
||||
onChange={(event) => {
|
||||
setSearchTerm(event.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
setSearchTerm("");
|
||||
setHeroSearchFocused(false);
|
||||
setHeroActiveIndex(-1);
|
||||
event.currentTarget.blur();
|
||||
return;
|
||||
}
|
||||
handleResultsKeyDown(
|
||||
event,
|
||||
heroOptionCount,
|
||||
heroActiveIndex,
|
||||
setHeroActiveIndex,
|
||||
"hero-search-option",
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{searchTerm === "" && (
|
||||
<div className={styles.heroSearchHint} aria-hidden="true">
|
||||
<span className={styles.heroSearchCaret} />
|
||||
<span className={styles.heroSearchHintMono}>
|
||||
<ScrambleText
|
||||
text="What would you like to learn?"
|
||||
cursor="fade"
|
||||
/>
|
||||
</span>
|
||||
<span className={styles.heroSearchDuck}>
|
||||
<DuckIcon size={22} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{searchTerm === "" ? (
|
||||
<kbd className={styles.heroSearchKbd}>/</kbd>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
styles.heroSearchKbd,
|
||||
styles.heroSearchKbdButton,
|
||||
)}
|
||||
aria-label="Clear search"
|
||||
onClick={() => {
|
||||
setSearchTerm("");
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
/
|
||||
</button>
|
||||
)}
|
||||
{heroSearchOpen && (
|
||||
<div
|
||||
id="hero-search-results"
|
||||
className={styles.heroSearchResults}
|
||||
role="listbox"
|
||||
aria-label="Search results"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
{hasSearchResults ? (
|
||||
<>
|
||||
{articleMatches.length > 0 && (
|
||||
<div className={styles.heroSearchGroup}>
|
||||
<p className={styles.heroSearchGroupLabel}>Articles</p>
|
||||
{articleMatches.map((entry, i) => (
|
||||
<a
|
||||
key={entry.id}
|
||||
id={`hero-search-option-${i}`}
|
||||
href={entry.href}
|
||||
className={clsx(
|
||||
styles.heroSearchResult,
|
||||
heroActiveIndex === i &&
|
||||
styles.heroSearchResultActive,
|
||||
)}
|
||||
role="option"
|
||||
aria-selected={heroActiveIndex === i}
|
||||
onMouseEnter={() => setHeroActiveIndex(i)}
|
||||
>
|
||||
<span className={styles.heroSearchResultText}>
|
||||
{entry.title}
|
||||
</span>
|
||||
<ArrowUpRightIcon
|
||||
size={16}
|
||||
className={styles.heroSearchResultIcon}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{resourceMatches.length > 0 && (
|
||||
<div className={styles.heroSearchGroup}>
|
||||
<p className={styles.heroSearchGroupLabel}>Resources</p>
|
||||
{resourceMatches.map((resource, j) => {
|
||||
const optionIndex = articleMatches.length + j;
|
||||
return (
|
||||
<a
|
||||
key={`${resource.category}-${resource.title}`}
|
||||
id={`hero-search-option-${optionIndex}`}
|
||||
href={resource.href}
|
||||
className={clsx(
|
||||
styles.heroSearchResult,
|
||||
heroActiveIndex === optionIndex &&
|
||||
styles.heroSearchResultActive,
|
||||
)}
|
||||
role="option"
|
||||
aria-selected={heroActiveIndex === optionIndex}
|
||||
onMouseEnter={() => setHeroActiveIndex(optionIndex)}
|
||||
>
|
||||
<span className={styles.heroSearchResultText}>
|
||||
{resource.title}
|
||||
<span className={styles.heroSearchResultMeta}>
|
||||
{resource.category}
|
||||
</span>
|
||||
</span>
|
||||
<ArrowUpRightIcon
|
||||
size={16}
|
||||
className={styles.heroSearchResultIcon}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.heroSearchMore}
|
||||
onClick={() => {
|
||||
setHeroSearchFocused(false);
|
||||
document
|
||||
.getElementById("catalog")
|
||||
?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}}
|
||||
>
|
||||
See all results for “{trimmedSearch}”
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className={styles.heroSearchEmpty}>
|
||||
No results for “{trimmedSearch}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box className={styles.recommended}>
|
||||
<div className={styles.recommendedInner}>
|
||||
<div className={styles.recommendedHeader}>
|
||||
<Heading as="h2" className={styles.recommendedTitle}>
|
||||
Get started
|
||||
</Heading>
|
||||
<div className={styles.carouselNav}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.carouselButton}
|
||||
aria-label="Previous recommendations"
|
||||
onClick={() => changeRecPage(-1)}
|
||||
>
|
||||
<ArrowLeftIcon size={20} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.carouselButton}
|
||||
aria-label="Next recommendations"
|
||||
onClick={() => changeRecPage(1)}
|
||||
>
|
||||
<ArrowRightIcon size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.carouselTrack} key={recPage}>
|
||||
{visibleRecCards.map((rec) => (
|
||||
<a
|
||||
key={rec.id}
|
||||
href={pageHref(rec.page)}
|
||||
data-rec-card
|
||||
className={styles.recCard}
|
||||
>
|
||||
<div className={styles.recCardLabels}>
|
||||
{rec.labels.map((label) => (
|
||||
<span key={label} className={styles.recCardLabel}>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<h3 className={styles.recCardHeading}>
|
||||
{rec.title}
|
||||
<ArrowUpRightIcon size={20} />
|
||||
</h3>
|
||||
<p className={styles.recCardDesc}>{rec.description}</p>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
styles.recCardImage,
|
||||
rec.id === "desktop-app" && styles.recCardImageDesktop,
|
||||
rec.id === "terminal" && styles.recCardImageTerminal,
|
||||
rec.id === "fundamentals" && styles.recCardImageFundamentals,
|
||||
rec.id === "automations" && styles.recCardImageAutomations,
|
||||
rec.id === "canvases" && styles.recCardImageCanvases,
|
||||
rec.id === "reference" && styles.recCardImageReference,
|
||||
)}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Section
|
||||
id="catalog"
|
||||
className={styles.catalogSection}
|
||||
paddingBlockStart="none"
|
||||
paddingBlockEnd="none"
|
||||
>
|
||||
<div className={styles.catalogHeader}>
|
||||
<Heading as="h2" className={styles.recommendedTitle}>
|
||||
Articles
|
||||
</Heading>
|
||||
<div className={styles.sortControl}>
|
||||
<span className={styles.sortLabel}>Sort by:</span>
|
||||
<details className={styles.sortMenu}>
|
||||
<summary className={styles.sortTrigger}>
|
||||
{sortOrder === "newest" ? "Newest" : "Oldest"}
|
||||
<ChevronDownIcon size={16} className={styles.sortChevron} />
|
||||
</summary>
|
||||
<div className={styles.sortOverlay} role="menu">
|
||||
{(["newest", "oldest"] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={sortOrder === value}
|
||||
className={clsx(
|
||||
styles.sortOption,
|
||||
sortOrder === value && styles.sortOptionActive,
|
||||
)}
|
||||
onClick={(event) => {
|
||||
setSortOrder(value);
|
||||
setCurrentPage(1);
|
||||
event.currentTarget
|
||||
.closest("details")
|
||||
?.removeAttribute("open");
|
||||
}}
|
||||
>
|
||||
{value === "newest" ? "Newest" : "Oldest"}
|
||||
<CheckIcon
|
||||
size={16}
|
||||
className={styles.sortOptionCheck}
|
||||
aria-hidden={sortOrder !== value}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.filterToggle}
|
||||
aria-expanded={mobileFiltersOpen}
|
||||
onClick={() => setMobileFiltersOpen((open) => !open)}
|
||||
>
|
||||
<span>
|
||||
Filters{hasActiveFilters ? ` (${activeFilterCount})` : ""}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
size={16}
|
||||
className={clsx(
|
||||
styles.filterToggleChevron,
|
||||
mobileFiltersOpen && styles.filterToggleChevronOpen,
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<Box className={styles.catalog}>
|
||||
<aside className={styles.filterNav} aria-label="Filter guide sections">
|
||||
<div
|
||||
className={clsx(
|
||||
styles.filterBody,
|
||||
!mobileFiltersOpen && styles.filterBodyCollapsed,
|
||||
)}
|
||||
>
|
||||
{filterGroups.map((group) => (
|
||||
<div className={styles.filterGroup} key={group.id}>
|
||||
<Text as="h2" size="100" className={styles.filterHeading}>
|
||||
{group.label}
|
||||
</Text>
|
||||
<div className={styles.filterOptions}>
|
||||
{group.options.map((option) => (
|
||||
<div className={styles.filterOption} key={option}>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={filters[group.id].includes(option)}
|
||||
onChange={() => toggleFilter(group.id, option)}
|
||||
/>
|
||||
<FormControl.Label>{option}</FormControl.Label>
|
||||
</FormControl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{hasActiveFilters ? (
|
||||
<div className={styles.filterActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
hasArrow={false}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear all ({activeFilterCount})
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Box className={styles.catalogMain}>
|
||||
<Box className={styles.gridFrame} data-mode={colorMode}>
|
||||
<Box className={styles.gridContent}>
|
||||
<Grid
|
||||
className={styles.threeUp}
|
||||
columnGap="none"
|
||||
rowGap="none"
|
||||
enableGutters={false}
|
||||
>
|
||||
{visibleEntries.map((entry) => (
|
||||
<Grid.Column
|
||||
key={entry.id}
|
||||
span={{ xsmall: 12, medium: 6, large: 6 }}
|
||||
className={styles.col}
|
||||
>
|
||||
<Box className={clsx(styles.item, styles.itemHover)}>
|
||||
<a href={entry.href} className={styles.catalogCard}>
|
||||
<div className={styles.recCardLabels}>
|
||||
{entry.isNew ? (
|
||||
<span
|
||||
className={clsx(
|
||||
styles.recCardLabel,
|
||||
styles.recCardLabelAccent,
|
||||
)}
|
||||
>
|
||||
New
|
||||
</span>
|
||||
) : null}
|
||||
<span className={styles.recCardLabel}>
|
||||
{entry.topic}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
styles.recCardLabel,
|
||||
styles.recCardLabelOutline,
|
||||
)}
|
||||
>
|
||||
{entry.kind}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className={styles.recCardHeading}>
|
||||
{entry.title}
|
||||
<ArrowUpRightIcon size={20} />
|
||||
</h3>
|
||||
<p className={styles.recCardDesc}>
|
||||
{entry.description}
|
||||
</p>
|
||||
</a>
|
||||
</Box>
|
||||
</Grid.Column>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{filteredEntries.length === 0 ? (
|
||||
<Box className={styles.emptyState}>
|
||||
<Text as="p" variant="muted">
|
||||
No sections match the selected filters.
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
<div className={styles.rule} aria-hidden="true" />
|
||||
|
||||
<Box className={styles.paginationRow}>
|
||||
<Stack direction="horizontal" justifyContent="center" padding="none">
|
||||
<Pagination
|
||||
className={styles.pagination}
|
||||
pageCount={pageCount}
|
||||
currentPage={page}
|
||||
onPageChange={(e, n) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(n);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box className={styles.dividerFrame}>
|
||||
<Section paddingBlockStart="condensed" paddingBlockEnd="none">
|
||||
<Stack justifyContent="center" padding="none">
|
||||
<Image
|
||||
src="/media/brand-divider-copilot-jumping.webp"
|
||||
alt=""
|
||||
width={1738}
|
||||
height={196}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ display: "block", width: "100%", height: "auto" }}
|
||||
/>
|
||||
</Stack>
|
||||
</Section>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { CheckIcon, CopyIcon, MarkGithubIcon } from "@primer/octicons-react";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Grid,
|
||||
Heading,
|
||||
Label,
|
||||
Text,
|
||||
Token,
|
||||
useTheme,
|
||||
} from "@primer/react-brand";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
import type { ExternalSource } from "../../lib/external-source";
|
||||
import {
|
||||
DetailChassis,
|
||||
type DetailSibling,
|
||||
type DetailTocItem,
|
||||
} from "./DetailChassis";
|
||||
import { ResourceMeta } from "./ResourceMeta";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import installStyles from "./styles/github-copilot-app.module.css";
|
||||
import gridStyles from "./styles/plugins.module.css";
|
||||
import styles from "./styles/dotnet-upgrade.module.css";
|
||||
|
||||
/** A bundled agent / hook / skill / extension inside a plugin. */
|
||||
export type PluginIncludedItem = {
|
||||
kind: string;
|
||||
path?: string;
|
||||
title?: string | null;
|
||||
/** Absolute site path to the item's own detail page, when one exists. */
|
||||
detailUrl?: string | null;
|
||||
};
|
||||
|
||||
export type PluginDetailItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
version?: string | null;
|
||||
tags?: string[];
|
||||
itemCount?: number;
|
||||
items?: PluginIncludedItem[];
|
||||
external?: boolean;
|
||||
repository?: string | null;
|
||||
homepage?: string | null;
|
||||
license?: string | null;
|
||||
author?: { name?: string; url?: string } | null;
|
||||
source?: ExternalSource | null;
|
||||
};
|
||||
|
||||
export type PluginDetailProps = {
|
||||
item: PluginDetailItem;
|
||||
/** Rendered, sanitized README with heading ids already stamped. */
|
||||
markdownHtml: string;
|
||||
toc: DetailTocItem[];
|
||||
/** GitHub URL for the plugin source (repo tree, or the external repo). */
|
||||
githubUrl: string;
|
||||
/** Copilot CLI command that installs this plugin. */
|
||||
installCommand: string;
|
||||
lastUpdated?: string | null;
|
||||
previous?: DetailSibling;
|
||||
next?: DetailSibling;
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
};
|
||||
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
agent: "Agent",
|
||||
hook: "Hook",
|
||||
skill: "Skill",
|
||||
extension: "Extension",
|
||||
instruction: "Instruction",
|
||||
mcp: "MCP server",
|
||||
prompt: "Prompt",
|
||||
};
|
||||
|
||||
const kindLabel = (kind: string) =>
|
||||
KIND_LABELS[kind] ?? kind.charAt(0).toUpperCase() + kind.slice(1);
|
||||
|
||||
/** Fall back to the trailing path segment when an item carries no title. */
|
||||
function itemTitle(item: PluginIncludedItem): string {
|
||||
if (item.title) return item.title;
|
||||
const segments = (item.path ?? "")
|
||||
.replace(/\/+$/, "")
|
||||
.split("/")
|
||||
.filter(Boolean);
|
||||
const last = segments[segments.length - 1] ?? "";
|
||||
return last.replace(/\.[^.]+$/, "") || kindLabel(item.kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail route for a plugin.
|
||||
*
|
||||
* 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 is a copyable Copilot CLI command rather than a
|
||||
* VS Code deep link, and externally-hosted plugins carry provenance badges.
|
||||
*/
|
||||
export function PluginDetail({
|
||||
item,
|
||||
markdownHtml,
|
||||
toc,
|
||||
githubUrl,
|
||||
installCommand,
|
||||
lastUpdated,
|
||||
previous,
|
||||
next,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
}: PluginDetailProps) {
|
||||
const { colorMode } = useTheme();
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const copyTimer = React.useRef<number | null>(null);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(installCommand);
|
||||
setCopied(true);
|
||||
if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
|
||||
copyTimer.current = window.setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
}, [installCommand]);
|
||||
|
||||
const includedItems = item.items ?? [];
|
||||
const hasContents = includedItems.length > 0;
|
||||
|
||||
const install = (
|
||||
<>
|
||||
<div className={installStyles.installBar}>
|
||||
<code className={installStyles.installCommand}>{installCommand}</code>
|
||||
<button
|
||||
type="button"
|
||||
className={installStyles.installCopy}
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? "Copied to clipboard" : "Copy install command"}
|
||||
>
|
||||
{copied ? <CheckIcon size={16} /> : <CopyIcon size={16} />}
|
||||
<span>{copied ? "Copied" : "Copy"}</span>
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
as="a"
|
||||
href={githubUrl}
|
||||
variant="secondary"
|
||||
className={styles.iconButton}
|
||||
aria-label={`View the ${item.name} plugin on GitHub`}
|
||||
>
|
||||
<MarkGithubIcon size={16} />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
// Provenance sits in the hero: whether the plugin ships from this repository
|
||||
// or from a third-party repo is the first thing a reader needs to know.
|
||||
const heroExtras = (
|
||||
<div className={styles.metaValues} aria-label="Plugin provenance">
|
||||
<Label color={item.external ? "purple" : "green"} size="medium">
|
||||
{item.external ? "External plugin" : "Built-in"}
|
||||
</Label>
|
||||
{item.version ? <Token>{`v${item.version}`}</Token> : null}
|
||||
{item.author?.name ? <Token>{item.author.name}</Token> : null}
|
||||
{item.license ? <Token>{item.license}</Token> : null}
|
||||
{item.external && item.source?.repo ? (
|
||||
<Token>{item.source.repo}</Token>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const chassisToc: DetailTocItem[] = hasContents
|
||||
? [{ id: "plugin-contents", label: "What's included" }, ...toc]
|
||||
: toc;
|
||||
|
||||
return (
|
||||
<DetailChassis
|
||||
title={item.name}
|
||||
description={item.description}
|
||||
breadcrumbs={[
|
||||
{ label: "Plugins", href: pageHref("plugins") },
|
||||
{ label: item.name },
|
||||
]}
|
||||
install={install}
|
||||
heroExtras={heroExtras}
|
||||
toc={chassisToc}
|
||||
sidebar={
|
||||
<ResourceMeta
|
||||
kicker="Plugin details"
|
||||
groups={[
|
||||
{ label: "Version", items: item.version ? [item.version] : [] },
|
||||
{
|
||||
label: "Source",
|
||||
items: [item.external ? "External" : "Built-in"],
|
||||
},
|
||||
{ label: "License", items: item.license ? [item.license] : [] },
|
||||
{ label: "Tags", items: item.tags ?? [] },
|
||||
]}
|
||||
author={item.author?.name ?? null}
|
||||
lastUpdated={lastUpdated}
|
||||
sourceUrl={githubUrl}
|
||||
/>
|
||||
}
|
||||
previous={previous}
|
||||
next={next}
|
||||
currentPage="plugins"
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
>
|
||||
{item.external ? (
|
||||
<section className={styles.articleSection}>
|
||||
<div className={styles.proTip}>
|
||||
<div className={styles.proTipHeader}>
|
||||
<span className={styles.proTipTitle}>
|
||||
Maintained outside this repository
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.proTipBody}>
|
||||
<p className={styles.proTipLead}>
|
||||
{item.author?.name
|
||||
? `${item.name} is published by ${item.author.name}`
|
||||
: `${item.name} is published by a third party`}
|
||||
{item.source?.repo ? ` from ${item.source.repo}` : ""}
|
||||
{item.source?.sha || item.source?.ref
|
||||
? `, pinned to ${item.source.sha ?? item.source.ref}`
|
||||
: ""}
|
||||
. Review the source before installing.
|
||||
</p>
|
||||
<p>
|
||||
<a className={styles.proTipLink} href={githubUrl}>
|
||||
View the source repository
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{hasContents ? (
|
||||
<section className={styles.articleSection} id="plugin-contents">
|
||||
<Heading as="h2" size="5" className={styles.articleHeading}>
|
||||
What’s included
|
||||
</Heading>
|
||||
<Box className={gridStyles.gridFrame} data-mode={colorMode}>
|
||||
<Box className={gridStyles.gridContent}>
|
||||
<Grid
|
||||
className={gridStyles.threeUp}
|
||||
columnGap="none"
|
||||
rowGap="none"
|
||||
enableGutters={false}
|
||||
>
|
||||
{includedItems.map((included, index) => {
|
||||
const title = itemTitle(included);
|
||||
return (
|
||||
<Grid.Column
|
||||
key={`${included.kind}-${included.path ?? title}-${index}`}
|
||||
span={{ xsmall: 12, medium: 6, large: 6 }}
|
||||
className={gridStyles.col}
|
||||
>
|
||||
<Box
|
||||
className={clsx(
|
||||
gridStyles.item,
|
||||
included.detailUrl && gridStyles.itemHover,
|
||||
)}
|
||||
>
|
||||
<Card
|
||||
href={
|
||||
included.detailUrl
|
||||
? pageHref(included.detailUrl)
|
||||
: githubUrl
|
||||
}
|
||||
fullWidth
|
||||
ctaVariant="none"
|
||||
backgroundColor="none"
|
||||
className={gridStyles.card}
|
||||
>
|
||||
<Card.Heading as="h3">{title}</Card.Heading>
|
||||
<Card.Description>
|
||||
<span className={gridStyles.cardDescText}>
|
||||
{included.path ?? kindLabel(included.kind)}
|
||||
</span>
|
||||
</Card.Description>
|
||||
</Card>
|
||||
<div className={gridStyles.cardMeta}>
|
||||
<Token>{kindLabel(included.kind)}</Token>
|
||||
</div>
|
||||
</Box>
|
||||
</Grid.Column>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{markdownHtml ? (
|
||||
<section className={styles.articleSection}>
|
||||
<div dangerouslySetInnerHTML={{ __html: markdownHtml }} />
|
||||
</section>
|
||||
) : (
|
||||
<section className={styles.articleSection}>
|
||||
<Text as="p" variant="muted">
|
||||
This plugin does not ship a README in this repository.
|
||||
{item.external ? " Documentation lives in its source repo." : ""}
|
||||
</Text>
|
||||
</section>
|
||||
)}
|
||||
</DetailChassis>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import { ChevronDownIcon, MarkGithubIcon } from "@primer/octicons-react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CTABanner,
|
||||
Card,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
Grid,
|
||||
Heading,
|
||||
Pagination,
|
||||
Section,
|
||||
Stack,
|
||||
Text,
|
||||
useTheme,
|
||||
} from "@primer/react-brand";
|
||||
import { clsx } from "clsx";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { externalRepoUrl, type ExternalSource } from "../../lib/external-source";
|
||||
import { PageShell } from "./PageShell";
|
||||
import { PluginsIcon } from "./PluginsIcon";
|
||||
import { toggleValue } from "./catalogFilters";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import styles from "./styles/plugins.module.css";
|
||||
|
||||
const CONTRIBUTE_URL =
|
||||
"https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md";
|
||||
|
||||
const REQUEST_URL = "https://github.com/github/awesome-copilot/issues/new";
|
||||
|
||||
const GITHUB_TREE = "https://github.com/github/awesome-copilot/tree/main";
|
||||
|
||||
type SortMode = "az" | "newest";
|
||||
|
||||
/** A plugin record as emitted into public/data/plugins.json. */
|
||||
export type PluginItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
version?: string | null;
|
||||
tags?: string[];
|
||||
itemCount: number;
|
||||
lastUpdated?: string;
|
||||
external?: boolean;
|
||||
repository?: string | null;
|
||||
homepage?: string | null;
|
||||
author?: { name: string; url?: string } | null;
|
||||
source?: ExternalSource | null;
|
||||
};
|
||||
|
||||
const sourceUrlOf = (plugin: PluginItem) =>
|
||||
plugin.external
|
||||
? externalRepoUrl(plugin.source, [
|
||||
plugin.repository,
|
||||
plugin.homepage,
|
||||
GITHUB_TREE,
|
||||
])
|
||||
: `${GITHUB_TREE}/${plugin.path}`;
|
||||
|
||||
const sizeBuckets: { label: string; test: (n: number) => boolean }[] = [
|
||||
{ label: "Single item", test: (n) => n === 1 },
|
||||
{ label: "2–4 items", test: (n) => n >= 2 && n <= 4 },
|
||||
{ label: "5+ items", test: (n) => n >= 5 },
|
||||
];
|
||||
|
||||
type FilterGroupId = "source" | "category" | "size";
|
||||
|
||||
type FilterState = Record<FilterGroupId, string[]>;
|
||||
|
||||
const emptyFilters: FilterState = { source: [], category: [], size: [] };
|
||||
|
||||
const sourceOf = (plugin: PluginItem) =>
|
||||
plugin.external ? "External" : "Built-in";
|
||||
|
||||
const sizeLabelOf = (plugin: PluginItem) =>
|
||||
sizeBuckets.find((bucket) => bucket.test(plugin.itemCount))?.label ?? null;
|
||||
|
||||
/**
|
||||
* The prototype hardcoded twelve category options. Real data carries 585 tags,
|
||||
* so the category group keeps the same shape by taking the most-used tags that
|
||||
* the build-time filter list offers.
|
||||
*/
|
||||
const CATEGORY_LIMIT = 12;
|
||||
|
||||
function categoryOptions(plugins: PluginItem[], tags: string[]): string[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const plugin of plugins) {
|
||||
for (const tag of plugin.tags ?? []) {
|
||||
counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return tags
|
||||
.filter((tag) => counts.has(tag))
|
||||
.sort((a, b) => (counts.get(b) ?? 0) - (counts.get(a) ?? 0) || a.localeCompare(b))
|
||||
.slice(0, CATEGORY_LIMIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Plugins catalog, ported from the design prototype's `plugins.tsx`. The
|
||||
* prototype's hardcoded array is replaced by build-time data; the layout,
|
||||
* filters, and interactions are unchanged.
|
||||
*/
|
||||
export function PluginsCatalog({
|
||||
plugins,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
tags = [],
|
||||
}: {
|
||||
plugins: PluginItem[];
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
tags?: string[];
|
||||
}) {
|
||||
const { colorMode } = useTheme();
|
||||
const [sortMode] = useState<SortMode>("az");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
|
||||
const [filters, setFilters] = useState<FilterState>(emptyFilters);
|
||||
|
||||
const filterGroups = useMemo(
|
||||
(): { id: FilterGroupId; label: string; options: string[] }[] => [
|
||||
{ id: "source", label: "Source", options: ["Built-in", "External"] },
|
||||
{
|
||||
id: "category",
|
||||
label: "Category",
|
||||
options: categoryOptions(plugins, tags),
|
||||
},
|
||||
{ id: "size", label: "Contents", options: sizeBuckets.map((b) => b.label) },
|
||||
],
|
||||
[plugins, tags],
|
||||
);
|
||||
|
||||
const sortedPlugins = useMemo(() => {
|
||||
const copy = plugins.filter((plugin) => {
|
||||
const sourceOk =
|
||||
filters.source.length === 0 || filters.source.includes(sourceOf(plugin));
|
||||
const categoryOk =
|
||||
filters.category.length === 0 ||
|
||||
filters.category.some((tag) => (plugin.tags ?? []).includes(tag));
|
||||
const size = sizeLabelOf(plugin);
|
||||
const sizeOk =
|
||||
filters.size.length === 0 ||
|
||||
(size !== null && filters.size.includes(size));
|
||||
return sourceOk && categoryOk && sizeOk;
|
||||
});
|
||||
if (sortMode === "az") {
|
||||
copy.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
return copy;
|
||||
}, [plugins, sortMode, filters]);
|
||||
|
||||
const toggleFilter = (groupId: FilterGroupId, option: string) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
[groupId]: toggleValue(prev[groupId], option),
|
||||
}));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters(emptyFilters);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const activeFilterCount =
|
||||
filters.source.length + filters.category.length + filters.size.length;
|
||||
const hasActiveFilters = activeFilterCount > 0;
|
||||
|
||||
const pageSize = 6;
|
||||
const pageCount = Math.max(1, Math.ceil(sortedPlugins.length / pageSize));
|
||||
const page = Math.min(currentPage, pageCount);
|
||||
const visiblePlugins = sortedPlugins.slice(
|
||||
(page - 1) * pageSize,
|
||||
page * pageSize,
|
||||
);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
styles={styles}
|
||||
currentPage="plugins"
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
searchAriaLabel="Search plugins"
|
||||
>
|
||||
<Box className={styles.hero}>
|
||||
<div className={styles.heroInner}>
|
||||
<span className={styles.heroIcon} aria-hidden="true">
|
||||
<PluginsIcon size={36} />
|
||||
</span>
|
||||
<Heading as="h1" size="3" className={styles.heroHeading}>
|
||||
Plugins
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted" className={styles.heroText}>
|
||||
Curated plugins of agents, hooks, and skills for specific workflows
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Section id="catalog" paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box className={styles.catalog}>
|
||||
<aside className={styles.filterNav} aria-label="Filter plugins">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.filterToggle}
|
||||
aria-expanded={mobileFiltersOpen}
|
||||
onClick={() => setMobileFiltersOpen((open) => !open)}
|
||||
>
|
||||
<span>
|
||||
Filters{hasActiveFilters ? ` (${activeFilterCount})` : ""}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
size={16}
|
||||
className={clsx(
|
||||
styles.filterToggleChevron,
|
||||
mobileFiltersOpen && styles.filterToggleChevronOpen,
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.filterBody,
|
||||
!mobileFiltersOpen && styles.filterBodyCollapsed,
|
||||
)}
|
||||
>
|
||||
{filterGroups.map((group) => (
|
||||
<div className={styles.filterGroup} key={group.id}>
|
||||
<Text as="h2" size="100" className={styles.filterHeading}>
|
||||
{group.label}
|
||||
</Text>
|
||||
<div className={styles.filterOptions}>
|
||||
{group.options.map((option) => (
|
||||
<div className={styles.filterOption} key={option}>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={filters[group.id].includes(option)}
|
||||
onChange={() => toggleFilter(group.id, option)}
|
||||
/>
|
||||
<FormControl.Label>{option}</FormControl.Label>
|
||||
</FormControl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{hasActiveFilters ? (
|
||||
<div className={styles.filterActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
hasArrow={false}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear all ({activeFilterCount})
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Box className={styles.catalogMain}>
|
||||
<Box className={styles.accessPanel} data-mode={colorMode}>
|
||||
<p className={styles.accessTitle}>Access from tools</p>
|
||||
<ul className={styles.accessList}>
|
||||
<li className={styles.accessItem}>
|
||||
<Text as="span" size="200" className={styles.accessDesc}>
|
||||
<span className={styles.accessLabel}>GitHub Copilot CLI</span>
|
||||
{" — Type "}
|
||||
<code className={styles.accessCode}>
|
||||
/plugin marketplace browse awesome-copilot
|
||||
</code>{" "}
|
||||
in a Copilot session
|
||||
</Text>
|
||||
</li>
|
||||
<li className={styles.accessItem}>
|
||||
<Text as="span" size="200" className={styles.accessDesc}>
|
||||
<span className={styles.accessLabel}>VS Code</span>
|
||||
{" — Type "}
|
||||
<code className={styles.accessCode}>@agentPlugins</code> in the
|
||||
Extensions search view, or run{" "}
|
||||
<code className={styles.accessCode}>Chat: Plugins</code>
|
||||
</Text>
|
||||
</li>
|
||||
<li className={styles.accessItem}>
|
||||
<Text as="span" size="200" className={styles.accessDesc}>
|
||||
<span className={styles.accessLabel}>Install</span>
|
||||
{" — "}
|
||||
<code className={styles.accessCode}>
|
||||
copilot plugin install <plugin-name>@awesome-copilot
|
||||
</code>
|
||||
</Text>
|
||||
</li>
|
||||
</ul>
|
||||
</Box>
|
||||
|
||||
<Box className={styles.gridFrame} data-mode={colorMode}>
|
||||
<Box className={styles.gridContent}>
|
||||
<Grid
|
||||
className={styles.threeUp}
|
||||
columnGap="none"
|
||||
rowGap="none"
|
||||
enableGutters={false}
|
||||
>
|
||||
{visiblePlugins.map((plugin) => (
|
||||
<Grid.Column
|
||||
key={plugin.id}
|
||||
span={{ xsmall: 12, medium: 6, large: 6 }}
|
||||
className={styles.col}
|
||||
>
|
||||
<Box className={clsx(styles.item, styles.itemHover)}>
|
||||
<Card
|
||||
href={pageHref(`plugin/${plugin.id}`)}
|
||||
fullWidth
|
||||
ctaVariant="none"
|
||||
backgroundColor="none"
|
||||
className={styles.card}
|
||||
>
|
||||
<Card.Heading as="h2">{plugin.name}</Card.Heading>
|
||||
<Card.Description>
|
||||
<span className={styles.cardDescText}>
|
||||
{plugin.description}
|
||||
</span>
|
||||
</Card.Description>
|
||||
</Card>
|
||||
<div className={styles.cardActions}>
|
||||
<Button
|
||||
as="a"
|
||||
href={sourceUrlOf(plugin)}
|
||||
variant="primary"
|
||||
leadingVisual={MarkGithubIcon}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View source
|
||||
</Button>
|
||||
</div>
|
||||
{plugin.author?.name ? (
|
||||
<Text
|
||||
as="p"
|
||||
size="100"
|
||||
className={clsx(
|
||||
styles.cardBadge,
|
||||
styles.cardBadgeAuthor,
|
||||
)}
|
||||
>
|
||||
{plugin.author.name}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
</Grid.Column>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{sortedPlugins.length === 0 ? (
|
||||
<Box className={styles.emptyState}>
|
||||
<Text as="p" variant="muted">
|
||||
No plugins match the selected filters.
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
<div className={styles.rule} aria-hidden="true" />
|
||||
|
||||
<Box className={styles.paginationRow}>
|
||||
<Stack direction="horizontal" justifyContent="center" padding="none">
|
||||
<Pagination
|
||||
className={styles.pagination}
|
||||
pageCount={pageCount}
|
||||
currentPage={page}
|
||||
onPageChange={(e, n) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(n);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<div className={styles.rule} aria-hidden="true" />
|
||||
|
||||
<Box className={styles.ctaFrame}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box className={styles.ctaFrameInner}>
|
||||
<CTABanner align="center" backgroundColor="subtle">
|
||||
<CTABanner.Heading>
|
||||
Can’t find the plugin you need?
|
||||
</CTABanner.Heading>
|
||||
<CTABanner.Description>
|
||||
This marketplace is community-built. Share a plugin you use
|
||||
<br />
|
||||
—or request one—to help developers ship faster with
|
||||
GitHub Copilot.
|
||||
</CTABanner.Description>
|
||||
<CTABanner.ButtonGroup>
|
||||
<Button as="a" href={CONTRIBUTE_URL}>
|
||||
Submit a plugin
|
||||
</Button>
|
||||
<Button as="a" href={REQUEST_URL} variant="secondary">
|
||||
Request a plugin
|
||||
</Button>
|
||||
</CTABanner.ButtonGroup>
|
||||
</CTABanner>
|
||||
</Box>
|
||||
</Section>
|
||||
</Box>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
type PluginsIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export function PluginsIcon({ size = 36 }: PluginsIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 96 96"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Plugins"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M26.6614 44V59C26.6614 67.8366 33.8248 75 42.6614 75H51.6614"
|
||||
stroke="var(--brand-color-text-default)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M60.3728 52V54H86.3728V52V50H60.3728V52ZM94.3728 60H92.3728V86H94.3728H96.3728V60H94.3728ZM86.3728 94V92H60.3728V94V96H86.3728V94ZM52.3728 86H54.3728V60H52.3728H50.3728V86H52.3728ZM60.3728 94V92C57.0591 92 54.3728 89.3137 54.3728 86H52.3728H50.3728C50.3728 91.5228 54.85 96 60.3728 96V94ZM94.3728 86H92.3728C92.3728 89.3137 89.6865 92 86.3728 92V94V96C91.8956 96 96.3728 91.5228 96.3728 86H94.3728ZM86.3728 52V54C89.6865 54 92.3728 56.6863 92.3728 60H94.3728H96.3728C96.3728 54.4772 91.8956 50 86.3728 50V52ZM60.3728 52V50C54.85 50 50.3728 54.4772 50.3728 60H52.3728H54.3728C54.3728 56.6863 57.0591 54 60.3728 54V52Z"
|
||||
fill="var(--brand-color-text-default)"
|
||||
/>
|
||||
<rect
|
||||
x="18.0001"
|
||||
y="35.9998"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="9"
|
||||
transform="rotate(-90 18.0001 35.9998)"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
<rect
|
||||
x="36.0001"
|
||||
y="35.9998"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="9"
|
||||
transform="rotate(-90 36.0001 35.9998)"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
<rect
|
||||
x="36.0001"
|
||||
y="18"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="9"
|
||||
transform="rotate(180 36.0001 18)"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
<rect
|
||||
x="54.0001"
|
||||
y="18"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="9"
|
||||
transform="rotate(180 54.0001 18)"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
<rect
|
||||
x="6.10352e-05"
|
||||
y="18"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="9"
|
||||
transform="rotate(-90 6.10352e-05 18)"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
<rect
|
||||
x="18.0001"
|
||||
y="36"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="9"
|
||||
transform="rotate(180 18.0001 36)"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { MarkGithubIcon } from "@primer/octicons-react";
|
||||
import { Link } from "@primer/react-brand";
|
||||
|
||||
import styles from "./styles/dotnet-upgrade.module.css";
|
||||
|
||||
export type ResourceMetaProps = {
|
||||
/** Kicker above the panel, e.g. "Agent details". */
|
||||
kicker: string;
|
||||
/** Chip groups: models, tools, applies-to globs, … */
|
||||
groups?: { label: string; items: string[] }[];
|
||||
/** Preformatted date string (see `formatLastUpdated`). */
|
||||
lastUpdated?: string | null;
|
||||
/** GitHub blob URL for the resource's source file. */
|
||||
sourceUrl?: string;
|
||||
/** Contributed-by attribution, when the resource carries one. */
|
||||
author?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sidebar panel listing a resource's metadata chips alongside the
|
||||
* author / last-updated / view-source affordances shared by every detail route.
|
||||
*/
|
||||
export function ResourceMeta({
|
||||
kicker,
|
||||
groups = [],
|
||||
lastUpdated,
|
||||
sourceUrl,
|
||||
author,
|
||||
}: ResourceMetaProps) {
|
||||
const visibleGroups = groups.filter((group) => group.items.length > 0);
|
||||
if (
|
||||
visibleGroups.length === 0 &&
|
||||
!lastUpdated &&
|
||||
!sourceUrl &&
|
||||
!author
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.sidebarSection}>
|
||||
<div className={styles.sidebarSummary}>
|
||||
<span className={styles.sidebarKicker}>{kicker}</span>
|
||||
</div>
|
||||
<div className={styles.sidebarSectionBody}>
|
||||
{visibleGroups.map((group) => (
|
||||
<div key={group.label} className={styles.metaGroup}>
|
||||
<span className={styles.metaLabel}>{group.label}</span>
|
||||
<div className={styles.metaValues}>
|
||||
{group.items.map((item) => (
|
||||
<span key={item} className={styles.metaChip}>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{author ? (
|
||||
<div className={styles.metaGroup}>
|
||||
<span className={styles.metaLabel}>Author</span>
|
||||
<div className={styles.metaValues}>
|
||||
<span className={styles.metaChip}>{author}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{lastUpdated ? (
|
||||
<div className={styles.metaGroup}>
|
||||
<span className={styles.metaLabel}>Last updated</span>
|
||||
<div className={styles.metaValues}>
|
||||
<span className={styles.metaChip}>{lastUpdated}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{sourceUrl ? (
|
||||
<div className={styles.metaGroup}>
|
||||
<span className={styles.metaLabel}>Source</span>
|
||||
<div className={styles.metaValues}>
|
||||
<Link href={sourceUrl} size="small">
|
||||
<MarkGithubIcon size={16} /> View on GitHub
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { clsx } from "clsx";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
|
||||
import styles from "./styles/learning-hub-copilot-app.module.css";
|
||||
|
||||
const DEFAULT_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!<>-_\\/[]{}=+*^?#";
|
||||
|
||||
type ScrambleCursor = "persist" | "fade" | "none";
|
||||
type ScrambleTrigger = "view" | "hover";
|
||||
|
||||
type ScrambleTextProps = {
|
||||
/** Final, resolved text that also serves as the accessible label. */
|
||||
text: string;
|
||||
/** Optional class applied to the wrapper. */
|
||||
className?: string;
|
||||
/** Character pool used while decoding. */
|
||||
chars?: string;
|
||||
/** Milliseconds each character stays scrambled before it locks in. */
|
||||
msPerChar?: number;
|
||||
/** When the decode plays: once on scroll into view, or on each hover. */
|
||||
trigger?: ScrambleTrigger;
|
||||
/**
|
||||
* Green square behaviour: rest just after the last letter ("persist"), fade
|
||||
* out once the word resolves ("fade"), or never render one ("none").
|
||||
*/
|
||||
cursor?: ScrambleCursor;
|
||||
};
|
||||
/**
|
||||
* GitHub Universe style "decode" reveal: the word starts as green random
|
||||
* glyphs, then resolves one letter at a time from left to right, the letters
|
||||
* ahead scrambling in green. An optional green square cursor can step along and
|
||||
* either rest at the end or fade out. Plays once on scroll into view or on each
|
||||
* hover, and honours prefers-reduced-motion.
|
||||
*/
|
||||
export function ScrambleText({
|
||||
text,
|
||||
className,
|
||||
chars = DEFAULT_CHARS,
|
||||
msPerChar = 55,
|
||||
trigger = "view",
|
||||
cursor = "persist",
|
||||
}: ScrambleTextProps) {
|
||||
const charsRef = useRef<HTMLSpanElement>(null);
|
||||
const cursorRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const charsEl = charsRef.current;
|
||||
if (!charsEl) return;
|
||||
const cursorEl = cursor === "none" ? null : cursorRef.current;
|
||||
|
||||
const spans = Array.from(
|
||||
charsEl.querySelectorAll<HTMLElement>("[data-final]"),
|
||||
);
|
||||
const randomChar = () => chars[Math.floor(Math.random() * chars.length)];
|
||||
const isSpace = (span: HTMLElement) => (span.dataset.final ?? "") === " ";
|
||||
const glyphClass = styles.scrambleGlyph;
|
||||
|
||||
// Lock each character box to its final width so scrambling never reflows.
|
||||
const metrics = spans.map((span) => ({
|
||||
left: span.offsetLeft,
|
||||
top: span.offsetTop,
|
||||
width: span.offsetWidth,
|
||||
height: span.offsetHeight,
|
||||
}));
|
||||
spans.forEach((span, i) => {
|
||||
span.style.width = `${metrics[i].width}px`;
|
||||
});
|
||||
|
||||
// Size the cursor and rest it just past the last glyph. On phones the
|
||||
// placeholder wraps onto two lines, so the square grows to the full line
|
||||
// height and follows the active line vertically; on wider screens it stays
|
||||
// a small square centred on the single line.
|
||||
const fontSize = parseFloat(getComputedStyle(charsEl).fontSize) || 16;
|
||||
const isNarrow =
|
||||
window.matchMedia?.("(max-width: 47.99rem)").matches ?? false;
|
||||
const side = fontSize * 0.62;
|
||||
const gap = fontSize * 0.18;
|
||||
const cursorH = isNarrow ? (metrics[0]?.height ?? fontSize) : side;
|
||||
const cursorY = (i: number) => {
|
||||
const m = metrics[i] ?? metrics[metrics.length - 1];
|
||||
return m ? charsEl.offsetTop + m.top + (m.height - cursorH) / 2 : 0;
|
||||
};
|
||||
const lastMetric = metrics[metrics.length - 1];
|
||||
const restX = (lastMetric ? lastMetric.left + lastMetric.width : 0) + gap;
|
||||
const restY = cursorY(metrics.length - 1);
|
||||
if (cursorEl) {
|
||||
cursorEl.style.width = `${side}px`;
|
||||
cursorEl.style.height = `${cursorH}px`;
|
||||
cursorEl.style.top = "0px";
|
||||
cursorEl.style.transform = `translate(${restX}px, ${restY}px)`;
|
||||
// Glide the square between letters instead of snapping, synced to the step.
|
||||
cursorEl.style.transition = `transform ${msPerChar}ms linear, opacity 150ms ease`;
|
||||
}
|
||||
|
||||
const finalize = () => {
|
||||
for (const span of spans) {
|
||||
span.textContent = span.dataset.final ?? "";
|
||||
span.classList.remove(glyphClass);
|
||||
}
|
||||
if (cursorEl) {
|
||||
if (cursor === "persist") {
|
||||
// Keep the green square resting after the final character.
|
||||
cursorEl.style.transform = `translate(${restX}px, ${restY}px)`;
|
||||
cursorEl.style.opacity = "1";
|
||||
} else {
|
||||
// "fade": the square disappears once the word has resolved.
|
||||
cursorEl.style.opacity = "0";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
|
||||
finalize();
|
||||
return;
|
||||
}
|
||||
|
||||
// Only real letters get a reveal step (spaces resolve for free).
|
||||
const revealOrder = spans
|
||||
.map((span, i) => (isSpace(span) ? -1 : i))
|
||||
.filter((i) => i >= 0);
|
||||
|
||||
const setScrambled = () => {
|
||||
for (const span of spans) {
|
||||
if (isSpace(span)) {
|
||||
span.textContent = " ";
|
||||
continue;
|
||||
}
|
||||
span.textContent = randomChar();
|
||||
span.classList.add(glyphClass);
|
||||
}
|
||||
};
|
||||
|
||||
let raf = 0;
|
||||
let startTime = 0;
|
||||
|
||||
const tick = (now: number) => {
|
||||
if (!startTime) startTime = now;
|
||||
const step = Math.floor((now - startTime) / msPerChar);
|
||||
|
||||
if (step >= revealOrder.length) {
|
||||
finalize();
|
||||
return;
|
||||
}
|
||||
|
||||
const revealIdx = revealOrder[step];
|
||||
|
||||
spans.forEach((span, i) => {
|
||||
if (isSpace(span)) {
|
||||
span.textContent = " ";
|
||||
return;
|
||||
}
|
||||
if (i < revealIdx) {
|
||||
span.textContent = span.dataset.final ?? "";
|
||||
span.classList.remove(glyphClass);
|
||||
} else {
|
||||
if (Math.random() < 0.5) span.textContent = randomChar();
|
||||
span.classList.add(glyphClass);
|
||||
}
|
||||
});
|
||||
|
||||
// Park the square at the boundary between resolved and scrambling text,
|
||||
// following the active line so it stays aligned when the phrase wraps.
|
||||
if (cursorEl) {
|
||||
cursorEl.style.transform = `translate(${metrics[revealIdx].left}px, ${cursorY(revealIdx)}px)`;
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
const play = () => {
|
||||
cancelAnimationFrame(raf);
|
||||
setScrambled();
|
||||
if (cursorEl) cursorEl.style.opacity = "1";
|
||||
startTime = 0;
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
if (trigger === "hover") {
|
||||
// Rest as the finished word; decode again on every hover.
|
||||
const onEnter = () => play();
|
||||
charsEl.addEventListener("mouseenter", onEnter);
|
||||
return () => {
|
||||
charsEl.removeEventListener("mouseenter", onEnter);
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}
|
||||
|
||||
// trigger === "view": start scrambled so the final text never flashes,
|
||||
// then decode once when it scrolls into view.
|
||||
setScrambled();
|
||||
let played = false;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting && !played) {
|
||||
played = true;
|
||||
play();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0.6 },
|
||||
);
|
||||
observer.observe(charsEl);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [text, chars, msPerChar, trigger, cursor]);
|
||||
|
||||
// Group characters into non-breaking word spans (with the spaces between them
|
||||
// left as breakable boxes) so that when the phrase wraps it only ever breaks
|
||||
// between words, never mid-word. The animation still drives each character
|
||||
// span individually via the [data-final] lookup above.
|
||||
const charSpan = (ch: string, i: number) => (
|
||||
<span key={`c-${i}`} className={styles.scrambleChar} data-final={ch}>
|
||||
{ch}
|
||||
</span>
|
||||
);
|
||||
|
||||
const nodes: React.ReactNode[] = [];
|
||||
let word: React.ReactNode[] = [];
|
||||
let wordStart = 0;
|
||||
const flushWord = () => {
|
||||
if (word.length) {
|
||||
nodes.push(
|
||||
<span key={`w-${wordStart}`} className={styles.scrambleWord}>
|
||||
{word}
|
||||
</span>,
|
||||
);
|
||||
word = [];
|
||||
}
|
||||
};
|
||||
Array.from(text).forEach((ch, i) => {
|
||||
if (ch === " ") {
|
||||
flushWord();
|
||||
nodes.push(charSpan(ch, i));
|
||||
} else {
|
||||
if (word.length === 0) wordStart = i;
|
||||
word.push(charSpan(ch, i));
|
||||
}
|
||||
});
|
||||
flushWord();
|
||||
|
||||
return (
|
||||
<span className={clsx(styles.scrambleWrap, className)}>
|
||||
<span className={styles.scrambleSrOnly}>{text}</span>
|
||||
<span ref={charsRef} aria-hidden="true" className={styles.scrambleChars}>
|
||||
{nodes}
|
||||
</span>
|
||||
{cursor !== "none" && (
|
||||
<span
|
||||
ref={cursorRef}
|
||||
aria-hidden="true"
|
||||
className={styles.scrambleCursor}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
FileDirectoryIcon,
|
||||
MarkGithubIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { clsx } from "clsx";
|
||||
import React from "react";
|
||||
|
||||
import { Button, Text } from "@primer/react-brand";
|
||||
|
||||
import {
|
||||
DetailChassis,
|
||||
type DetailSibling,
|
||||
type DetailTocItem,
|
||||
} from "./DetailChassis";
|
||||
import { ResourceMeta } from "./ResourceMeta";
|
||||
import {
|
||||
SyntaxHighlightedCode,
|
||||
detectCodeLanguage,
|
||||
type CodeLanguage,
|
||||
} from "./SyntaxHighlightedCode";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import styles from "./styles/dotnet-upgrade.module.css";
|
||||
import fileStyles from "./styles/skill-files.module.css";
|
||||
|
||||
export type SkillFile = {
|
||||
/** Repo-relative path, e.g. `skills/my-skill/scripts/scan.py`. */
|
||||
path: string;
|
||||
/** Path relative to the skill folder, e.g. `scripts/scan.py`. */
|
||||
name: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export type SkillDetailItem = {
|
||||
id: string;
|
||||
name?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
skillFile: string;
|
||||
files?: SkillFile[];
|
||||
assets?: string[];
|
||||
hasAssets?: boolean;
|
||||
assetCount?: number;
|
||||
};
|
||||
|
||||
export type SkillDetailProps = {
|
||||
item: SkillDetailItem;
|
||||
/** Rendered, sanitized SKILL.md body with heading ids already stamped. */
|
||||
markdownHtml: string;
|
||||
toc: DetailTocItem[];
|
||||
/** GitHub tree URL for the skill folder. */
|
||||
githubUrl: string;
|
||||
/** `https://github.com/.../blob/main` — per-file links are built from this. */
|
||||
githubBlobBase: string;
|
||||
/** `https://raw.githubusercontent.com/.../main` — lazy file fetches. */
|
||||
rawBase: string;
|
||||
/** `gh skills install …` — skills have no VS Code install URI. */
|
||||
installCommand: string;
|
||||
rawMarkdown: string;
|
||||
lastUpdated?: string | null;
|
||||
previous?: DetailSibling;
|
||||
next?: DetailSibling;
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
};
|
||||
|
||||
type FileKind = "markdown" | "code" | "image" | "text";
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"webp",
|
||||
"svg",
|
||||
"avif",
|
||||
]);
|
||||
|
||||
const CODE_LANGUAGES: Record<string, CodeLanguage> = {
|
||||
json: "json",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
ts: "tsx",
|
||||
tsx: "tsx",
|
||||
js: "tsx",
|
||||
jsx: "tsx",
|
||||
mjs: "tsx",
|
||||
cjs: "tsx",
|
||||
cs: "tsx",
|
||||
py: "bash",
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
ps1: "bash",
|
||||
html: "markup",
|
||||
xml: "markup",
|
||||
svg: "markup",
|
||||
csproj: "markup",
|
||||
props: "markup",
|
||||
targets: "markup",
|
||||
bicep: "bash",
|
||||
};
|
||||
|
||||
function extensionOf(path: string): string {
|
||||
return path.split(".").pop()?.toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function kindOf(path: string): FileKind {
|
||||
const ext = extensionOf(path);
|
||||
if (IMAGE_EXTENSIONS.has(ext) && ext !== "svg") return "image";
|
||||
if (ext === "md" || ext === "markdown") return "markdown";
|
||||
if (ext in CODE_LANGUAGES) return "code";
|
||||
return "text";
|
||||
}
|
||||
|
||||
function formatSize(bytes?: number): string | null {
|
||||
if (!bytes && bytes !== 0) return null;
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function encodeRepoPath(filePath: string): string {
|
||||
return filePath.split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill detail route.
|
||||
*
|
||||
* A skill is a folder, not a file, so the content region is a file switcher
|
||||
* rather than a single article: `SKILL.md` is server-rendered into the page and
|
||||
* every bundled asset is lazily fetched from raw.githubusercontent.com on first
|
||||
* selection, then cached. Markdown assets render as rich markdown, recognised
|
||||
* source files are syntax highlighted with the prototype's highlighter, images
|
||||
* render inline, and anything else falls back to a monospace block.
|
||||
*
|
||||
* The `#file=<path>` deep-link convention from the previous file browser is
|
||||
* preserved so existing shared links keep working.
|
||||
*/
|
||||
export function SkillDetail({
|
||||
item,
|
||||
markdownHtml,
|
||||
toc,
|
||||
githubUrl,
|
||||
githubBlobBase,
|
||||
rawBase,
|
||||
installCommand,
|
||||
rawMarkdown,
|
||||
lastUpdated,
|
||||
previous,
|
||||
next,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
}: SkillDetailProps) {
|
||||
const primaryPath = item.skillFile;
|
||||
const primaryName = primaryPath.split("/").pop() ?? "SKILL.md";
|
||||
|
||||
// SKILL.md always leads; the remaining bundle files follow in data order.
|
||||
const files = React.useMemo<SkillFile[]>(() => {
|
||||
const all = item.files ?? [];
|
||||
const primary = all.find((file) => file.path === primaryPath) ?? {
|
||||
path: primaryPath,
|
||||
name: primaryName,
|
||||
};
|
||||
return [primary, ...all.filter((file) => file.path !== primaryPath)];
|
||||
}, [item.files, primaryPath, primaryName]);
|
||||
|
||||
const [activePath, setActivePath] = React.useState(primaryPath);
|
||||
const [contents, setContents] = React.useState<Record<string, string>>({});
|
||||
const [status, setStatus] = React.useState<"idle" | "loading" | "error">(
|
||||
"idle",
|
||||
);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const active =
|
||||
files.find((file) => file.path === activePath) ?? files[0] ?? null;
|
||||
const isPrimary = active?.path === primaryPath;
|
||||
const kind = active ? kindOf(active.path) : "text";
|
||||
|
||||
// Honour `#file=<path>` on load and on later hash navigation.
|
||||
React.useEffect(() => {
|
||||
const fromHash = () => {
|
||||
const match = /^#file=(.+)$/.exec(window.location.hash);
|
||||
if (!match) return;
|
||||
let wanted: string | undefined;
|
||||
try {
|
||||
wanted = decodeURIComponent(match[1]);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (files.some((file) => file.path === wanted)) setActivePath(wanted);
|
||||
};
|
||||
fromHash();
|
||||
window.addEventListener("hashchange", fromHash);
|
||||
return () => window.removeEventListener("hashchange", fromHash);
|
||||
}, [files]);
|
||||
|
||||
// Lazily fetch the selected asset. SKILL.md and images never need a fetch.
|
||||
React.useEffect(() => {
|
||||
if (!active || isPrimary || kind === "image") return;
|
||||
if (contents[active.path] !== undefined) {
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setStatus("loading");
|
||||
fetch(`${rawBase}/${encodeRepoPath(active.path)}`)
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(String(response.status));
|
||||
return response.text();
|
||||
})
|
||||
.then((text) => {
|
||||
if (cancelled) return;
|
||||
setContents((current) => ({ ...current, [active.path]: text }));
|
||||
setStatus("idle");
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStatus("error");
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active, isPrimary, kind, rawBase, contents]);
|
||||
|
||||
const selectFile = React.useCallback((path: string) => {
|
||||
setActivePath(path);
|
||||
const hash = `#file=${encodeURIComponent(path)}`;
|
||||
if (window.location.hash !== hash) history.replaceState(null, "", hash);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!copied) return;
|
||||
const timer = window.setTimeout(() => setCopied(false), 2000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [copied]);
|
||||
|
||||
const handleCopyInstall = React.useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(installCommand);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
}, [installCommand]);
|
||||
|
||||
const activeGithubUrl = active
|
||||
? `${githubBlobBase}/${encodeRepoPath(active.path)}`
|
||||
: githubUrl;
|
||||
const activeRawUrl = active
|
||||
? `${rawBase}/${encodeRepoPath(active.path)}`
|
||||
: undefined;
|
||||
|
||||
const install = (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCopyInstall}
|
||||
leadingVisual={copied ? CheckIcon : CopyIcon}
|
||||
>
|
||||
{copied ? "Copied" : "Copy install command"}
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href={githubUrl}
|
||||
variant="secondary"
|
||||
className={styles.iconButton}
|
||||
aria-label={`View the ${item.title} skill on GitHub`}
|
||||
>
|
||||
<MarkGithubIcon size={16} />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
const fileCount = files.length;
|
||||
const assetCount = item.assetCount ?? Math.max(0, fileCount - 1);
|
||||
|
||||
return (
|
||||
<DetailChassis
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
breadcrumbs={[
|
||||
{ label: "Skills", href: pageHref("skills") },
|
||||
{ label: item.title },
|
||||
]}
|
||||
install={install}
|
||||
heroExtras={
|
||||
<div className={styles.codeBlockWrap}>
|
||||
<span className={styles.codeLabel}>Install with the GitHub CLI</span>
|
||||
<SyntaxHighlightedCode
|
||||
className={styles.codeBlock}
|
||||
code={installCommand}
|
||||
language="bash"
|
||||
lineClassName={styles.codeLine}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/* Anchors only exist while SKILL.md is on screen. */
|
||||
toc={isPrimary ? toc : []}
|
||||
sidebar={
|
||||
<ResourceMeta
|
||||
kicker="Skill details"
|
||||
groups={[
|
||||
{
|
||||
label: "Files",
|
||||
items: [`${fileCount} file${fileCount === 1 ? "" : "s"}`],
|
||||
},
|
||||
{
|
||||
label: "Bundled assets",
|
||||
items: assetCount
|
||||
? [`${assetCount} asset${assetCount === 1 ? "" : "s"}`]
|
||||
: [],
|
||||
},
|
||||
{ label: "Folder", items: [item.path] },
|
||||
]}
|
||||
lastUpdated={lastUpdated}
|
||||
sourceUrl={githubUrl}
|
||||
/>
|
||||
}
|
||||
previous={previous}
|
||||
next={next}
|
||||
currentPage="skills"
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
>
|
||||
<section className={styles.articleSection}>
|
||||
{fileCount > 1 ? (
|
||||
<>
|
||||
<span className={styles.codeLabel}>
|
||||
<FileDirectoryIcon size={16} /> Files in this skill
|
||||
</span>
|
||||
<ul className={fileStyles.fileTabs} aria-label="Files in this skill">
|
||||
{files.map((file) => (
|
||||
<li key={file.path}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
fileStyles.fileTab,
|
||||
file.path === activePath && fileStyles.fileTabActive,
|
||||
)}
|
||||
aria-current={
|
||||
file.path === activePath ? "true" : undefined
|
||||
}
|
||||
onClick={() => selectFile(file.path)}
|
||||
>
|
||||
{file.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{active ? (
|
||||
<div className={fileStyles.fileBar}>
|
||||
<span className={fileStyles.filePath}>
|
||||
{active.name}
|
||||
{formatSize(active.size) ? ` · ${formatSize(active.size)}` : ""}
|
||||
</span>
|
||||
<div className={fileStyles.fileActions}>
|
||||
<Button
|
||||
as="a"
|
||||
href={activeGithubUrl}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className={styles.iconButton}
|
||||
aria-label={`View ${active.name} on GitHub`}
|
||||
>
|
||||
<MarkGithubIcon size={16} />
|
||||
</Button>
|
||||
{activeRawUrl ? (
|
||||
<Button
|
||||
as="a"
|
||||
href={activeRawUrl}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
download
|
||||
className={styles.iconButton}
|
||||
aria-label={`Download ${active.name}`}
|
||||
>
|
||||
<DownloadIcon size={16} />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<FileView
|
||||
active={active}
|
||||
isPrimary={isPrimary}
|
||||
kind={kind}
|
||||
markdownHtml={markdownHtml}
|
||||
rawMarkdown={rawMarkdown}
|
||||
text={active ? contents[active.path] : undefined}
|
||||
status={status}
|
||||
rawBase={rawBase}
|
||||
githubUrl={activeGithubUrl}
|
||||
/>
|
||||
</section>
|
||||
</DetailChassis>
|
||||
);
|
||||
}
|
||||
|
||||
function FileView({
|
||||
active,
|
||||
isPrimary,
|
||||
kind,
|
||||
markdownHtml,
|
||||
rawMarkdown,
|
||||
text,
|
||||
status,
|
||||
rawBase,
|
||||
githubUrl,
|
||||
}: {
|
||||
active: SkillFile | null;
|
||||
isPrimary: boolean;
|
||||
kind: FileKind;
|
||||
markdownHtml: string;
|
||||
rawMarkdown: string;
|
||||
text?: string;
|
||||
status: "idle" | "loading" | "error";
|
||||
rawBase: string;
|
||||
githubUrl: string;
|
||||
}) {
|
||||
const [markdown, setMarkdown] = React.useState<string | null>(null);
|
||||
|
||||
// Non-primary markdown assets are parsed in the browser; `marked` and the
|
||||
// sanitizer are only pulled in when such a file is actually opened.
|
||||
React.useEffect(() => {
|
||||
if (isPrimary || kind !== "markdown" || text === undefined) {
|
||||
setMarkdown(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void Promise.all([
|
||||
import("marked"),
|
||||
import("../../lib/sanitize-html"),
|
||||
import("../../lib/markdown-a11y"),
|
||||
]).then(([{ marked }, { sanitizeHtml }, { enhanceMarkdownA11y }]) => {
|
||||
if (cancelled) return;
|
||||
setMarkdown(
|
||||
enhanceMarkdownA11y(
|
||||
sanitizeHtml(marked.parse(text, { async: false }) as string),
|
||||
),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isPrimary, kind, text]);
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
if (isPrimary) {
|
||||
return markdownHtml ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: markdownHtml }} />
|
||||
) : (
|
||||
<pre tabIndex={0} className={styles.codeBlock}>
|
||||
<code>{rawMarkdown}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "image") {
|
||||
return (
|
||||
<img
|
||||
className={fileStyles.fileImage}
|
||||
src={`${rawBase}/${encodeRepoPath(active.path)}`}
|
||||
alt={active.name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<Text as="p" size="200" variant="muted">
|
||||
Loading {active.name}…
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error" || text === undefined) {
|
||||
return (
|
||||
<Text as="p" size="200" variant="muted">
|
||||
Couldn't load this file. <a href={githubUrl}>View it on GitHub</a>.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "markdown") {
|
||||
return markdown ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: markdown }} />
|
||||
) : (
|
||||
<Text as="p" size="200" variant="muted">
|
||||
Rendering {active.name}…
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "code") {
|
||||
const language =
|
||||
CODE_LANGUAGES[extensionOf(active.path)] ?? detectCodeLanguage(text);
|
||||
return (
|
||||
<SyntaxHighlightedCode
|
||||
className={styles.codeBlock}
|
||||
code={text}
|
||||
language={language}
|
||||
lineClassName={styles.codeLine}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<pre tabIndex={0} className={styles.codeBlock}>
|
||||
<code>{text}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import { ChevronDownIcon, CopyIcon, DownloadIcon, MarkGithubIcon } from "@primer/octicons-react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CTABanner,
|
||||
Card,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
Grid,
|
||||
Heading,
|
||||
Pagination,
|
||||
Section,
|
||||
Stack,
|
||||
Text,
|
||||
useTheme,
|
||||
} from "@primer/react-brand";
|
||||
import { clsx } from "clsx";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
import { PageShell } from "./PageShell";
|
||||
import { SkillsIcon } from "./SkillsIcon";
|
||||
import {
|
||||
daysSince,
|
||||
fileBuckets,
|
||||
fileBucketOf,
|
||||
toggleValue,
|
||||
updatedBuckets,
|
||||
updatedBucketOf,
|
||||
} from "./catalogFilters";
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import styles from "./styles/skills.module.css";
|
||||
|
||||
const CONTRIBUTE_URL =
|
||||
"https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md";
|
||||
const REQUEST_URL = "https://github.com/github/awesome-copilot/issues/new";
|
||||
|
||||
/** A skill record as emitted into public/data/skills.json. */
|
||||
export type SkillItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
assetCount: number;
|
||||
files: number;
|
||||
lastUpdated: string;
|
||||
};
|
||||
|
||||
const skillSourceUrl = (skill: SkillItem) =>
|
||||
`https://github.com/github/awesome-copilot/blob/main/skills/${skill.id}`;
|
||||
|
||||
const installCommand = (skill: SkillItem) =>
|
||||
`gh skills install github/awesome-copilot ${skill.id}`;
|
||||
|
||||
const downloadUrl = (skill: SkillItem) =>
|
||||
`https://raw.githubusercontent.com/github/awesome-copilot/main/skills/${skill.id}/SKILL.md`;
|
||||
|
||||
type FilterGroupId = "resources" | "files" | "updated";
|
||||
|
||||
const filterGroups: { id: FilterGroupId; label: string; options: string[] }[] = [
|
||||
{
|
||||
id: "resources",
|
||||
label: "Resources",
|
||||
options: ["Includes assets", "Instructions only"],
|
||||
},
|
||||
{ id: "files", label: "Bundled files", options: fileBuckets.map((b) => b.label) },
|
||||
{ id: "updated", label: "Last updated", options: updatedBuckets.map((b) => b.label) },
|
||||
];
|
||||
|
||||
type FilterState = Record<FilterGroupId, string[]>;
|
||||
|
||||
const emptyFilters: FilterState = { resources: [], files: [], updated: [] };
|
||||
|
||||
const resourceOf = (skill: SkillItem) =>
|
||||
skill.assetCount > 0 ? "Includes assets" : "Instructions only";
|
||||
|
||||
const PAGE_SIZE = 6;
|
||||
|
||||
/**
|
||||
* The Skills catalog, ported from the design prototype's `skills.tsx`. The
|
||||
* prototype's hardcoded array is replaced by build-time data; the layout,
|
||||
* filters, and interactions are unchanged.
|
||||
*/
|
||||
export function SkillsCatalog({
|
||||
skills,
|
||||
searchIndex,
|
||||
contributorsTotal,
|
||||
}: {
|
||||
skills: SkillItem[];
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsTotal?: number;
|
||||
}) {
|
||||
const { colorMode } = useTheme();
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<FilterState>(emptyFilters);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimer = React.useRef<number | undefined>(undefined);
|
||||
|
||||
React.useEffect(
|
||||
() => () => window.clearTimeout(copyTimer.current),
|
||||
[],
|
||||
);
|
||||
|
||||
const copyInstall = async (skill: SkillItem) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(installCommand(skill));
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
setCopied(true);
|
||||
window.clearTimeout(copyTimer.current);
|
||||
copyTimer.current = window.setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const sortedSkills = useMemo(() => {
|
||||
const filtered = skills.filter((skill) => {
|
||||
const resourcesOk =
|
||||
filters.resources.length === 0 ||
|
||||
filters.resources.includes(resourceOf(skill));
|
||||
const filesOk =
|
||||
filters.files.length === 0 ||
|
||||
filters.files.includes(fileBucketOf(skill.files));
|
||||
const updatedOk =
|
||||
filters.updated.length === 0 ||
|
||||
filters.updated.includes(updatedBucketOf(daysSince(skill.lastUpdated)));
|
||||
return resourcesOk && filesOk && updatedOk;
|
||||
});
|
||||
return filtered.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}, [skills, filters]);
|
||||
|
||||
const toggleFilter = (groupId: FilterGroupId, option: string) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
[groupId]: toggleValue(prev[groupId], option),
|
||||
}));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters(emptyFilters);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const activeFilterCount =
|
||||
filters.resources.length + filters.files.length + filters.updated.length;
|
||||
const hasActiveFilters = activeFilterCount > 0;
|
||||
const pageCount = Math.max(1, Math.ceil(sortedSkills.length / PAGE_SIZE));
|
||||
const page = Math.min(currentPage, pageCount);
|
||||
const visibleSkills = sortedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
styles={styles}
|
||||
currentPage="skills"
|
||||
searchIndex={searchIndex}
|
||||
contributorsTotal={contributorsTotal}
|
||||
searchAriaLabel="Search skills"
|
||||
>
|
||||
<Box className={styles.hero}>
|
||||
<div className={styles.heroInner}>
|
||||
<span className={styles.heroIcon} aria-hidden="true">
|
||||
<SkillsIcon size={36} />
|
||||
</span>
|
||||
<Heading as="h1" size="3" className={styles.heroHeading}>
|
||||
Skills
|
||||
</Heading>
|
||||
<Text as="p" size="300" variant="muted" className={styles.heroText}>
|
||||
Self-contained agent skills that bundle instructions and resources —
|
||||
contributed and curated by the community to extend GitHub Copilot.
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Section id="catalog" paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box className={styles.catalog}>
|
||||
<aside className={styles.filterNav} aria-label="Filter skills">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.filterToggle}
|
||||
aria-expanded={mobileFiltersOpen}
|
||||
onClick={() => setMobileFiltersOpen((open) => !open)}
|
||||
>
|
||||
<span>Filters{hasActiveFilters ? ` (${activeFilterCount})` : ""}</span>
|
||||
<ChevronDownIcon
|
||||
size={16}
|
||||
className={clsx(
|
||||
styles.filterToggleChevron,
|
||||
mobileFiltersOpen && styles.filterToggleChevronOpen,
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.filterBody,
|
||||
!mobileFiltersOpen && styles.filterBodyCollapsed,
|
||||
)}
|
||||
>
|
||||
{filterGroups.map((group) => (
|
||||
<div className={styles.filterGroup} key={group.id}>
|
||||
<Text as="h2" size="100" className={styles.filterHeading}>
|
||||
{group.label}
|
||||
</Text>
|
||||
<div className={styles.filterOptions}>
|
||||
{group.options.map((option) => (
|
||||
<div className={styles.filterOption} key={option}>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={filters[group.id].includes(option)}
|
||||
onChange={() => toggleFilter(group.id, option)}
|
||||
/>
|
||||
<FormControl.Label>{option}</FormControl.Label>
|
||||
</FormControl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{hasActiveFilters ? (
|
||||
<div className={styles.filterActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
hasArrow={false}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear all ({activeFilterCount})
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Box className={styles.catalogMain}>
|
||||
<Box className={styles.gridFrame} data-mode={colorMode}>
|
||||
<Box className={styles.gridContent}>
|
||||
<Grid
|
||||
className={styles.threeUp}
|
||||
columnGap="none"
|
||||
rowGap="none"
|
||||
enableGutters={false}
|
||||
>
|
||||
{visibleSkills.map((skill) => (
|
||||
<Grid.Column
|
||||
key={skill.id}
|
||||
span={{ xsmall: 12, medium: 6, large: 6 }}
|
||||
className={styles.col}
|
||||
>
|
||||
<Box className={clsx(styles.item, styles.itemHover)}>
|
||||
<Card
|
||||
href={pageHref(`skill/${skill.id}`)}
|
||||
fullWidth
|
||||
ctaVariant="none"
|
||||
backgroundColor="none"
|
||||
className={styles.card}
|
||||
>
|
||||
<Card.Heading as="h2">{skill.title}</Card.Heading>
|
||||
<Card.Description>
|
||||
<span className={styles.cardDescText}>
|
||||
{skill.description}
|
||||
</span>
|
||||
</Card.Description>
|
||||
</Card>
|
||||
<div className={styles.cardActions}>
|
||||
<Button
|
||||
variant="primary"
|
||||
hasArrow={false}
|
||||
leadingVisual={CopyIcon}
|
||||
onClick={() => copyInstall(skill)}
|
||||
>
|
||||
Copy install
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href={downloadUrl(skill)}
|
||||
variant="secondary"
|
||||
download
|
||||
aria-label={`Download ${skill.title} skill file`}
|
||||
className={styles.iconButton}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href={skillSourceUrl(skill)}
|
||||
variant="secondary"
|
||||
aria-label={`View ${skill.title} skill on GitHub`}
|
||||
className={styles.iconButton}
|
||||
>
|
||||
<MarkGithubIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</Grid.Column>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{sortedSkills.length === 0 ? (
|
||||
<Box className={styles.emptyState}>
|
||||
<Text as="p" variant="muted">
|
||||
No skills match the selected filters.
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
<div className={styles.rule} aria-hidden="true" />
|
||||
|
||||
<Box className={styles.paginationRow}>
|
||||
<Stack direction="horizontal" justifyContent="center" padding="none">
|
||||
<Pagination
|
||||
className={styles.pagination}
|
||||
pageCount={pageCount}
|
||||
currentPage={page}
|
||||
onPageChange={(e, n) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(n);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<div className={styles.rule} aria-hidden="true" />
|
||||
|
||||
<Box className={styles.ctaFrame}>
|
||||
<Section paddingBlockStart="none" paddingBlockEnd="none">
|
||||
<Box className={styles.ctaFrameInner}>
|
||||
<CTABanner align="center" backgroundColor="subtle">
|
||||
<CTABanner.Heading>
|
||||
Can’t find the skill you need?
|
||||
</CTABanner.Heading>
|
||||
<CTABanner.Description>
|
||||
This library is community-built. Share a skill you use—or
|
||||
request one—to help developers ship faster with GitHub
|
||||
Copilot.
|
||||
</CTABanner.Description>
|
||||
<CTABanner.ButtonGroup>
|
||||
<Button as="a" href={CONTRIBUTE_URL}>
|
||||
Submit a skill
|
||||
</Button>
|
||||
<Button as="a" href={REQUEST_URL}>
|
||||
Request a skill
|
||||
</Button>
|
||||
</CTABanner.ButtonGroup>
|
||||
</CTABanner>
|
||||
</Box>
|
||||
</Section>
|
||||
</Box>
|
||||
|
||||
<div
|
||||
className={styles.toast}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-visible={copied ? "true" : undefined}
|
||||
>
|
||||
Install command copied!
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
type SkillsIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export function SkillsIcon({ size = 36 }: SkillsIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 96 96"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Skills"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#skills-icon-clip)">
|
||||
<path
|
||||
d="M87.6452 36.2143H60L69.8776 7.27142C71.2028 3.38827 66.5201 0.2653 63.4444 2.98108L5.9258 53.7695C3.16384 56.2082 4.88879 60.7679 8.57336 60.7679H38L29.0183 88.7728C27.7683 92.6706 32.486 95.7075 35.5163 92.9556L90.3343 43.1755C93.0417 40.7169 91.3024 36.2143 87.6452 36.2143Z"
|
||||
stroke="var(--brand-color-text-default)"
|
||||
strokeWidth="4"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M24 0C27.3137 -1.51144e-07 30 2.68629 30 6C29.9999 9.31356 27.314 11.9999 24.0005 12C20.6868 12 18.0001 9.31364 18 6C18 2.68637 20.6864 0.000131825 24 0Z"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
<path
|
||||
d="M11 82C14.3137 82 17 84.6863 17 88C16.9999 91.3136 14.314 93.9999 11.0005 94C7.68683 94 5.00008 91.3136 5 88C5 84.6864 7.6864 82.0001 11 82Z"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
<path
|
||||
d="M90 56C93.3137 56 96 58.6863 96 62C95.9999 65.3136 93.314 67.9999 90.0005 68C86.6868 68 84.0001 65.3136 84 62C84 58.6864 86.6864 56.0001 90 56Z"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="skills-icon-clip">
|
||||
<rect width="96" height="96" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import styles from "./styles/SkipLink.module.css";
|
||||
|
||||
/**
|
||||
* Keyboard-only "Skip to main content" link. Rendered as the first focusable
|
||||
* element on every page so keyboard and screen-reader users can bypass the
|
||||
* header navigation and jump straight to the page's <main> region. It stays
|
||||
* visually hidden until it receives focus, then slides into the top-left.
|
||||
*/
|
||||
export function SkipLink({
|
||||
targetId = "main-content",
|
||||
label = "Skip to main content",
|
||||
}: {
|
||||
targetId?: string;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<a className={styles.skipLink} href={`#${targetId}`}>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import React from "react";
|
||||
|
||||
export type CodeLanguage = "bash" | "json" | "markup" | "tsx" | "yaml";
|
||||
|
||||
type TokenKind =
|
||||
| "attr-name"
|
||||
| "boolean"
|
||||
| "comment"
|
||||
| "function"
|
||||
| "keyword"
|
||||
| "number"
|
||||
| "operator"
|
||||
| "placeholder"
|
||||
| "property"
|
||||
| "punctuation"
|
||||
| "string"
|
||||
| "tag"
|
||||
| "variable";
|
||||
|
||||
type TokenRule = {
|
||||
kind: TokenKind;
|
||||
expression: RegExp;
|
||||
};
|
||||
|
||||
type HighlightToken = {
|
||||
content: string;
|
||||
kind?: TokenKind;
|
||||
};
|
||||
|
||||
const rules: Record<CodeLanguage, readonly TokenRule[]> = {
|
||||
bash: [
|
||||
{ kind: "comment", expression: /#.*$/y },
|
||||
{ kind: "string", expression: /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/y },
|
||||
{ kind: "placeholder", expression: /<[\w.*-]+>/y },
|
||||
{ kind: "variable", expression: /\$\{?[\w@#?-]+\}?/y },
|
||||
{
|
||||
kind: "variable",
|
||||
expression: /(?<=^|[\s|(])(?:--?|\/)[\w][\w:-]*(?:=[^\s\\]+)?/y,
|
||||
},
|
||||
{
|
||||
kind: "function",
|
||||
expression:
|
||||
/(?:dotnet|find|gh|git|grep|npm|npx|sed|sort|uniq|brew)\b/y,
|
||||
},
|
||||
{ kind: "operator", expression: /(?:&&|\|\||[|;&])/y },
|
||||
],
|
||||
json: [
|
||||
{ kind: "property", expression: /"(?:\\.|[^"\\])*"(?=\s*:)/y },
|
||||
{ kind: "string", expression: /"(?:\\.|[^"\\])*"/y },
|
||||
{ kind: "boolean", expression: /\b(?:false|null|true)\b/y },
|
||||
{ kind: "number", expression: /-?\b\d+(?:\.\d+)?(?:e[+-]?\d+)?\b/iy },
|
||||
{ kind: "punctuation", expression: /[{}[\],:]/y },
|
||||
],
|
||||
markup: [
|
||||
{ kind: "comment", expression: /<!--.*?-->/y },
|
||||
{ kind: "tag", expression: /<\/?[A-Za-z][\w:-]*/y },
|
||||
{ kind: "attr-name", expression: /[A-Za-z_:][\w:.-]*(?=\s*=)/y },
|
||||
{ kind: "string", expression: /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/y },
|
||||
{ kind: "punctuation", expression: /\/?>/y },
|
||||
],
|
||||
tsx: [
|
||||
{ kind: "comment", expression: /\/\/.*$|\/\*.*?\*\//y },
|
||||
{ kind: "string", expression: /`(?:\\.|[^`\\])*`|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/y },
|
||||
{ kind: "tag", expression: /<\/?[A-Za-z][\w.]*/y },
|
||||
{
|
||||
kind: "keyword",
|
||||
expression:
|
||||
/\b(?:as|async|await|const|else|export|extends|false|for|from|function|if|import|interface|let|new|null|return|true|type|undefined)\b/y,
|
||||
},
|
||||
{ kind: "number", expression: /\b\d+(?:\.\d+)?\b/y },
|
||||
{ kind: "variable", expression: /\b[A-Z][A-Za-z0-9_]*\b/y },
|
||||
{ kind: "operator", expression: /=>|===?|!==?|[+*/%-]/y },
|
||||
{ kind: "punctuation", expression: /[{}[\](),.;:<>]/y },
|
||||
],
|
||||
yaml: [
|
||||
{ kind: "comment", expression: /#.*$/y },
|
||||
{ kind: "punctuation", expression: /(?:---|\.\.\.)/y },
|
||||
{ kind: "property", expression: /[A-Za-z_][\w.-]*(?=\s*:)/y },
|
||||
{ kind: "string", expression: /"(?:\\.|[^"\\])*"|'(?:''|[^'])*'/y },
|
||||
{ kind: "placeholder", expression: /<[\w.*-]+>/y },
|
||||
{ kind: "variable", expression: /\$\{\{.*?\}\}|\$\([\w.-]+\)/y },
|
||||
{ kind: "boolean", expression: /\b(?:false|null|true|~)\b/iy },
|
||||
{ kind: "number", expression: /-?\b\d+(?:\.\d+)?\b/y },
|
||||
{ kind: "function", expression: /\bdotnet\b/y },
|
||||
{ kind: "punctuation", expression: /[:,[\]{}]|-(?=\s)/y },
|
||||
],
|
||||
};
|
||||
|
||||
function matchRule(line: string, cursor: number, rule: TokenRule) {
|
||||
rule.expression.lastIndex = cursor;
|
||||
return rule.expression.exec(line);
|
||||
}
|
||||
|
||||
function tokenizeLine(line: string, language: CodeLanguage): HighlightToken[] {
|
||||
const languageRules = rules[language];
|
||||
const tokens: HighlightToken[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < line.length) {
|
||||
const ruleMatch = languageRules
|
||||
.map((rule) => ({ match: matchRule(line, cursor, rule), rule }))
|
||||
.find(({ match }) => match?.index === cursor);
|
||||
|
||||
if (ruleMatch?.match) {
|
||||
tokens.push({
|
||||
content: ruleMatch.match[0],
|
||||
kind: ruleMatch.rule.kind,
|
||||
});
|
||||
cursor += ruleMatch.match[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
let end = cursor + 1;
|
||||
while (
|
||||
end < line.length &&
|
||||
!languageRules.some((rule) => matchRule(line, end, rule)?.index === end)
|
||||
) {
|
||||
end += 1;
|
||||
}
|
||||
tokens.push({ content: line.slice(cursor, end) });
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function detectCodeLanguage(code: string): CodeLanguage {
|
||||
const source = code.trimStart();
|
||||
if (source.startsWith("{") || source.startsWith("[")) return "json";
|
||||
if (
|
||||
source.startsWith("---") ||
|
||||
/^(?:- (?:task|uses)|description|model|name|tools|version):/m.test(source)
|
||||
) {
|
||||
return "yaml";
|
||||
}
|
||||
if (/^(?:import|export|const|let|function|interface|type)\s/m.test(source)) {
|
||||
return "tsx";
|
||||
}
|
||||
if (/^<[/!?]?[a-z][^>]*>/i.test(source)) return "markup";
|
||||
return "bash";
|
||||
}
|
||||
|
||||
export function SyntaxHighlightedCode({
|
||||
className,
|
||||
code,
|
||||
language = detectCodeLanguage(code),
|
||||
lineClassName,
|
||||
}: {
|
||||
className: string;
|
||||
code: string;
|
||||
language?: CodeLanguage;
|
||||
lineClassName: string;
|
||||
}) {
|
||||
return (
|
||||
<pre className={className} tabIndex={0}>
|
||||
<code>
|
||||
{code.split("\n").map((line, lineIndex) => (
|
||||
<span className={lineClassName} key={lineIndex}>
|
||||
{tokenizeLine(line, language).map((token, tokenIndex) =>
|
||||
token.kind ? (
|
||||
<span className={`token ${token.kind}`} key={tokenIndex}>
|
||||
{token.content}
|
||||
</span>
|
||||
) : (
|
||||
<React.Fragment key={tokenIndex}>
|
||||
{token.content}
|
||||
</React.Fragment>
|
||||
),
|
||||
)}
|
||||
{"\n"}
|
||||
</span>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { ChevronDownIcon, ThreeBarsIcon, XIcon } from "@primer/octicons-react";
|
||||
import { clsx } from "clsx";
|
||||
import { Button } from "@primer/react-brand";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import type { SearchItem } from "./searchIndex";
|
||||
import mobileStyles from "./styles/TopNav.module.css";
|
||||
import { ContributorsNavButton } from "./ContributorsNavButton";
|
||||
import { LanguageSelect } from "./LanguageSelect";
|
||||
import { TopNavSearch } from "./TopNavSearch";
|
||||
|
||||
const CONTRIBUTING_URL =
|
||||
"https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md";
|
||||
|
||||
type NavLink = { label: string; href: string; current: boolean };
|
||||
|
||||
/**
|
||||
* The top navigation is intentionally reduced to two tabs shared across every
|
||||
* page: a "Resources" dropdown that gathers all catalog destinations, plus a
|
||||
* standalone "Copilot Playbook" tab. Each page owns its own scoped CSS module,
|
||||
* so the class map is injected via `styles` to keep the existing look.
|
||||
*/
|
||||
export function TopNav({
|
||||
styles,
|
||||
links,
|
||||
libraryLabel = "Resources",
|
||||
playbookLabel = "Playbook",
|
||||
contributorsHref,
|
||||
contributorsTotal = 0,
|
||||
searchIndex,
|
||||
contributorsCurrent = false,
|
||||
searchAriaLabel = "Search the library",
|
||||
}: {
|
||||
styles: Record<string, string | undefined>;
|
||||
links: NavLink[];
|
||||
libraryLabel?: string;
|
||||
playbookLabel?: string;
|
||||
contributorsHref?: string;
|
||||
contributorsTotal?: number;
|
||||
searchIndex?: SearchItem[];
|
||||
contributorsCurrent?: boolean;
|
||||
searchAriaLabel?: string;
|
||||
}) {
|
||||
const playbook = links.find((link) => link.label === playbookLabel);
|
||||
const libraryLinks = links.filter((link) => link.label !== playbookLabel);
|
||||
const libraryActive = libraryLinks.some((link) => link.current);
|
||||
const desktopMenuRef = useRef<HTMLDetailsElement>(null);
|
||||
const mobileMenuRef = useRef<HTMLDetailsElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const closeMenus = () => {
|
||||
if (desktopMenuRef.current) desktopMenuRef.current.open = false;
|
||||
if (mobileMenuRef.current) mobileMenuRef.current.open = false;
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
const activeElement = document.activeElement;
|
||||
const activeMenu = [desktopMenuRef.current, mobileMenuRef.current].find(
|
||||
(menu) => menu?.open && activeElement && menu.contains(activeElement),
|
||||
);
|
||||
if (!activeMenu) return;
|
||||
closeMenus();
|
||||
activeMenu.querySelector("summary")?.focus();
|
||||
};
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (
|
||||
!desktopMenuRef.current?.contains(target) &&
|
||||
!mobileMenuRef.current?.contains(target)
|
||||
) {
|
||||
closeMenus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className={styles.subNavList}>
|
||||
<li className={styles.moreItem}>
|
||||
<details className={styles.moreMenu} ref={desktopMenuRef}>
|
||||
<summary
|
||||
className={clsx(
|
||||
styles.subNavLink,
|
||||
styles.moreTrigger,
|
||||
libraryActive && styles.subNavLinkActive,
|
||||
)}
|
||||
>
|
||||
{libraryLabel}
|
||||
<ChevronDownIcon size={16} className={styles.moreChevron} />
|
||||
</summary>
|
||||
<div className={styles.moreOverlay}>
|
||||
{libraryLinks.map((link) => (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
className={styles.moreLink}
|
||||
aria-current={link.current ? "page" : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</li>
|
||||
{playbook && (
|
||||
<li>
|
||||
<a
|
||||
href={playbook.href}
|
||||
className={clsx(
|
||||
styles.subNavLink,
|
||||
playbook.current && styles.subNavLinkActive,
|
||||
)}
|
||||
aria-current={playbook.current ? "page" : undefined}
|
||||
>
|
||||
{playbook.label}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<details className={mobileStyles.mobileMenu} ref={mobileMenuRef}>
|
||||
<summary
|
||||
className={mobileStyles.mobileTrigger}
|
||||
aria-label={`Open ${libraryLabel} menu`}
|
||||
>
|
||||
<ThreeBarsIcon size={20} className={mobileStyles.triggerBars} />
|
||||
<XIcon size={20} className={mobileStyles.triggerClose} />
|
||||
</summary>
|
||||
<nav className={mobileStyles.mobileOverlay} aria-label={libraryLabel}>
|
||||
{contributorsHref && (
|
||||
<>
|
||||
<div className={mobileStyles.mobileSearch}>
|
||||
<TopNavSearch
|
||||
index={searchIndex}
|
||||
styles={styles}
|
||||
variant="inline"
|
||||
inputAriaLabel={searchAriaLabel}
|
||||
/>
|
||||
</div>
|
||||
<span className={mobileStyles.overlayDivider} aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
<span className={mobileStyles.overlayLabel}>{libraryLabel}</span>
|
||||
{libraryLinks.map((link) => (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
className={mobileStyles.mobileLink}
|
||||
aria-current={link.current ? "page" : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
{playbook && (
|
||||
<>
|
||||
<span className={mobileStyles.overlayDivider} aria-hidden="true" />
|
||||
<a
|
||||
href={playbook.href}
|
||||
className={mobileStyles.mobileLink}
|
||||
aria-current={playbook.current ? "page" : undefined}
|
||||
>
|
||||
{playbook.label}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{contributorsHref && (
|
||||
<>
|
||||
<span className={mobileStyles.overlayDivider} aria-hidden="true" />
|
||||
<div className={mobileStyles.mobileActions}>
|
||||
<ContributorsNavButton
|
||||
href={contributorsHref}
|
||||
current={contributorsCurrent}
|
||||
total={contributorsTotal}
|
||||
/>
|
||||
<LanguageSelect />
|
||||
<Button
|
||||
as="a"
|
||||
href={CONTRIBUTING_URL}
|
||||
variant="primary"
|
||||
size="medium"
|
||||
>
|
||||
Contribute
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ArrowUpRightIcon, SearchIcon } from "@primer/octicons-react";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
import { hrefKey, searchPagefind } from "./pagefindSearch";
|
||||
import { type SearchCategory, type SearchItem } from "./searchIndex";
|
||||
import s from "./styles/TopNavSearch.module.css";
|
||||
|
||||
const CATEGORY_ORDER: SearchCategory[] = [
|
||||
"Pages",
|
||||
"Articles",
|
||||
"Agents",
|
||||
"Instructions",
|
||||
"Skills",
|
||||
"Plugins",
|
||||
"Extensions",
|
||||
];
|
||||
|
||||
const MAX_RESULTS = 8;
|
||||
const MAX_PER_GROUP = 4;
|
||||
/** Upper bound on Pagefind hits fetched per query before merge/dedupe. */
|
||||
const MAX_PAGEFIND_HITS = 12;
|
||||
|
||||
type ResultGroup = { category: SearchCategory; items: SearchItem[] };
|
||||
|
||||
/**
|
||||
* Shared top-navigation search. A collapsed magnifier button expands into a
|
||||
* search field (also opened with ⌘K / Ctrl+K) and shows a live dropdown of
|
||||
* matches drawn from the site-wide index, so a visitor can search the whole
|
||||
* library and jump to any page from anywhere. The field, button, and ⌘K hint
|
||||
* reuse each page's scoped `styles`; the popover uses this component's module.
|
||||
*
|
||||
* The term can be controlled via `term`/`onTermChange` so a page that also
|
||||
* filters its own catalog (agents, skills, …) keeps narrowing its list as the
|
||||
* visitor types; omit them and the component manages its own term.
|
||||
*/
|
||||
export function TopNavSearch({
|
||||
index = [],
|
||||
styles,
|
||||
inputAriaLabel = "Search the library",
|
||||
term: controlledTerm,
|
||||
onTermChange,
|
||||
variant = "bar",
|
||||
}: {
|
||||
/** Site-wide search index, injected from build-time data. */
|
||||
index?: SearchItem[];
|
||||
styles: Record<string, string | undefined>;
|
||||
inputAriaLabel?: string;
|
||||
term?: string;
|
||||
onTermChange?: (value: string) => void;
|
||||
/**
|
||||
* `"bar"` (default) is the collapsed magnifier that expands in the desktop
|
||||
* top bar. `"inline"` is the always-open, full-width field used inside the
|
||||
* mobile hamburger menu, where the desktop bar is hidden — so search stays
|
||||
* available at every viewport.
|
||||
*/
|
||||
variant?: "bar" | "inline";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [internalTerm, setInternalTerm] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const listboxId = useId();
|
||||
|
||||
const isControlled = controlledTerm !== undefined;
|
||||
const term = isControlled ? controlledTerm : internalTerm;
|
||||
const setTerm = (value: string) => {
|
||||
if (isControlled) {
|
||||
onTermChange?.(value);
|
||||
} else {
|
||||
setInternalTerm(value);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const trimmed = term.trim();
|
||||
|
||||
// Pagefind covers every built HTML page (Playbook articles included), which
|
||||
// the build-time `index` prop does not. It is only available after a build,
|
||||
// so results arrive asynchronously and are merged in when they land.
|
||||
const [pagefindHits, setPagefindHits] = useState<SearchItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trimmed.length === 0) {
|
||||
setPagefindHits([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
searchPagefind(trimmed, MAX_PAGEFIND_HITS).then((hits) => {
|
||||
if (!cancelled) setPagefindHits(hits);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [trimmed]);
|
||||
|
||||
const groups = useMemo<ResultGroup[]>(() => {
|
||||
const query = trimmed.toLowerCase();
|
||||
if (query.length === 0) return [];
|
||||
const staticMatches = index.filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(query) ||
|
||||
item.description.toLowerCase().includes(query) ||
|
||||
item.category.toLowerCase().includes(query),
|
||||
);
|
||||
// Static hits win on ties: they carry curated titles and descriptions.
|
||||
const seen = new Set(staticMatches.map((item) => hrefKey(item.href)));
|
||||
const matches = [...staticMatches];
|
||||
for (const hit of pagefindHits) {
|
||||
const key = hrefKey(hit.href);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
matches.push(hit);
|
||||
}
|
||||
let remaining = MAX_RESULTS;
|
||||
const grouped: ResultGroup[] = [];
|
||||
for (const category of CATEGORY_ORDER) {
|
||||
if (remaining <= 0) break;
|
||||
const items = matches
|
||||
.filter((item) => item.category === category)
|
||||
.slice(0, Math.min(MAX_PER_GROUP, remaining));
|
||||
if (items.length === 0) continue;
|
||||
remaining -= items.length;
|
||||
grouped.push({ category, items });
|
||||
}
|
||||
return grouped;
|
||||
}, [trimmed, index, pagefindHits]);
|
||||
|
||||
const flatResults = useMemo(
|
||||
() => groups.flatMap((group) => group.items),
|
||||
[groups],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(-1);
|
||||
}, [trimmed]);
|
||||
|
||||
const resultsOpen = open && focused && trimmed.length > 0;
|
||||
const hasResults = groups.length > 0;
|
||||
const activeDescendant =
|
||||
activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined;
|
||||
|
||||
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Escape") {
|
||||
setTerm("");
|
||||
setActiveIndex(-1);
|
||||
setFocused(false);
|
||||
if (variant === "bar") {
|
||||
setOpen(false);
|
||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasResults) return;
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((current) =>
|
||||
current >= flatResults.length - 1 ? 0 : current + 1,
|
||||
);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((current) =>
|
||||
current <= 0 ? flatResults.length - 1 : current - 1,
|
||||
);
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
setActiveIndex(0);
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
setActiveIndex(flatResults.length - 1);
|
||||
} else if (event.key === "Enter" && activeIndex >= 0) {
|
||||
event.preventDefault();
|
||||
window.location.assign(flatResults[activeIndex].href);
|
||||
}
|
||||
};
|
||||
|
||||
const renderGroups = () => {
|
||||
if (!hasResults) {
|
||||
return (
|
||||
<p className={s.empty} role="status">
|
||||
No results for “{trimmed}”
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
let itemIndex = 0;
|
||||
return groups.map((group, groupIndex) => {
|
||||
const groupLabelId = `${listboxId}-group-${groupIndex}`;
|
||||
return (
|
||||
<div
|
||||
className={s.group}
|
||||
key={group.category}
|
||||
role="group"
|
||||
aria-labelledby={groupLabelId}
|
||||
>
|
||||
<p id={groupLabelId} className={s.groupLabel}>
|
||||
{group.category}
|
||||
</p>
|
||||
{group.items.map((item) => {
|
||||
const index = itemIndex++;
|
||||
return (
|
||||
<a
|
||||
id={`${listboxId}-option-${index}`}
|
||||
key={`${item.category}-${item.href}-${item.title}`}
|
||||
href={item.href}
|
||||
className={clsx(
|
||||
s.result,
|
||||
activeIndex === index && s.resultActive,
|
||||
)}
|
||||
role="option"
|
||||
aria-selected={activeIndex === index}
|
||||
onFocus={() => setActiveIndex(index)}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
>
|
||||
<span className={s.resultText}>{item.title}</span>
|
||||
<ArrowUpRightIcon size={16} className={s.resultIcon} />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<div className={s.inlineWrap} ref={rootRef}>
|
||||
<div className={s.inlineField}>
|
||||
<SearchIcon size={16} className={s.inlineIcon} />
|
||||
<input
|
||||
type="text"
|
||||
className={s.inlineInput}
|
||||
placeholder="Search"
|
||||
aria-label={inputAriaLabel}
|
||||
role="combobox"
|
||||
aria-expanded={trimmed.length > 0}
|
||||
aria-controls={listboxId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={activeDescendant}
|
||||
autoComplete="off"
|
||||
value={term}
|
||||
onChange={(event) => setTerm(event.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
</div>
|
||||
{trimmed.length > 0 && (
|
||||
<div
|
||||
id={listboxId}
|
||||
className={s.inlineResults}
|
||||
role="listbox"
|
||||
aria-label="Search results"
|
||||
>
|
||||
{renderGroups()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={s.wrap}
|
||||
ref={rootRef}
|
||||
onBlur={(event) => {
|
||||
if (event.currentTarget.contains(event.relatedTarget)) return;
|
||||
setFocused(false);
|
||||
setActiveIndex(-1);
|
||||
if (term.trim() === "") setOpen(false);
|
||||
}}
|
||||
>
|
||||
{open ? (
|
||||
<div className={styles.searchField}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={styles.searchInput}
|
||||
placeholder="Search"
|
||||
aria-label={inputAriaLabel}
|
||||
role="combobox"
|
||||
aria-expanded={resultsOpen}
|
||||
aria-controls={listboxId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={activeDescendant}
|
||||
autoComplete="off"
|
||||
value={term}
|
||||
onFocus={() => setFocused(true)}
|
||||
onChange={(event) => setTerm(event.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
<kbd className={styles.searchKbd}>⌘K</kbd>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={styles.searchButton}
|
||||
aria-label="Search"
|
||||
aria-expanded={false}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<SearchIcon size={16} />
|
||||
</button>
|
||||
)}
|
||||
{resultsOpen && (
|
||||
<div
|
||||
id={listboxId}
|
||||
className={s.results}
|
||||
role="listbox"
|
||||
aria-label="Search results"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
{renderGroups()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import styles from "./styles/TypingText.module.css";
|
||||
|
||||
type TypingTextProps = {
|
||||
/** The full string to type out. */
|
||||
text: string;
|
||||
/** Class applied to the animated (aria-hidden) span. */
|
||||
className?: string;
|
||||
/** Per-character delay in milliseconds. */
|
||||
speedMs?: number;
|
||||
/** Render a blinking block caret that trails the text while it types and
|
||||
* disappears once the line is complete. */
|
||||
caret?: boolean;
|
||||
/** Class applied to the trailing caret span (only used when `caret`). */
|
||||
caretClassName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Terminal-style typewriter that reveals `text` one character at a time,
|
||||
* starting when the element scrolls into view. An optional blinking block
|
||||
* caret can trail the text while it types (see `caret`); otherwise the cursor
|
||||
* is expected to be a sibling element. The full string is always exposed to
|
||||
* assistive tech through a visually hidden copy, and reduced-motion users see
|
||||
* it immediately.
|
||||
*/
|
||||
export function TypingText({
|
||||
text,
|
||||
className,
|
||||
speedMs = 55,
|
||||
caret = false,
|
||||
caretClassName,
|
||||
}: TypingTextProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [count, setCount] = useState(0);
|
||||
const [started, setStarted] = useState(false);
|
||||
const done = count >= text.length;
|
||||
|
||||
useEffect(() => {
|
||||
const prefersReduced = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
if (prefersReduced) {
|
||||
setCount(text.length);
|
||||
setStarted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
setStarted(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0.6 },
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!started || done) return;
|
||||
const id = window.setTimeout(() => setCount((c) => c + 1), speedMs);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [started, count, done, speedMs]);
|
||||
|
||||
return (
|
||||
<span ref={ref}>
|
||||
<span className={styles.visuallyHidden}>{text}</span>
|
||||
<span aria-hidden="true" className={className}>
|
||||
{text.slice(0, count)}
|
||||
</span>
|
||||
{caret && started && !done ? (
|
||||
<span aria-hidden="true" className={caretClassName} />
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { ArrowLeftIcon, ArrowRightIcon, PlayIcon, XIcon } from "@primer/octicons-react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { Heading, ThemeProvider, useTheme } from "@primer/react-brand";
|
||||
|
||||
import styles from "./styles/VideoCarousel.module.css";
|
||||
|
||||
export type Video = {
|
||||
id: string;
|
||||
title: string;
|
||||
meta: string;
|
||||
};
|
||||
|
||||
const PER_PAGE = 3;
|
||||
|
||||
/** Modal YouTube player with a portal, focus trap, scroll lock, and Escape close. */
|
||||
function VideoDialog({ video, onClose }: { video: Video; onClose: () => void }) {
|
||||
const { colorMode } = useTheme();
|
||||
const titleId = useId();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
const { overflow } = document.body.style;
|
||||
document.body.style.overflow = "hidden";
|
||||
closeRef.current?.focus();
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab" || !dialogRef.current) return;
|
||||
const focusable = dialogRef.current.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), iframe, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = overflow;
|
||||
previouslyFocused?.focus?.();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<ThemeProvider colorMode={colorMode}>
|
||||
<div className={styles.backdrop} onMouseDown={onClose}>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
className={styles.dialog}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className={styles.dialogHeader}>
|
||||
<Heading as="h2" size="6" id={titleId} className={styles.dialogTitle}>
|
||||
{video.title}
|
||||
</Heading>
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
className={styles.closeButton}
|
||||
aria-label="Close video"
|
||||
onClick={onClose}
|
||||
>
|
||||
<XIcon size={16} />
|
||||
</button>
|
||||
</header>
|
||||
<div className={styles.videoFrame}>
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${video.id}?autoplay=1&rel=0`}
|
||||
title={video.title}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ThemeProvider>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function VideoCarousel({ videos }: { videos: Video[] }) {
|
||||
const [page, setPage] = useState(0);
|
||||
const [activeVideo, setActiveVideo] = useState<Video | null>(null);
|
||||
const pageCount = Math.ceil(videos.length / PER_PAGE);
|
||||
const changePage = (direction: 1 | -1) => {
|
||||
setPage((current) => (current + direction + pageCount) % pageCount);
|
||||
};
|
||||
const visibleVideos = videos.slice(page * PER_PAGE, page * PER_PAGE + PER_PAGE);
|
||||
|
||||
return (
|
||||
<div className={styles.carousel}>
|
||||
<div className={styles.header}>
|
||||
<p className={styles.headerLabel}>
|
||||
{videos.length} {videos.length === 1 ? "video" : "videos"} in this
|
||||
series
|
||||
</p>
|
||||
{pageCount > 1 ? (
|
||||
<div className={styles.nav}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navButton}
|
||||
aria-label="Previous videos"
|
||||
onClick={() => changePage(-1)}
|
||||
>
|
||||
<ArrowLeftIcon size={20} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navButton}
|
||||
aria-label="Next videos"
|
||||
onClick={() => changePage(1)}
|
||||
>
|
||||
<ArrowRightIcon size={20} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.track} key={page}>
|
||||
{visibleVideos.map((video) => (
|
||||
<button
|
||||
key={video.id}
|
||||
type="button"
|
||||
className={styles.card}
|
||||
onClick={() => setActiveVideo(video)}
|
||||
aria-label={`Play video: ${video.title}`}
|
||||
>
|
||||
<span className={styles.thumb}>
|
||||
<img
|
||||
className={styles.thumbImage}
|
||||
src={`https://i.ytimg.com/vi/${video.id}/maxresdefault.jpg`}
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = `https://i.ytimg.com/vi/${video.id}/hqdefault.jpg`;
|
||||
}}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
width={1280}
|
||||
height={720}
|
||||
/>
|
||||
<span className={styles.playBadge} aria-hidden="true">
|
||||
<PlayIcon size={24} />
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.cardMeta}>{video.meta}</span>
|
||||
<span className={styles.cardTitle}>{video.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeVideo ? (
|
||||
<VideoDialog video={activeVideo} onClose={() => setActiveVideo(null)} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
type WikiIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export function WikiIcon({ size = 64 }: WikiIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Community library"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M32 23.666L32 53.666"
|
||||
stroke="var(--brand-color-text-muted)"
|
||||
strokeWidth="2.66667"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M32.0007 53.666L29.7091 51.7564C27.3129 49.7596 24.2925 48.666 21.1733 48.666H9.33398"
|
||||
stroke="var(--brand-color-text-muted)"
|
||||
strokeWidth="2.66667"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M32.0013 53.666L34.2928 51.7564C36.689 49.7596 39.7095 48.666 42.8286 48.666H54.668"
|
||||
stroke="var(--brand-color-text-muted)"
|
||||
strokeWidth="2.66667"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M16.6673 4C24.0311 4 30.6673 8.6362 32.0007 15.3333C33.334 8.6362 39.9702 4 47.334 4H60.0007C61.4734 4 62.6673 5.19391 62.6673 6.66667V54C62.6673 55.4728 61.4734 56.6667 60.0007 56.6667H48.334C45.3852 56.6667 42.6394 58.0407 40.8692 60.3251C40.2659 61.1035 39.3855 61.6667 38.4007 61.6667H25.6007C24.6158 61.6667 23.7354 61.1035 23.1321 60.3251C21.3619 58.0407 18.6161 56.6667 15.6673 56.6667H4.00065C2.52789 56.6667 1.33398 55.4728 1.33398 54V6.66667C1.33398 5.19391 2.52789 4 4.00065 4H16.6673Z"
|
||||
stroke="var(--brand-color-text-default)"
|
||||
strokeWidth="2.66667"
|
||||
strokeMiterlimit="10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M20 16.666C20 19.9797 17.3137 22.666 14 22.666C10.6864 22.6659 8.00013 19.9801 8 16.6665C8 13.3528 10.6864 10.6661 14 10.666C17.3136 10.666 19.9999 13.3524 20 16.666Z"
|
||||
fill="var(--brand-color-text-muted)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Shared filter/sort helpers for the catalog pages.
|
||||
*
|
||||
* The prototype hardcoded its catalog data with a precomputed `updatedDays`
|
||||
* field. Real catalog data carries an ISO `lastUpdated` timestamp instead, so
|
||||
* these helpers derive the same buckets the prototype filtered on.
|
||||
*/
|
||||
|
||||
export const updatedBuckets: { label: string; max: number }[] = [
|
||||
{ label: "Past week", max: 7 },
|
||||
{ label: "Past month", max: 30 },
|
||||
{ label: "Past 3 months", max: 90 },
|
||||
{ label: "Past 6 months", max: 180 },
|
||||
{ label: "Older", max: Number.POSITIVE_INFINITY },
|
||||
];
|
||||
|
||||
/** Whole days between `lastUpdated` and now. Unparseable dates sort as oldest. */
|
||||
export function daysSince(lastUpdated: string | undefined): number {
|
||||
if (!lastUpdated) return Number.POSITIVE_INFINITY;
|
||||
const then = Date.parse(lastUpdated);
|
||||
if (Number.isNaN(then)) return Number.POSITIVE_INFINITY;
|
||||
return Math.max(0, Math.floor((Date.now() - then) / 86_400_000));
|
||||
}
|
||||
|
||||
export function updatedBucketOf(days: number): string {
|
||||
return updatedBuckets.find((bucket) => days <= bucket.max)?.label ?? "Older";
|
||||
}
|
||||
|
||||
export const fileBuckets: { label: string; min: number; max: number }[] = [
|
||||
{ label: "1–2 files", min: 0, max: 2 },
|
||||
{ label: "3–5 files", min: 3, max: 5 },
|
||||
{ label: "6–10 files", min: 6, max: 10 },
|
||||
{ label: "11+ files", min: 11, max: Number.POSITIVE_INFINITY },
|
||||
];
|
||||
|
||||
export function fileBucketOf(files: number): string {
|
||||
return (
|
||||
fileBuckets.find((bucket) => files >= bucket.min && files <= bucket.max)
|
||||
?.label ?? "1–2 files"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a value in a filter list, returning a new array. Extracted because
|
||||
* every catalog page repeats the same checkbox toggle behaviour.
|
||||
*/
|
||||
export function toggleValue(current: string[], option: string): string[] {
|
||||
return current.includes(option)
|
||||
? current.filter((value) => value !== option)
|
||||
: [...current, option];
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Locale table for the site's language selector.
|
||||
*
|
||||
* These codes mirror the `i18n.locales` list in `astro.config.mjs` and the
|
||||
* locale directory names used by mirrored Playbook content
|
||||
* (`website/src/content/docs/<locale>/…`). The default locale is served at the
|
||||
* site root with no prefix (`prefixDefaultLocale: false`).
|
||||
*/
|
||||
|
||||
export type SiteLocale = {
|
||||
/** Locale code / URL prefix, e.g. `es-es`. */
|
||||
code: string;
|
||||
/** Name of the language, written in that language. */
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_LOCALE = "en";
|
||||
|
||||
export const SITE_LOCALES: SiteLocale[] = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "es-es", label: "Español" },
|
||||
{ code: "ja-jp", label: "日本語" },
|
||||
{ code: "ko-kr", label: "한국어" },
|
||||
{ code: "pt-br", label: "Português do Brasil" },
|
||||
{ code: "zh-cn", label: "简体中文" },
|
||||
];
|
||||
|
||||
const LOCALE_CODES = new Set(SITE_LOCALES.map((locale) => locale.code));
|
||||
|
||||
/**
|
||||
* Split a base-relative path into its locale prefix (if any) and the remainder.
|
||||
* The default locale has no prefix, so an unprefixed path reports as default.
|
||||
*/
|
||||
export function splitLocale(relativePath: string): {
|
||||
locale: string;
|
||||
rest: string;
|
||||
} {
|
||||
const trimmed = relativePath.replace(/^\/+/, "");
|
||||
const [first, ...restParts] = trimmed.split("/");
|
||||
if (first && LOCALE_CODES.has(first) && first !== DEFAULT_LOCALE) {
|
||||
return { locale: first, rest: restParts.join("/") };
|
||||
}
|
||||
return { locale: DEFAULT_LOCALE, rest: trimmed };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { PrototypePageProps } from "./pageHref";
|
||||
|
||||
export type AwesomeCopilotPage =
|
||||
| "agents"
|
||||
| "extensions"
|
||||
| "instructions"
|
||||
| "plugins"
|
||||
| "skills";
|
||||
|
||||
const destinations = [
|
||||
{ label: "Agents", page: "agents" },
|
||||
{ label: "Instructions", page: "instructions" },
|
||||
{ label: "Playbook", page: "learning-hub-copilot-app" },
|
||||
{ label: "Skills", page: "skills" },
|
||||
{ label: "Plugins", page: "plugins" },
|
||||
{ label: "Extensions", page: "extensions" },
|
||||
] as const;
|
||||
|
||||
export function getAwesomeCopilotNavLinks(
|
||||
pageHref: PrototypePageProps["pageHref"],
|
||||
currentPage?: AwesomeCopilotPage,
|
||||
) {
|
||||
return destinations.map((destination) => ({
|
||||
label: destination.label,
|
||||
href: pageHref(destination.page),
|
||||
current: destination.page === currentPage,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Local replacement for the prototype's `PrototypePageProps`.
|
||||
*
|
||||
* The prototype resolved page links through a host-supplied `pageHref` helper.
|
||||
* On this site, routes are real URLs, so `pageHref` maps a prototype page slug
|
||||
* to the equivalent site route (honouring the configured base path).
|
||||
*/
|
||||
|
||||
const BASE = import.meta.env.BASE_URL ?? "/";
|
||||
|
||||
/** Prototype page slug → site route. Slugs not listed fall through as-is. */
|
||||
const ROUTES: Record<string, string> = {
|
||||
"": "",
|
||||
agents: "agents/",
|
||||
instructions: "instructions/",
|
||||
skills: "skills/",
|
||||
plugins: "plugins/",
|
||||
extensions: "extensions/",
|
||||
contributors: "contributors/",
|
||||
agent: "agents/",
|
||||
"learning-hub-copilot-app": "learning-hub/",
|
||||
cookbook: "learning-hub/cookbook/",
|
||||
"github-copilot-app": "learning-hub/github-copilot-app/",
|
||||
"working-with-canvas-extensions": "learning-hub/working-with-canvas-extensions/",
|
||||
"using-automations-in-copilot-app":
|
||||
"learning-hub/using-automations-in-copilot-app/",
|
||||
"what-are-agents-skills-instructions":
|
||||
"learning-hub/what-are-agents-skills-instructions/",
|
||||
"agents-and-subagents": "learning-hub/agents-and-subagents/",
|
||||
"copilot-configuration-basics": "learning-hub/copilot-configuration-basics/",
|
||||
"github-copilot-terminology-glossary":
|
||||
"learning-hub/github-copilot-terminology-glossary/",
|
||||
"cli-for-beginners": "learning-hub/cli-for-beginners/",
|
||||
"dotnet-upgrade": "agent/dotnet-upgrade/",
|
||||
};
|
||||
|
||||
export type PageHref = (page?: string) => string;
|
||||
|
||||
const withBase = (path: string) =>
|
||||
`${BASE.endsWith("/") ? BASE : `${BASE}/`}${path}`.replace(/\/{2,}/g, "/");
|
||||
|
||||
/**
|
||||
* Resolve a prototype page slug (or an explicit path) to a site URL.
|
||||
* Passing no argument returns the site root.
|
||||
*/
|
||||
export const pageHref: PageHref = (page) => {
|
||||
if (!page) return withBase("");
|
||||
const mapped = ROUTES[page];
|
||||
if (mapped !== undefined) return withBase(mapped);
|
||||
// Unknown slug: treat as an explicit path so new pages work without a map entry.
|
||||
return withBase(page.replace(/^\//, "").replace(/\/?$/, "/"));
|
||||
};
|
||||
|
||||
export type PrototypePageProps = { pageHref: PageHref };
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Client-side Pagefind adapter.
|
||||
*
|
||||
* Pagefind is generated by a post-build step (see
|
||||
* `src/integrations/pagefind-resources.ts`), so `/pagefind/pagefind.js` only
|
||||
* exists in a real build. The import is therefore lazy and guarded: in `astro
|
||||
* dev` it simply fails and search degrades to the static in-memory index.
|
||||
*/
|
||||
import { pageHref } from "./pageHref";
|
||||
import type { SearchCategory, SearchItem } from "./searchIndex";
|
||||
|
||||
type PagefindResultData = {
|
||||
url: string;
|
||||
excerpt?: string;
|
||||
meta?: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
type PagefindModule = {
|
||||
options?: (opts: Record<string, unknown>) => Promise<void>;
|
||||
init?: () => Promise<void>;
|
||||
debouncedSearch?: (
|
||||
term: string,
|
||||
options?: unknown,
|
||||
debounceMs?: number,
|
||||
) => Promise<{ results: { data: () => Promise<PagefindResultData> }[] } | null>;
|
||||
search: (
|
||||
term: string,
|
||||
options?: unknown,
|
||||
) => Promise<{ results: { data: () => Promise<PagefindResultData> }[] }>;
|
||||
};
|
||||
|
||||
/** Site base path, matching the value bootstrapped by `BaseLayout.astro`. */
|
||||
function basePath(): string {
|
||||
const fromDocument =
|
||||
typeof document !== "undefined"
|
||||
? document.documentElement.dataset.basePath
|
||||
: undefined;
|
||||
const base = fromDocument || pageHref();
|
||||
return base.endsWith("/") ? base : `${base}/`;
|
||||
}
|
||||
|
||||
let modulePromise: Promise<PagefindModule | null> | null = null;
|
||||
|
||||
/** Load (once) the Pagefind bundle, or resolve to `null` when unavailable. */
|
||||
export function loadPagefind(): Promise<PagefindModule | null> {
|
||||
if (modulePromise) return modulePromise;
|
||||
modulePromise = (async () => {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const url = `${basePath()}pagefind/pagefind.js`;
|
||||
const mod: PagefindModule = await import(/* @vite-ignore */ url);
|
||||
await mod.options?.({ baseUrl: basePath() });
|
||||
await mod.init?.();
|
||||
return mod;
|
||||
} catch {
|
||||
// No index (dev server, or a build that skipped Pagefind) — the caller
|
||||
// falls back to the static index.
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return modulePromise;
|
||||
}
|
||||
|
||||
const CATEGORY_BY_SEGMENT: Record<string, SearchCategory> = {
|
||||
agent: "Agents",
|
||||
agents: "Agents",
|
||||
instruction: "Instructions",
|
||||
instructions: "Instructions",
|
||||
skill: "Skills",
|
||||
skills: "Skills",
|
||||
plugin: "Plugins",
|
||||
plugins: "Plugins",
|
||||
extension: "Extensions",
|
||||
extensions: "Extensions",
|
||||
"learning-hub": "Articles",
|
||||
};
|
||||
|
||||
/** Derive a result category from the result URL, relative to the base path. */
|
||||
function categoryOf(pathname: string): SearchCategory {
|
||||
const base = basePath();
|
||||
const relative = pathname.startsWith(base)
|
||||
? pathname.slice(base.length)
|
||||
: pathname.replace(/^\//, "");
|
||||
const segments = relative.split("/").filter(Boolean);
|
||||
if (segments.length === 0) return "Pages";
|
||||
// A bare section landing page (e.g. /skills/) is a destination, not a record.
|
||||
if (segments.length === 1 && !CATEGORY_BY_SEGMENT[segments[0]]) return "Pages";
|
||||
if (segments.length === 1) return "Pages";
|
||||
return CATEGORY_BY_SEGMENT[segments[0]] ?? "Pages";
|
||||
}
|
||||
|
||||
/** Pagefind custom records carry a `Title — Agent` style suffix; strip it. */
|
||||
function cleanTitle(title: string): string {
|
||||
return title.replace(/\s+—\s+(Agent|Instruction|Skill|Hook|Workflow|Plugin|Tool)$/u, "");
|
||||
}
|
||||
|
||||
/** Normalised key used to dedupe against the static index. */
|
||||
export function hrefKey(href: string): string {
|
||||
try {
|
||||
const url = new URL(href, window.location.origin);
|
||||
return url.pathname.replace(/\/+$/, "").toLowerCase() || "/";
|
||||
} catch {
|
||||
return href.replace(/\/+$/, "").toLowerCase() || "/";
|
||||
}
|
||||
}
|
||||
|
||||
/** Run a Pagefind query and adapt the hits to `SearchItem`s. */
|
||||
export async function searchPagefind(
|
||||
term: string,
|
||||
limit: number,
|
||||
): Promise<SearchItem[]> {
|
||||
const pagefind = await loadPagefind();
|
||||
if (!pagefind) return [];
|
||||
try {
|
||||
const response = pagefind.debouncedSearch
|
||||
? await pagefind.debouncedSearch(term, undefined, 180)
|
||||
: await pagefind.search(term);
|
||||
// `debouncedSearch` resolves to null when superseded by a newer query.
|
||||
if (!response) return [];
|
||||
const data = await Promise.all(
|
||||
response.results.slice(0, limit).map((result) => result.data()),
|
||||
);
|
||||
const items: SearchItem[] = [];
|
||||
for (const entry of data) {
|
||||
if (!entry?.url) continue;
|
||||
const title = cleanTitle(entry.meta?.title?.trim() || entry.url);
|
||||
if (!title) continue;
|
||||
items.push({
|
||||
title,
|
||||
description: entry.excerpt?.replace(/<[^>]*>/g, "") ?? "",
|
||||
category: categoryOf(entry.url.split(/[?#]/)[0]),
|
||||
href: entry.url,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Search index adapter.
|
||||
*
|
||||
* The prototype shipped a hand-written `searchIndex.ts`. Here the same shape is
|
||||
* produced from the site's generated `public/data/search-index.json` plus the
|
||||
* fixed top-level destinations, so `TopNavSearch` searches the real library.
|
||||
*/
|
||||
|
||||
export type SearchCategory =
|
||||
| "Pages"
|
||||
| "Articles"
|
||||
| "Agents"
|
||||
| "Instructions"
|
||||
| "Skills"
|
||||
| "Plugins"
|
||||
| "Extensions";
|
||||
|
||||
export type SearchItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
category: SearchCategory;
|
||||
/** Resolved site URL for the result. */
|
||||
href: string;
|
||||
};
|
||||
|
||||
/** Generated record shape from `eng/generate-website-data.mjs`. */
|
||||
export type GeneratedSearchRecord = {
|
||||
type: string;
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
const CATEGORY_BY_TYPE: Record<string, SearchCategory> = {
|
||||
agent: "Agents",
|
||||
instruction: "Instructions",
|
||||
skill: "Skills",
|
||||
plugin: "Plugins",
|
||||
extension: "Extensions",
|
||||
article: "Articles",
|
||||
};
|
||||
|
||||
const DETAIL_ROUTE_BY_TYPE: Record<string, string> = {
|
||||
agent: "agent",
|
||||
instruction: "instruction",
|
||||
skill: "skill",
|
||||
plugin: "plugin",
|
||||
extension: "extension",
|
||||
};
|
||||
|
||||
/** Top-level destinations so search always surfaces the main sections. */
|
||||
export const staticPages = (base: string): SearchItem[] => {
|
||||
const at = (path: string) => `${base}${path}`.replace(/\/{2,}/g, "/");
|
||||
return [
|
||||
{
|
||||
title: "Home",
|
||||
description:
|
||||
"The Awesome GitHub Copilot library home — browse agents, instructions, skills, plugins, and extensions.",
|
||||
category: "Pages",
|
||||
href: at("/"),
|
||||
},
|
||||
{
|
||||
title: "Agents",
|
||||
description:
|
||||
"Ready-to-use custom agents for GitHub Copilot — specialized assistants for focused tasks.",
|
||||
category: "Pages",
|
||||
href: at("/agents/"),
|
||||
},
|
||||
{
|
||||
title: "Instructions",
|
||||
description:
|
||||
"Repository and language instruction files that steer GitHub Copilot toward your conventions.",
|
||||
category: "Pages",
|
||||
href: at("/instructions/"),
|
||||
},
|
||||
{
|
||||
title: "Skills",
|
||||
description:
|
||||
"Self-contained skill folders that bundle instructions and resources together.",
|
||||
category: "Pages",
|
||||
href: at("/skills/"),
|
||||
},
|
||||
{
|
||||
title: "Plugins",
|
||||
description:
|
||||
"Installable plugin packages that group related agents, hooks, and skills.",
|
||||
category: "Pages",
|
||||
href: at("/plugins/"),
|
||||
},
|
||||
{
|
||||
title: "Extensions",
|
||||
description:
|
||||
"Interactive canvas extensions that enrich the GitHub Copilot app experience.",
|
||||
category: "Pages",
|
||||
href: at("/extensions/"),
|
||||
},
|
||||
{
|
||||
title: "Playbook",
|
||||
description:
|
||||
"Articles and guides for getting the most from every agent, skill, and instruction.",
|
||||
category: "Pages",
|
||||
href: at("/learning-hub/"),
|
||||
},
|
||||
{
|
||||
title: "Contributors",
|
||||
description: "The people who build and maintain the community library.",
|
||||
category: "Pages",
|
||||
href: at("/contributors/"),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
/** Convert generated records into `TopNavSearch` items. */
|
||||
export function buildSearchIndex(
|
||||
records: GeneratedSearchRecord[],
|
||||
base = "/",
|
||||
): SearchItem[] {
|
||||
const at = (path: string) => `${base}${path}`.replace(/\/{2,}/g, "/");
|
||||
const items: SearchItem[] = [];
|
||||
for (const record of records) {
|
||||
const category = CATEGORY_BY_TYPE[record.type];
|
||||
if (!category) continue;
|
||||
const route = DETAIL_ROUTE_BY_TYPE[record.type];
|
||||
items.push({
|
||||
title: record.title,
|
||||
description: record.description ?? "",
|
||||
category,
|
||||
href: route ? at(`/${route}/${record.id}/`) : at("/"),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The hero community icon links directly to the full contributors page. */
|
||||
.wrapper {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
line-height: 0;
|
||||
cursor: pointer;
|
||||
border-radius: var(--brand-borderRadius-full);
|
||||
}
|
||||
|
||||
.trigger:focus-visible {
|
||||
outline: var(--brand-borderWidth-thick) solid var(--brand-color-focus);
|
||||
outline-offset: var(--base-size-4);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--base-size-6);
|
||||
block-size: var(--base-size-32);
|
||||
padding-inline: var(--base-size-12);
|
||||
border: 0;
|
||||
border-radius: var(--brand-borderRadius-full);
|
||||
background-color: transparent;
|
||||
color: var(--brand-color-text-default);
|
||||
font-family: var(--brand-fontStack-sansSerif);
|
||||
font-size: var(--brand-text-size-100);
|
||||
font-weight: var(--brand-text-weight-semibold);
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.16s ease;
|
||||
}
|
||||
|
||||
.trigger:hover {
|
||||
background-color: var(--brand-color-canvas-subtle);
|
||||
}
|
||||
|
||||
.trigger:focus-visible {
|
||||
outline: var(--brand-borderWidth-thick) solid var(--brand-color-focus);
|
||||
outline-offset: var(--base-size-2);
|
||||
}
|
||||
|
||||
.heart {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--brand-color-success-fg);
|
||||
line-height: 1;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
/* The heart only beats while the contributors button (heart + count) is
|
||||
hovered or keyboard-focused, so the motion is intentional, not ambient. */
|
||||
.trigger:hover .heart,
|
||||
.trigger:focus-visible .heart {
|
||||
animation: heartbeat 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Lub-dub pulse: two quick beats early in the cycle, then a long rest
|
||||
before it repeats — reads like a resting human heartbeat. */
|
||||
@keyframes heartbeat {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
10% {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
20% {
|
||||
transform: scale(1);
|
||||
}
|
||||
30% {
|
||||
transform: scale(1.35);
|
||||
}
|
||||
42% {
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.countLabel {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trigger:hover .heart,
|
||||
.trigger:focus-visible .heart {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/* "Skip to main content" link. Off-screen until focused, then slides into the
|
||||
top-left corner on top of everything else. All colors and sizes use Primer
|
||||
Brand tokens so it adapts across light and dark color modes. */
|
||||
.skipLink {
|
||||
position: fixed;
|
||||
top: var(--base-size-8);
|
||||
left: var(--base-size-8);
|
||||
z-index: 1000;
|
||||
display: inline-block;
|
||||
padding: var(--base-size-8) var(--base-size-16);
|
||||
background-color: var(--brand-color-canvas-default);
|
||||
color: var(--brand-color-text-link-rest);
|
||||
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-default);
|
||||
border-radius: var(--brand-borderRadius-medium);
|
||||
font-family: var(--brand-fontStack-sansSerif);
|
||||
font-size: var(--brand-text-size-200);
|
||||
font-weight: var(--brand-text-weight-600);
|
||||
line-height: var(--brand-text-lineHeight-200);
|
||||
text-decoration: none;
|
||||
/* Park the link above the viewport until it is focused. */
|
||||
transform: translateY(calc(-100% - var(--base-size-16)));
|
||||
transition: transform 0.16s ease-in-out;
|
||||
}
|
||||
|
||||
.skipLink:focus,
|
||||
.skipLink:focus-visible {
|
||||
transform: translateY(0);
|
||||
outline: var(--brand-borderWidth-thick) solid var(--brand-color-focus);
|
||||
outline-offset: var(--base-size-2);
|
||||
}
|
||||
|
||||
/* The skip target is a large region; focusing it should not paint a full-width
|
||||
ring — the scroll + context change already signals the jump. */
|
||||
:global(#main-content:focus) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skipLink {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* Mobile navigation. Above 768px the desktop `.subNavList` (Resources dropdown
|
||||
* + Playbook tab) is shown and this hamburger is hidden; at/below 768px each
|
||||
* page module hides `.subNavList`, and this disclosure takes over as the mobile
|
||||
* menu. Styling mirrors the desktop `.moreOverlay` / `.moreLink` dropdown so the
|
||||
* two navs feel like one system.
|
||||
*/
|
||||
.mobileMenu {
|
||||
display: none;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
/* Sit at the far right of the header row (after the heart + Contribute
|
||||
actions) so the right-anchored dropdown opens inward and stays on screen. */
|
||||
order: 5;
|
||||
margin-inline-end: var(--base-size-16);
|
||||
}
|
||||
|
||||
.mobileTrigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--base-size-40);
|
||||
height: var(--base-size-40);
|
||||
color: var(--brand-color-text-default);
|
||||
background-color: transparent;
|
||||
border: var(--brand-borderWidth-thin) solid transparent;
|
||||
border-radius: var(--brand-borderRadius-medium);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.mobileTrigger::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobileTrigger:hover {
|
||||
background-color: var(--brand-color-canvas-subtle);
|
||||
}
|
||||
|
||||
.mobileTrigger:focus-visible {
|
||||
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
|
||||
outline-offset: var(--base-size-2);
|
||||
}
|
||||
|
||||
.triggerBars {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.triggerClose {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobileMenu[open] .triggerBars {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobileMenu[open] .triggerClose {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.mobileOverlay {
|
||||
position: absolute;
|
||||
/* Meet the top bar flush: the panel's top edge lands on the top bar's own
|
||||
bottom border, so nav and menu read as one connected surface (no gap). */
|
||||
inset-block-start: 100%;
|
||||
/* Break out of the right-anchored menu so the panel spans the full mobile
|
||||
viewport: the trigger sits 16px + the top bar's 1px border from the screen
|
||||
edge, so pull the panel's end edge out by that much and size it to 100vw. */
|
||||
inset-inline-end: calc(
|
||||
-1 * (var(--base-size-16) + var(--brand-borderWidth-thin))
|
||||
);
|
||||
z-index: 30;
|
||||
inline-size: 100vw;
|
||||
max-inline-size: 100vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--base-size-2);
|
||||
padding: var(--base-size-8);
|
||||
background-color: var(--brand-color-canvas-subtle);
|
||||
border-block: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
box-shadow: var(--brand-SubNav-shadow);
|
||||
}
|
||||
|
||||
.overlayLabel {
|
||||
padding-block: var(--base-size-8) var(--base-size-4);
|
||||
padding-inline: var(--base-size-12);
|
||||
font-family: var(--brand-fontStack-monospace);
|
||||
font-size: var(--brand-text-size-100);
|
||||
font-weight: var(--brand-text-weight-medium);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--brand-color-text-muted);
|
||||
}
|
||||
|
||||
/* Site-wide search relocated into the mobile menu (the desktop top-bar search
|
||||
is hidden below 48rem). Sits at the top of the panel so search is the first
|
||||
thing available when the menu opens. */
|
||||
.mobileSearch {
|
||||
padding-block: var(--base-size-4);
|
||||
padding-inline: var(--base-size-12);
|
||||
}
|
||||
|
||||
.mobileLink {
|
||||
padding-block: var(--base-size-8);
|
||||
padding-inline: var(--base-size-12);
|
||||
font-family: var(--brand-fontStack-sansSerif);
|
||||
font-size: var(--brand-text-size-200);
|
||||
font-weight: var(--brand-text-weight-normal);
|
||||
line-height: var(--brand-text-lineHeight-200);
|
||||
color: var(--brand-color-text-default);
|
||||
text-decoration: none;
|
||||
border-radius: var(--brand-borderRadius-medium);
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.mobileLink:hover {
|
||||
color: var(--brand-color-text-default);
|
||||
background-color: var(--brand-color-canvas-subtle);
|
||||
}
|
||||
|
||||
.mobileLink:focus-visible {
|
||||
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
|
||||
outline-offset: calc(var(--base-size-2) * -1);
|
||||
}
|
||||
|
||||
.mobileLink[aria-current="page"],
|
||||
.mobileLink[aria-current="page"]:hover {
|
||||
color: var(--brand-color-text-default);
|
||||
font-weight: var(--brand-text-weight-semibold);
|
||||
background-color: var(--brand-color-canvas-subtle);
|
||||
}
|
||||
|
||||
.overlayDivider {
|
||||
height: var(--brand-borderWidth-thin);
|
||||
margin-block: var(--base-size-8);
|
||||
margin-inline: var(--base-size-12);
|
||||
background-color: var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
/* Action cluster relocated from the header bar on mobile: the contributors
|
||||
heart and the Contribute button, stacked (heart above, Contribute below)
|
||||
under the nav links so the full-width panel reads top-to-bottom. */
|
||||
.mobileActions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--base-size-12);
|
||||
padding-block: var(--base-size-8);
|
||||
padding-inline: var(--base-size-12);
|
||||
}
|
||||
|
||||
/* The contributors heart button carries its own inline padding for the header
|
||||
pill; in the stacked mobile menu that indents the heart past the nav links.
|
||||
Drop the leading pad here so the heart lines up flush with the links, the
|
||||
RESOURCES label and the site title (issue #18). The Contribute button's box
|
||||
already sits on that same content edge. */
|
||||
.mobileActions :global(button[class*="trigger"]) {
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
|
||||
/* On the tablet band the top bar is inset from the screen edge by the page
|
||||
gutter, so the right-anchored breakout above (which only clears the trigger's
|
||||
16px + border) leaves the full-width panel hanging off the left of the screen.
|
||||
Pull the end edge out by the gutter as well so the 100vw panel stays aligned
|
||||
to the viewport here too. Phones (<48rem) have no gutter and keep the base
|
||||
value above. */
|
||||
@media screen and (min-width: 48rem) and (max-width: 74.99rem) {
|
||||
.mobileOverlay {
|
||||
inset-inline-end: calc(
|
||||
-1 *
|
||||
(
|
||||
var(--page-gutter) + var(--base-size-16) +
|
||||
var(--brand-borderWidth-thin)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* Show the hamburger across phones AND the tablet band (up to 74.99rem/1199px):
|
||||
the full desktop nav row doesn't fit below ~1200px — it wraps to two rows and
|
||||
opening search pushes the GitHub wordmark off-screen (issue #19). Each page
|
||||
module hides its desktop `.subNavList` / `.topBarActions` over the same range,
|
||||
so this disclosure takes over as the nav through the whole band. */
|
||||
@media screen and (max-width: 74.99rem) {
|
||||
.mobileMenu {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/* Dropdown styling for the shared top-navigation search. The search field,
|
||||
* button, and ⌘K hint reuse each page's own scoped classes (passed in via
|
||||
* `styles`); only the results popover lives here so it looks identical on
|
||||
* every page. */
|
||||
|
||||
.wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.results {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
inset-block-start: calc(100% + var(--base-size-8));
|
||||
inset-inline-end: 0;
|
||||
inline-size: 22rem;
|
||||
max-inline-size: min(22rem, 90vw);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--base-size-16);
|
||||
padding: var(--base-size-16);
|
||||
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted);
|
||||
background-color: var(--brand-color-canvas-muted);
|
||||
box-shadow: var(--brand-SubNav-shadow);
|
||||
text-align: start;
|
||||
animation: topNavSearchResultsIn 0.16s ease;
|
||||
}
|
||||
|
||||
@keyframes topNavSearchResultsIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(calc(var(--base-size-6) * -1));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.results {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.groupLabel {
|
||||
margin: 0 0 var(--base-size-4);
|
||||
font-family: var(--brand-fontStack-sansSerif);
|
||||
font-size: var(--brand-text-size-100);
|
||||
font-weight: var(--brand-text-weight-normal);
|
||||
color: var(--brand-color-text-muted);
|
||||
}
|
||||
|
||||
.result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--base-size-12);
|
||||
padding-block: var(--base-size-8);
|
||||
border-block-start: var(--brand-borderWidth-thin) dashed
|
||||
var(--brand-color-border-subtle);
|
||||
color: var(--brand-color-text-default);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.result:first-of-type {
|
||||
border-block-start: 0;
|
||||
}
|
||||
|
||||
.result:focus-visible {
|
||||
outline: var(--brand-borderWidth-thick) solid var(--brand-color-focus);
|
||||
outline-offset: calc(var(--base-size-2) * -1);
|
||||
border-radius: var(--brand-borderRadius-small);
|
||||
}
|
||||
|
||||
.resultText {
|
||||
font-family: var(--brand-fontStack-sansSerif);
|
||||
font-size: var(--brand-text-size-100);
|
||||
font-weight: var(--brand-text-weight-semibold);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.resultIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--brand-color-text-muted);
|
||||
}
|
||||
|
||||
.result:hover .resultText,
|
||||
.result:focus-visible .resultText,
|
||||
.resultActive .resultText,
|
||||
.result:hover .resultIcon,
|
||||
.result:focus-visible .resultIcon,
|
||||
.resultActive .resultIcon {
|
||||
color: var(--brand-color-text-link-rest);
|
||||
}
|
||||
|
||||
.resultActive {
|
||||
background-color: var(--brand-color-canvas-default);
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
font-family: var(--brand-fontStack-sansSerif);
|
||||
font-size: var(--brand-text-size-100);
|
||||
color: var(--brand-color-text-muted);
|
||||
}
|
||||
|
||||
/* Inline variant: an always-open, full-width search used inside the mobile
|
||||
* hamburger menu (where the desktop top-bar search is hidden). The field and
|
||||
* results flow in normal document order so search reads as the first item of
|
||||
* the menu, keeping an equivalent search UX at every viewport. */
|
||||
.inlineWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--base-size-8);
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.inlineField {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--base-size-8);
|
||||
inline-size: 100%;
|
||||
block-size: var(--base-size-40);
|
||||
padding-inline: var(--base-size-12);
|
||||
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted);
|
||||
border-radius: var(--brand-borderRadius-medium);
|
||||
background-color: var(--brand-color-canvas-default);
|
||||
color: var(--brand-color-text-muted);
|
||||
}
|
||||
|
||||
.inlineField:focus-within {
|
||||
border-color: var(--brand-color-focus);
|
||||
}
|
||||
|
||||
.inlineIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--brand-color-text-muted);
|
||||
}
|
||||
|
||||
.inlineInput {
|
||||
flex: 1;
|
||||
min-inline-size: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font-family: var(--brand-fontStack-sansSerif);
|
||||
font-size: var(--brand-text-size-200);
|
||||
font-weight: var(--brand-text-weight-normal);
|
||||
line-height: var(--brand-text-lineHeight-200);
|
||||
color: var(--brand-color-text-default);
|
||||
}
|
||||
|
||||
.inlineInput::placeholder {
|
||||
color: var(--brand-color-text-muted);
|
||||
}
|
||||
|
||||
.inlineInput:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.inlineResults {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--base-size-16);
|
||||
padding: var(--base-size-12);
|
||||
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted);
|
||||
border-radius: var(--brand-borderRadius-medium);
|
||||
background-color: var(--brand-color-canvas-muted);
|
||||
text-align: start;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
inline-size: var(--base-size-4);
|
||||
block-size: var(--base-size-4);
|
||||
padding: 0;
|
||||
margin: calc(-1 * var(--base-size-4));
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/* Video series carousel — mirrors the "Get started" carousel on the Copilot
|
||||
Playbook page (bordered band, header with prev/next, connected 3-up cards),
|
||||
adapted to hold YouTube video thumbnails that open in a modal player. */
|
||||
|
||||
.carousel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--brand-color-canvas-default);
|
||||
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--base-size-16);
|
||||
padding: var(--base-size-16) var(--base-size-24);
|
||||
background-color: var(--brand-color-canvas-muted);
|
||||
}
|
||||
|
||||
.header .headerLabel {
|
||||
margin: 0;
|
||||
font-family: var(--brand-fontStack-monospace);
|
||||
font-size: var(--brand-text-size-100);
|
||||
font-weight: var(--brand-text-weight-regular);
|
||||
letter-spacing: var(--brand-text-letterSpacing-100);
|
||||
text-transform: uppercase;
|
||||
color: var(--brand-color-text-muted);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--base-size-24);
|
||||
}
|
||||
|
||||
.navButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--base-size-4);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--brand-color-text-default);
|
||||
cursor: pointer;
|
||||
border-radius: var(--brand-borderRadius-small);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.navButton:hover {
|
||||
color: var(--brand-color-text-default);
|
||||
}
|
||||
|
||||
.navButton:focus-visible {
|
||||
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
|
||||
outline-offset: var(--base-size-2);
|
||||
}
|
||||
|
||||
.track {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
overflow: hidden;
|
||||
border-block-start: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
animation: videoPageFade 0.28s var(--brand-animation-easing-default, ease);
|
||||
}
|
||||
|
||||
@keyframes videoPageFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.track {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
flex: 0 0 calc(100% / 3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--base-size-16);
|
||||
padding: var(--base-size-24);
|
||||
margin: 0;
|
||||
text-align: start;
|
||||
border: 0;
|
||||
background-color: var(--brand-color-canvas-default);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--brand-animation-duration-extended)
|
||||
var(--brand-animation-easing-default);
|
||||
}
|
||||
|
||||
/* Keep an integer number of cards visible so the row never cuts a card
|
||||
mid-width. Steps 3-up -> 2-up -> 1-up as the container narrows. */
|
||||
@media screen and (max-width: 56.24rem) {
|
||||
.card {
|
||||
flex-basis: calc(100% / 2);
|
||||
}
|
||||
|
||||
/* Full row is now 2-up: reset the 3-up edge rule and add a right edge only
|
||||
when the trailing card doesn't complete a pair. */
|
||||
.card:last-child:not(:nth-child(3n)) {
|
||||
border-inline-end: 0;
|
||||
}
|
||||
|
||||
.card:last-child:not(:nth-child(2n)) {
|
||||
border-inline-end: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 37.49rem) {
|
||||
.card {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
/* 1-up: every card fills the width, so the carousel border is the right edge. */
|
||||
.card:last-child:not(:nth-child(2n)),
|
||||
.card:last-child:not(:nth-child(3n)) {
|
||||
border-inline-end: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card:not(:first-child) {
|
||||
border-inline-start: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
/* When the last row isn't full (e.g. a single video), the trailing card still
|
||||
needs a right edge so it reads as a complete card rather than an open band.
|
||||
Skip it when the card already fills to the container edge (a full 3-up row),
|
||||
where the carousel's own border supplies the right edge. */
|
||||
.card:last-child:not(:nth-child(3n)) {
|
||||
border-inline-end: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
.card:is(:hover, :focus-visible) {
|
||||
background-color: var(--brand-color-canvas-subtle);
|
||||
}
|
||||
|
||||
.card:focus-visible {
|
||||
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
|
||||
outline-offset: calc(-1 * var(--base-size-4));
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted);
|
||||
border-radius: var(--brand-borderRadius-medium);
|
||||
background-color: var(--brand-color-canvas-subtle);
|
||||
}
|
||||
|
||||
.thumbImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: transform var(--brand-animation-duration-extended)
|
||||
var(--brand-animation-easing-default);
|
||||
}
|
||||
|
||||
.card:is(:hover, :focus-visible) .thumbImage {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.playBadge {
|
||||
position: absolute;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: var(--base-size-48);
|
||||
block-size: var(--base-size-48);
|
||||
border-radius: var(--brand-borderRadius-full);
|
||||
color: var(--base-color-scale-white-0);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--base-color-scale-black-0) 68%,
|
||||
transparent
|
||||
);
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.card:is(:hover, :focus-visible) .playBadge {
|
||||
background-color: var(--base-color-scale-green-5);
|
||||
transform: translate(-50%, -50%) scale(1.06);
|
||||
}
|
||||
|
||||
.cardMeta {
|
||||
margin: 0;
|
||||
font-family: var(--brand-fontStack-monospace);
|
||||
font-size: var(--brand-text-size-100);
|
||||
font-weight: var(--brand-text-weight-regular);
|
||||
letter-spacing: var(--brand-text-letterSpacing-100);
|
||||
text-transform: uppercase;
|
||||
color: var(--brand-color-text-muted);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-family: var(--brand-fontStack-sansSerif);
|
||||
font-size: var(--brand-text-size-300);
|
||||
font-weight: var(--brand-text-weight-600, 600);
|
||||
line-height: var(--brand-text-lineHeight-300);
|
||||
color: var(--brand-color-text-default);
|
||||
}
|
||||
|
||||
/* ---- Modal player ---------------------------------------------------- */
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset-block-start: 0;
|
||||
inset-inline: 0;
|
||||
/* Use the dynamic viewport height so the dialog stays centred in the visible
|
||||
area on mobile, where the browser chrome shrinks the layout viewport. */
|
||||
block-size: 100dvh;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--base-size-24);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--base-color-scale-black-0) 72%,
|
||||
transparent
|
||||
);
|
||||
animation: videoBackdropIn 0.18s ease-out both;
|
||||
}
|
||||
|
||||
@keyframes videoBackdropIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
inline-size: min(60rem, 100%);
|
||||
max-block-size: 100%;
|
||||
overflow: hidden;
|
||||
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted);
|
||||
background-color: var(--brand-color-canvas-default);
|
||||
box-shadow: var(--brand-River-visual-shadow);
|
||||
animation: videoDialogIn 0.22s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@keyframes videoDialogIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(var(--base-size-8));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.dialogHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--base-size-16);
|
||||
padding: var(--base-size-16) var(--base-size-24);
|
||||
border-block-end: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
.dialogTitle {
|
||||
margin: 0;
|
||||
font-family: var(--brand-fontStack-sansSerif);
|
||||
font-size: var(--brand-text-size-300);
|
||||
font-weight: var(--brand-text-weight-600, 600);
|
||||
line-height: var(--brand-text-lineHeight-300);
|
||||
color: var(--brand-color-text-default);
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--base-size-8);
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
border: var(--brand-borderWidth-thick) solid var(--brand-color-border-default);
|
||||
border-radius: var(--brand-borderRadius-medium);
|
||||
background-color: var(--brand-color-canvas-subtle);
|
||||
line-height: 1;
|
||||
color: var(--brand-color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.closeButton:hover {
|
||||
color: var(--brand-color-text-default);
|
||||
border-color: var(--brand-color-border-muted);
|
||||
background-color: var(--brand-color-canvas-default);
|
||||
}
|
||||
|
||||
.closeButton:focus-visible {
|
||||
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
|
||||
outline-offset: var(--base-size-2);
|
||||
}
|
||||
|
||||
.videoFrame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background-color: var(--base-color-scale-black-0);
|
||||
}
|
||||
|
||||
.videoFrame iframe {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
.hero {
|
||||
inline-size: 100%;
|
||||
min-block-size: 17rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
border-block-end: var(--brand-borderWidth-thick) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
.heroInner,
|
||||
.sectionInner {
|
||||
inline-size: var(--brand-breakpoint-xlarge);
|
||||
max-inline-size: 100%;
|
||||
margin-inline: auto;
|
||||
border-inline: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
.heroInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--base-size-24);
|
||||
padding-block: var(--base-size-80);
|
||||
padding-inline: var(--base-size-24);
|
||||
background-color: var(--brand-color-canvas-subtle);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.heroHeading {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.heroText {
|
||||
max-inline-size: var(--brand-breakpoint-small);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
border-block-end: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.gridCell {
|
||||
min-inline-size: 0;
|
||||
border-inline-end: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
border-block-end: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
.gridCell:nth-child(6n) {
|
||||
border-inline-end: 0;
|
||||
}
|
||||
|
||||
.contributorLink {
|
||||
display: flex;
|
||||
padding: var(--base-size-24);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: var(--base-size-8);
|
||||
color: var(--brand-color-text-default);
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition: background-color var(--brand-animation-duration-fast)
|
||||
var(--brand-animation-easing-default);
|
||||
}
|
||||
|
||||
.contributorLink:hover {
|
||||
background-color: var(--brand-color-canvas-subtle);
|
||||
}
|
||||
|
||||
.contributorLink:focus-visible {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
outline: var(--brand-borderWidth-thick) solid
|
||||
var(--brand-color-focus);
|
||||
outline-offset: calc(var(--brand-borderWidth-thick) * -1);
|
||||
}
|
||||
|
||||
.contributorName,
|
||||
.contributorLogin {
|
||||
max-inline-size: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.contributorName {
|
||||
font-family: var(--brand-fontStack-sansSerif);
|
||||
font-size: var(--brand-text-size-200);
|
||||
font-weight: var(--brand-text-weight-semibold);
|
||||
}
|
||||
|
||||
.contributorLogin {
|
||||
font-family: var(--brand-fontStack-sansSerif);
|
||||
font-size: var(--brand-text-size-100);
|
||||
color: var(--brand-color-text-muted);
|
||||
}
|
||||
|
||||
.backToTop {
|
||||
position: fixed;
|
||||
inset-block-end: var(--base-size-32);
|
||||
inset-inline-end: var(--base-size-32);
|
||||
z-index: 30;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: var(--brand-control-large-size);
|
||||
block-size: var(--brand-control-large-size);
|
||||
padding: 0;
|
||||
border: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
border-radius: var(--brand-borderRadius-full);
|
||||
background-color: var(--brand-color-canvas-subtle);
|
||||
color: var(--brand-color-text-default);
|
||||
box-shadow: var(--brand-SubNav-shadow);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transform: translateY(var(--base-size-8));
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity var(--brand-animation-duration-fast)
|
||||
var(--brand-animation-easing-default),
|
||||
transform var(--brand-animation-duration-fast)
|
||||
var(--brand-animation-easing-default);
|
||||
}
|
||||
|
||||
.backToTopVisible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.backToTop:hover {
|
||||
background-color: var(--brand-color-canvas-default);
|
||||
}
|
||||
|
||||
.backToTop:focus-visible {
|
||||
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
|
||||
outline-offset: var(--base-size-2);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 119.99rem) {
|
||||
.heroInner,
|
||||
.sectionInner {
|
||||
inline-size: auto;
|
||||
max-inline-size: none;
|
||||
margin-inline: var(--page-gutter);
|
||||
}
|
||||
|
||||
.heroInner {
|
||||
flex: 1 1 auto;
|
||||
padding-block: var(--page-pad-block);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 63.99rem) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.gridCell {
|
||||
border-inline-end: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
.gridCell:nth-child(4n) {
|
||||
border-inline-end: 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 47.99rem) {
|
||||
.heroInner,
|
||||
.sectionInner {
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.gridCell {
|
||||
border-inline-end: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
.gridCell:nth-child(2n) {
|
||||
border-inline-end: 0;
|
||||
}
|
||||
|
||||
.contributorLink {
|
||||
padding: var(--base-size-16);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.contributorLink {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.backToTop {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/* File switcher for skill bundles.
|
||||
|
||||
Skills are folders, so the content region is a browser rather than a single
|
||||
article. The tab strip reuses the prototype's pill-tab treatment
|
||||
(`.subNavLink` / `.subNavLinkActive` in dotnet-upgrade.module.css) via
|
||||
`composes`, so there is no new visual language here — only the layout the
|
||||
prototype has no equivalent for. */
|
||||
|
||||
.fileTabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--base-size-8);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.fileTab {
|
||||
composes: subNavLink from "./dotnet-upgrade.module.css";
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-family: var(--brand-fontStack-monospace);
|
||||
}
|
||||
|
||||
.fileTabActive {
|
||||
composes: subNavLinkActive from "./dotnet-upgrade.module.css";
|
||||
}
|
||||
|
||||
/* Meta row above the rendered file: path, size, per-file GitHub link. */
|
||||
.fileBar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--base-size-12);
|
||||
padding-block-end: var(--base-size-12);
|
||||
border-block-end: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
.filePath {
|
||||
font-family: var(--brand-fontStack-monospace);
|
||||
font-size: var(--brand-text-size-100);
|
||||
color: var(--brand-color-text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fileActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--base-size-8);
|
||||
}
|
||||
|
||||
.fileImage {
|
||||
max-inline-size: 100%;
|
||||
block-size: auto;
|
||||
border-radius: var(--brand-borderRadius-medium);
|
||||
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted);
|
||||
}
|
||||
@@ -0,0 +1,793 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
/*
|
||||
* Responsive page gutters (Figma "card" spec). A fixed side inset and block
|
||||
* padding per breakpoint tier, exposed as variables so every section band
|
||||
* below can share them:
|
||||
* <768px → L/R 20px · T/B 40px
|
||||
* 768–1279px → L/R 91px · T/B 40px
|
||||
* 1280–1919px → L/R 152px · T/B 60px
|
||||
* ≥1920px → variables stop applying → native centered-1280 layout
|
||||
* (91/152/60 have no base-size token, so they are raw px.)
|
||||
*/
|
||||
--page-gutter: var(--base-size-20);
|
||||
--page-pad-block: var(--base-size-40);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 48rem) and (max-width: 79.99rem) {
|
||||
.page {
|
||||
--page-gutter: 91px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 80rem) and (max-width: 119.99rem) {
|
||||
.page {
|
||||
--page-gutter: 152px;
|
||||
--page-pad-block: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Combined top navigation: brand mark, title, divider, content-type tabs,
|
||||
and trailing actions, framed by the same gridlines as the hero. Ported from
|
||||
the catalog pages. */
|
||||
.topBar {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
border-block-end: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
.topBarInner {
|
||||
width: 1280px;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-inline-start: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
border-inline-end: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
.brandMark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-inline-start: var(--base-size-24);
|
||||
padding-inline-end: var(--base-size-24);
|
||||
color: var(--brand-color-text-default);
|
||||
border-inline-end: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
.brandMark:focus-visible {
|
||||
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
|
||||
outline-offset: var(--base-size-4);
|
||||
}
|
||||
|
||||
.topBarActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--base-size-12);
|
||||
padding-inline-start: var(--base-size-16);
|
||||
padding-inline-end: var(--base-size-24);
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: var(--base-size-32);
|
||||
block-size: var(--base-size-32);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--brand-borderRadius-medium);
|
||||
background-color: transparent;
|
||||
color: var(--brand-color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.searchButton:hover {
|
||||
color: var(--brand-color-text-default);
|
||||
background-color: var(--base-color-scale-gray-1);
|
||||
}
|
||||
|
||||
.page[data-mode="dark"] .searchButton:hover {
|
||||
background-color: var(--base-color-scale-gray-6);
|
||||
}
|
||||
|
||||
.searchButton:focus-visible {
|
||||
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
|
||||
outline-offset: var(--base-size-2);
|
||||
}
|
||||
|
||||
.searchField {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--base-size-8);
|
||||
inline-size: 12rem;
|
||||
max-inline-size: 100%;
|
||||
block-size: var(--base-size-32);
|
||||
padding-inline: var(--base-size-12);
|
||||
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted);
|
||||
border-radius: var(--brand-borderRadius-medium);
|
||||
background-color: var(--brand-color-canvas-default);
|
||||
color: var(--brand-color-text-muted);
|
||||
overflow: hidden;
|
||||
transform-origin: right center;
|
||||
animation: searchFieldExpand 0.18s ease;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes searchFieldExpand {
|
||||
from {
|
||||
inline-size: var(--base-size-32);
|
||||
opacity: 0.4;
|
||||
}
|
||||
to {
|
||||
inline-size: 12rem;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.searchField {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.searchField:focus-within {
|
||||
border-color: var(--brand-color-focus);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
min-inline-size: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font-family: var(--brand-fontStack-sansSerif);
|
||||
font-size: var(--brand-text-size-100);
|
||||
font-weight: var(--brand-text-weight-normal);
|
||||
line-height: var(--brand-text-lineHeight-100);
|
||||
color: var(--brand-color-text-default);
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--brand-color-text-muted);
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.searchKbd {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding-inline: var(--base-size-6);
|
||||
padding-block: var(--base-size-2);
|
||||
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted);
|
||||
border-radius: var(--brand-borderRadius-small);
|
||||
font-family: var(--brand-fontStack-sansSerif);
|
||||
font-size: var(--brand-text-size-100);
|
||||
font-weight: var(--brand-text-weight-normal);
|
||||
line-height: 1;
|
||||
color: var(--brand-color-text-muted);
|
||||
}
|
||||
|
||||
.subNavTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--base-size-8);
|
||||
text-decoration: none;
|
||||
padding-block: var(--base-size-16);
|
||||
padding-inline: var(--base-size-24);
|
||||
font-family: var(--brand-fontStack-sansSerif);
|
||||
font-size: var(--brand-text-size-200);
|
||||
font-weight: var(--brand-text-weight-medium);
|
||||
line-height: var(--brand-text-lineHeight-200);
|
||||
color: var(--brand-color-text-default);
|
||||
white-space: nowrap;
|
||||
border-inline-end: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
.subNavList {
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
padding-block: var(--base-size-16);
|
||||
padding-inline: var(--base-size-16);
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--base-size-8);
|
||||
}
|
||||
|
||||
.subNavLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-block: 0;
|
||||
padding-block: var(--base-size-4);
|
||||
padding-inline: var(--base-size-16);
|
||||
font-family: var(--brand-fontStack-sansSerif);
|
||||
font-size: var(--brand-text-size-100);
|
||||
font-weight: var(--brand-text-weight-normal);
|
||||
line-height: var(--brand-text-lineHeight-200);
|
||||
color: var(--brand-color-text-muted);
|
||||
text-decoration: none;
|
||||
border-radius: var(--brand-borderRadius-full);
|
||||
background-color: transparent;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.subNavLink:hover {
|
||||
color: var(--brand-color-text-default);
|
||||
background-color: var(--brand-color-canvas-subtle);
|
||||
}
|
||||
|
||||
.subNavLink:focus-visible {
|
||||
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
|
||||
outline-offset: var(--base-size-4);
|
||||
}
|
||||
|
||||
.subNavTitle:focus-visible {
|
||||
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
|
||||
outline-offset: calc(var(--base-size-2) * -1);
|
||||
}
|
||||
|
||||
.subNavLinkActive,
|
||||
.subNavLinkActive:hover {
|
||||
color: var(--brand-color-text-default);
|
||||
background-color: var(--base-color-scale-gray-1);
|
||||
}
|
||||
|
||||
.page[data-mode="dark"] .subNavLinkActive,
|
||||
.page[data-mode="dark"] .subNavLinkActive:hover {
|
||||
background-color: var(--base-color-scale-gray-6);
|
||||
}
|
||||
|
||||
/* "More" overflow dropdown, trigger styled as a tab */
|
||||
.moreItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.moreMenu {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.moreTrigger {
|
||||
gap: var(--base-size-4);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.moreTrigger::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.moreChevron {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.moreMenu[open] .moreChevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.moreOverlay {
|
||||
position: absolute;
|
||||
inset-block-start: calc(100% + var(--base-size-4));
|
||||
inset-inline-start: 0;
|
||||
z-index: 20;
|
||||
min-inline-size: 12rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--base-size-8);
|
||||
background-color: var(--brand-color-canvas-default);
|
||||
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-muted);
|
||||
border-radius: var(--brand-borderRadius-medium);
|
||||
box-shadow: var(--brand-SubNav-shadow);
|
||||
}
|
||||
|
||||
.moreLink {
|
||||
padding-block: var(--base-size-8);
|
||||
padding-inline: var(--base-size-12);
|
||||
font-family: var(--brand-fontStack-sansSerif);
|
||||
font-size: var(--brand-text-size-100);
|
||||
font-weight: var(--brand-text-weight-normal);
|
||||
line-height: var(--brand-text-lineHeight-200);
|
||||
color: var(--brand-color-text-muted);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
border-radius: var(--brand-borderRadius-medium);
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.moreLink:hover {
|
||||
color: var(--brand-color-text-default);
|
||||
background-color: var(--brand-color-canvas-subtle);
|
||||
}
|
||||
|
||||
.moreLink:focus-visible {
|
||||
outline: var(--brand-borderWidth-thin) solid var(--brand-color-focus);
|
||||
outline-offset: calc(var(--base-size-2) * -1);
|
||||
}
|
||||
|
||||
/* Active dropdown item: mark the current Resources page so it stays
|
||||
indicated when the menu is reopened. */
|
||||
.moreLink[aria-current="page"],
|
||||
.moreLink[aria-current="page"]:hover {
|
||||
color: var(--brand-color-text-default);
|
||||
font-weight: var(--brand-text-weight-semibold);
|
||||
background-color: var(--brand-color-canvas-subtle);
|
||||
}
|
||||
|
||||
/*
|
||||
* Because gridlines stretch outside the native Grid, we use a wrapper frame
|
||||
* that draws the shared connecting lines around the card grid.
|
||||
* Adapted from templates/marketing/overview/styles.module.css.
|
||||
*/
|
||||
.cardGridFrame {
|
||||
width: 100%;
|
||||
border-block-start: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
border-block-end: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
.cardGridContent {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cardGridColumn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cardGridItem {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* 28px + the Primer Card's 32px inner padding = 60px, matching the catalog
|
||||
card padding used across the rest of the site. */
|
||||
padding: var(--base-size-28);
|
||||
border-block-start: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
/* Subtle hover/focus fill covers the full cell rectangle, out to the gridlines,
|
||||
like Primer Brand's Card surface. The rounded focus ring below sits inset
|
||||
within it (see the ::before layer). */
|
||||
.cardGridColumnArrowHover .cardGridItem {
|
||||
transition: background-color var(--brand-animation-duration-extended)
|
||||
var(--brand-animation-easing-default);
|
||||
}
|
||||
|
||||
.cardGridColumnArrowHover .cardGridItem:is(:hover, :focus-within) {
|
||||
background-color: var(--brand-color-canvas-subtle);
|
||||
}
|
||||
|
||||
/* Ring layer: inset from the gridline boundary by the cell padding, so a gutter
|
||||
sits between the ring and the gridlines, with medium (8px) corners, matching
|
||||
Primer Brand's Card, whose focus ring hugs the Card box inset within its grid
|
||||
cell. A transparent overlay (pointer-events: none) that only paints the ring
|
||||
on focus; the subtle fill above stays on the full cell. */
|
||||
.cardGridItem::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: var(--base-size-28);
|
||||
border-radius: var(--brand-borderRadius-medium);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cardGridColumn:first-child .cardGridItem {
|
||||
border-block-start: 0;
|
||||
}
|
||||
|
||||
.resourceCard {
|
||||
min-block-size: 100%;
|
||||
}
|
||||
|
||||
/* Suppress the Card's built-in ring so it doesn't stack with the surface ring. */
|
||||
.cardGridItem :global([class*="Card__outer"]):focus-within {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* On card-link focus, draw the ring on the inset layer (offset 0, medium 8px
|
||||
corners), so it hugs the card inset within the filled cell. Fires on mouse or
|
||||
keyboard focus of the card link (action buttons keep their own rings). */
|
||||
.cardGridItem:has(:global([class*="Card__link"]):focus)::before {
|
||||
outline: var(--brand-borderWidth-thick) solid var(--brand-color-focus);
|
||||
}
|
||||
|
||||
/*
|
||||
* Gridline frame around the Hero: full-bleed horizontal rules top and bottom,
|
||||
* with vertical rules inset to the Section content edges — two horizontal and
|
||||
* two vertical lines forming a connected box, matching the CTA frame below.
|
||||
*/
|
||||
.heroFrame {
|
||||
width: 100%;
|
||||
border-block-start: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
border-block-end: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
.heroFrameInner {
|
||||
background-color: var(--brand-color-canvas-subtle);
|
||||
border-inline: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
padding-block: var(--base-size-80);
|
||||
}
|
||||
|
||||
/* Zero the Hero's native block padding so the 60px frame padding above sets the
|
||||
* exact, symmetric top/bottom whitespace. */
|
||||
.heroFrameInner :global([class*="Hero-module__Hero___"]) {
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
/* Pin the Hero title to 48px (base-size-48 = 3rem). Cap the line box just past
|
||||
"The community library" so the title wraps to two lines after that word
|
||||
instead of splitting mid-word. */
|
||||
.heroFrameInner :global([class*="Hero-module__Hero-heading___"]) {
|
||||
font-size: var(--base-size-48);
|
||||
max-width: 33.5rem;
|
||||
margin-inline: auto;
|
||||
animation: heroRiseUp 0.6s ease-out 0.09s both;
|
||||
}
|
||||
|
||||
/* Hero icon, title, and description rise up from below and fade in on load, in
|
||||
a subtle stagger — matching the Agents page hero entrance. */
|
||||
@keyframes heroRiseUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(var(--base-size-24));
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.heroRiseIcon {
|
||||
animation: heroRiseUp 0.6s ease-out both;
|
||||
}
|
||||
|
||||
.heroFrameInner :global([class*="Hero-module__Hero-description___"]) {
|
||||
animation: heroRiseUp 0.6s ease-out 0.18s both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.heroRiseIcon,
|
||||
.heroFrameInner :global([class*="Hero-module__Hero-heading___"]),
|
||||
.heroFrameInner :global([class*="Hero-module__Hero-description___"]) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark-mode override for the Hero secondary CTA ("Become a contributor"): use the
|
||||
* requested solid #202020 fill instead of the default translucent white. */
|
||||
:global([data-color-mode="dark"]) .heroFrameInner
|
||||
:global([class*="Button--secondary"]) {
|
||||
background-color: #202020;
|
||||
}
|
||||
|
||||
/* Light-mode override for the Hero secondary CTA: requested solid #F0F0F0 fill. */
|
||||
:global([data-color-mode="light"]) .heroFrameInner
|
||||
:global([class*="Button--secondary"]) {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Lay the heading out as a row: title on the left with the count label sitting
|
||||
* just after it (not pushed to the far card edge).
|
||||
*/
|
||||
.cardHeadingRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: var(--base-size-12);
|
||||
}
|
||||
|
||||
/* Keep the count label at full width; let the title take the remaining space. */
|
||||
.cardHeadingRow :global([class*="Token"]) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Gridline frame around the closing CTA banner: full-bleed horizontal rules
|
||||
* top and bottom, with vertical rules inset to the banner (Section content)
|
||||
* edges — mirrors the connected gridline aesthetic of the card grid above.
|
||||
*/
|
||||
.ctaFrame {
|
||||
width: 100%;
|
||||
border-block-start: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
border-block-end: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
.ctaFrameInner {
|
||||
border-inline: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
/*
|
||||
* CTABanner caps its own width at --brand-breakpoint-xlarge and centers itself,
|
||||
* so on wide viewports the subtle background stops short of the frame's vertical
|
||||
* gridlines, leaving a gap. Let the banner (and its subtle background) fill the
|
||||
* full frame edge to edge; the inner content keeps its own max-width and stays
|
||||
* centered.
|
||||
*/
|
||||
.ctaFrameInner :global([class*="CTABanner-module__CTABanner___"]) {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* CTABanner.Logo caps its SVG at 40px tall; render the learning icon at its
|
||||
* intended 64px instead.
|
||||
*/
|
||||
.ctaFrameInner :global([class*="CTABanner-logo"]) > svg {
|
||||
height: var(--base-size-64);
|
||||
}
|
||||
|
||||
/*
|
||||
* Constrain the CTA body copy so the first line wraps after "…get the most
|
||||
* out" instead of stretching the full banner width. Centered to match the
|
||||
* banner's align="center" layout.
|
||||
*/
|
||||
.ctaFrameInner :global([class*="CTABanner-description"]) {
|
||||
max-width: 40rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* The branded page divider sits flush above the footer. */
|
||||
.dividerFrame {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
* Render the Card's arrow-only CTA as a circular chevron affordance, matching
|
||||
* the reference design. The Card link still wraps the whole card, so the
|
||||
* chevron stays accessible and clickable. Colour tracks the Primer Brand Card
|
||||
* default: the arrow uses the link accent, not the green accent-primary.
|
||||
*/
|
||||
.resourceCard :global([class*="Card__actionIcon--arrowOnly"]) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: var(--base-size-40);
|
||||
block-size: var(--base-size-40);
|
||||
overflow: hidden;
|
||||
color: var(--brand-Link-color-accent);
|
||||
border: var(--brand-borderWidth-thin) solid var(--brand-color-border-default);
|
||||
border-radius: var(--brand-borderRadius-full);
|
||||
}
|
||||
|
||||
/* Tablet nav: the full desktop nav row (tabs + search + heart + Contribute)
|
||||
doesn't fit from 48rem up to this breakpoint — it wraps to two rows, and
|
||||
opening search shoves the wordmark off-screen (issue #19). Collapse to the
|
||||
hamburger across this band too; the wordmark grows to fill so the menu
|
||||
button sits at the far right. */
|
||||
@media screen and (min-width: 48rem) and (max-width: 74.99rem) {
|
||||
.subNavList {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topBarActions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.searchButton,
|
||||
.searchField {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.subNavTitle {
|
||||
flex: 1 1 auto;
|
||||
border-inline-end: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 47.99rem) {
|
||||
.subNavList {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Inset the hero content 20px from the frame's vertical gridlines so the
|
||||
icon, title, description, and buttons keep breathing room off the lines
|
||||
(and the buttons pick up the same side margin as the CTA banner below). */
|
||||
.heroFrameInner {
|
||||
padding-inline: var(--base-size-20);
|
||||
}
|
||||
|
||||
/* Shrink the community icon to sit proportionally with the smaller mobile
|
||||
hero type (down from the 56px desktop size). */
|
||||
.heroRiseIcon svg {
|
||||
width: var(--base-size-44);
|
||||
height: var(--base-size-44);
|
||||
}
|
||||
|
||||
/* Mobile hero heading: step the title down to the 28px brand type scale
|
||||
(Heading, size-700) so it sits smaller and fits narrow viewports. */
|
||||
.heroFrameInner :global([class*="Hero-module__Hero-heading___"]) {
|
||||
font-size: var(--brand-text-size-700);
|
||||
line-height: 1.2;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Mobile hero description: step the body copy down to the 14px brand scale
|
||||
(size-100) to match the smaller title. */
|
||||
.heroFrameInner :global([class*="Hero-module__Hero-description___"]) {
|
||||
font-size: var(--brand-text-size-100);
|
||||
}
|
||||
|
||||
/* Mobile hero actions: stack the two CTAs and stretch them to equal
|
||||
(full) width so they read as a matched pair — mirroring the CTA banner's
|
||||
stacked, equal-width buttons below. */
|
||||
.heroFrameInner :global([class*="Hero-module__Hero-actions___"]) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heroFrameInner
|
||||
:global([class*="Hero-module__Hero-actions___"])
|
||||
:global([class*="Button-module__Button___"]) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Restore the vertical gridlines on the single-column card grid: the desktop
|
||||
lines come from the cards' inline borders (added at >=768px), so on mobile
|
||||
the stack has none. Add them back, inset to the 20px content gutter so the
|
||||
framed box's side rules stay continuous with the hero/CTA above and below. */
|
||||
.cardGridItem {
|
||||
border-inline: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
.subNavTitle {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: var(--brand-text-size-100);
|
||||
/* Align the wordmark with the hero's left gridline: the full-bleed header's
|
||||
inner border sits at x=0, so inset by the page gutter less that border. */
|
||||
padding-inline-start: calc(
|
||||
var(--page-gutter) - var(--brand-borderWidth-thin)
|
||||
);
|
||||
padding-inline-end: var(--base-size-12);
|
||||
border-inline-end: 0;
|
||||
}
|
||||
|
||||
/* Keep the GitHub mark on the wordmark at every viewport so the page still
|
||||
reads as official; it stays fixed while the title text ellipsizes. */
|
||||
.subNavTitle svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brandMark {
|
||||
padding-inline-start: var(--base-size-16);
|
||||
padding-inline-end: var(--base-size-16);
|
||||
}
|
||||
|
||||
/* The heart (contributors) and Contribute action move into the hamburger
|
||||
dropdown on mobile (see TopNav), so hide the header action cluster here.
|
||||
The search field is already hidden below. */
|
||||
.topBarActions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.searchButton,
|
||||
.searchField {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Two-column band: from tablet (768px) up to just below xlarge (1280px), the
|
||||
grid renders two columns (span small:6 holds through the large breakpoint).
|
||||
Draw the vertical rule on both edges of every card and clear the top border
|
||||
on the first row (items 1 and 2). */
|
||||
@media screen and (min-width: 48rem) and (max-width: 79.99rem) {
|
||||
.cardGridItem {
|
||||
padding: var(--base-size-28);
|
||||
border-inline: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
/* two columns: reset the top border on the first two items */
|
||||
.cardGridColumn:nth-child(2) .cardGridItem {
|
||||
border-block-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Three-column band: the grid only becomes three columns at xlarge (1280px),
|
||||
where span drops to 4. */
|
||||
@media screen and (min-width: 80rem) {
|
||||
.cardGridItem {
|
||||
padding: var(--base-size-28);
|
||||
border-block-start: 0;
|
||||
border-inline-end: 0;
|
||||
border-inline-start: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
/* rows 2+ (3 columns, so row 2 starts at item 4) draw the inter-row line,
|
||||
which also serves as the bottom line of the cards in the row above */
|
||||
.cardGridColumn:nth-child(n + 4) .cardGridItem {
|
||||
border-block-start: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
|
||||
/* close the right edge of each row (every 3rd card and the last one) */
|
||||
.cardGridColumn:nth-child(3n) .cardGridItem,
|
||||
.cardGridColumn:last-child .cardGridItem {
|
||||
border-inline-end: var(--brand-borderWidth-thin) solid
|
||||
var(--brand-color-border-muted);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Responsive gutters (below 1920px). Neutralise the native Section/Grid
|
||||
* 1280px-centered layout and inset each band's framed content by the tiered
|
||||
* --page-gutter, so the vertical gridlines track the content edge at every
|
||||
* width. At >=1920px this block drops out and the original layout returns.
|
||||
*/
|
||||
@media screen and (max-width: 119.99rem) {
|
||||
.heroFrame :global([class*="Section__container"]),
|
||||
.ctaFrame :global([class*="Section__container"]),
|
||||
.dividerFrame :global([class*="Section__container"]) {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Header bar: inset its side gridlines to the same gutter so the vertical
|
||||
rules stay continuous with the hero/content frame below. Only from 768px
|
||||
up — on phones the collapsed bar (title + heart + Contribute) needs the
|
||||
full width, so it stays full-bleed there. It is a centered flex child, so
|
||||
grow-to-fill minus symmetric gutter margins (never width:100% + margin,
|
||||
which would overflow). */
|
||||
|
||||
.heroFrameInner,
|
||||
.ctaFrameInner {
|
||||
margin-inline: var(--page-gutter);
|
||||
}
|
||||
|
||||
.heroFrameInner {
|
||||
padding-block: var(--page-pad-block);
|
||||
}
|
||||
|
||||
.cardGridContent {
|
||||
padding-inline: var(--page-gutter);
|
||||
}
|
||||
|
||||
.cardGridContent :global([class*="Grid-module__Grid_"]) {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.dividerFrame :global([class*="Section__container"]) > * {
|
||||
padding-inline: var(--page-gutter);
|
||||
}
|
||||
}
|
||||
|
||||
/* Header bar gridline alignment: 768–1919px only (phones keep the full-bleed
|
||||
collapsed bar). Grow-to-fill minus symmetric gutter margins. */
|
||||
@media screen and (min-width: 48rem) and (max-width: 119.99rem) {
|
||||
.topBarInner {
|
||||
width: auto;
|
||||
max-width: none;
|
||||
flex: 1 1 auto;
|
||||
margin-inline: var(--page-gutter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import React from "react";
|
||||
|
||||
const MIN_HERO_READING = 464;
|
||||
const TWO_COLUMN_QUERY = "(min-width: 75rem)";
|
||||
|
||||
type AgentDetailClasses = {
|
||||
hero: string;
|
||||
heroInner: string;
|
||||
heroContent: string;
|
||||
};
|
||||
|
||||
export function useAgentDetailHeroPin(
|
||||
contentScrollRef: React.RefObject<HTMLDivElement | null>,
|
||||
classes: AgentDetailClasses,
|
||||
) {
|
||||
React.useEffect(() => {
|
||||
const scrollHost = contentScrollRef.current;
|
||||
if (!scrollHost) return;
|
||||
const page = scrollHost.parentElement;
|
||||
|
||||
const measure = () => {
|
||||
const scrollbar = scrollHost.offsetWidth - scrollHost.clientWidth;
|
||||
page?.style.setProperty("--dotnet-scrollbar", `${scrollbar}px`);
|
||||
|
||||
const hero = page?.getElementsByClassName(classes.hero)[0];
|
||||
const frame = page?.getElementsByClassName(classes.heroInner)[0];
|
||||
const content = page?.getElementsByClassName(classes.heroContent)[0];
|
||||
if (!(hero instanceof HTMLElement) || !(frame instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const heroRect = hero.getBoundingClientRect();
|
||||
const frameRect = frame.getBoundingClientRect();
|
||||
const frameStart = Math.round((frameRect.left - heroRect.left) * 100) / 100;
|
||||
const frameWidth = Math.round(frameRect.width * 100) / 100;
|
||||
page?.style.setProperty("--dotnet-frame-start", `${frameStart}px`);
|
||||
page?.style.setProperty("--dotnet-frame-width", `${frameWidth}px`);
|
||||
|
||||
if (!(content instanceof HTMLElement)) return;
|
||||
const contentRect = content.getBoundingClientRect();
|
||||
const crumbBand = Math.round((contentRect.top - heroRect.top) * 100) / 100;
|
||||
const contentBand = Math.round((heroRect.bottom - contentRect.top) * 100) / 100;
|
||||
const twoColumn = window.matchMedia(TWO_COLUMN_QUERY).matches;
|
||||
const shouldPin = twoColumn && scrollHost.clientHeight - contentBand >= MIN_HERO_READING;
|
||||
const pinnedHeight = shouldPin ? contentBand : 0;
|
||||
|
||||
if (page) page.dataset.heroPin = shouldPin ? "true" : "false";
|
||||
page?.style.setProperty("--dotnet-hero-crumb", `${shouldPin ? crumbBand : 0}px`);
|
||||
page?.style.setProperty("--dotnet-hero-pinned", `${pinnedHeight}px`);
|
||||
};
|
||||
|
||||
measure();
|
||||
window.addEventListener("resize", measure);
|
||||
const hero = page?.getElementsByClassName(classes.hero)[0];
|
||||
const observer =
|
||||
hero instanceof HTMLElement && typeof ResizeObserver !== "undefined"
|
||||
? new ResizeObserver(measure)
|
||||
: undefined;
|
||||
if (hero instanceof HTMLElement) observer?.observe(hero);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", measure);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [classes, contentScrollRef]);
|
||||
}
|
||||
|
||||
export function useAgentDetailProgress(
|
||||
contentScrollRef: React.RefObject<HTMLDivElement | null>,
|
||||
lastSectionId: string,
|
||||
setShowBackToTop: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
setHeroBurst: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
) {
|
||||
const burstRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const scrollHost = contentScrollRef.current;
|
||||
if (!scrollHost) return;
|
||||
const page = scrollHost.parentElement;
|
||||
let lastHeading: HTMLElement | null = null;
|
||||
|
||||
const update = () => {
|
||||
if (!lastHeading) {
|
||||
const section = document.getElementById(lastSectionId);
|
||||
lastHeading = (section?.querySelector("h2") as HTMLElement | null) ?? section;
|
||||
}
|
||||
|
||||
const internalScroll = window.matchMedia(TWO_COLUMN_QUERY).matches;
|
||||
const rootRect = scrollHost.getBoundingClientRect();
|
||||
const regionTop = rootRect.top + window.scrollY;
|
||||
const scrollTop = internalScroll
|
||||
? scrollHost.scrollTop
|
||||
: Math.max(0, window.scrollY - regionTop);
|
||||
const viewportHeight = internalScroll ? rootRect.height : window.innerHeight;
|
||||
let finish = internalScroll
|
||||
? scrollHost.scrollHeight - scrollHost.clientHeight
|
||||
: Math.max(1, scrollHost.scrollHeight - viewportHeight);
|
||||
|
||||
if (lastHeading) {
|
||||
const headingOffset = internalScroll
|
||||
? lastHeading.getBoundingClientRect().top - rootRect.top + scrollHost.scrollTop
|
||||
: lastHeading.getBoundingClientRect().top + window.scrollY - regionTop;
|
||||
finish = Math.max(1, headingOffset - viewportHeight * 0.6);
|
||||
}
|
||||
|
||||
const progress = finish > 0 ? Math.min(1, scrollTop / finish) : 0;
|
||||
const eased = Math.max(progress > 0 ? Math.pow(progress, 0.5) : 0, 0.055);
|
||||
page?.style.setProperty("--dotnet-progress", String(eased));
|
||||
setShowBackToTop(scrollTop > 200);
|
||||
|
||||
const reached = progress >= 1;
|
||||
if (reached !== burstRef.current) {
|
||||
burstRef.current = reached;
|
||||
setHeroBurst(reached);
|
||||
}
|
||||
};
|
||||
|
||||
update();
|
||||
scrollHost.addEventListener("scroll", update, { passive: true });
|
||||
window.addEventListener("scroll", update, { passive: true });
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
scrollHost.removeEventListener("scroll", update);
|
||||
window.removeEventListener("scroll", update);
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, [contentScrollRef, lastSectionId, setHeroBurst, setShowBackToTop]);
|
||||
|
||||
return React.useCallback(() => {
|
||||
const scrollHost = contentScrollRef.current;
|
||||
if (!scrollHost) return;
|
||||
if (window.matchMedia(TWO_COLUMN_QUERY).matches) {
|
||||
scrollHost.scrollTo({ top: 0, behavior: "smooth" });
|
||||
} else {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
}, [contentScrollRef]);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
export type Breadcrumb = {
|
||||
paths: { name: string; href?: string }[];
|
||||
title: string;
|
||||
};
|
||||
|
||||
const { paths, title } = Astro.props as Breadcrumb;
|
||||
---
|
||||
|
||||
<nav class="detail-breadcrumbs" aria-label="Breadcrumb">
|
||||
{
|
||||
paths.map((path, index) => (
|
||||
<>
|
||||
{path.href ? (
|
||||
<a href={path.href}>{path.name}</a>
|
||||
) : (
|
||||
<span>{path.name}</span>
|
||||
)}
|
||||
{index < paths.length - 1 && <span aria-hidden="true">/</span>}
|
||||
</>
|
||||
))
|
||||
}
|
||||
<span aria-hidden="true">/</span>
|
||||
<span aria-current="page">{title}</span>
|
||||
</nav>
|
||||