diff --git a/package.json b/package.json index 871fe7708..17ac84fb7 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "website:data": "node ./eng/generate-website-data.mjs", "website:dev": "npm run website:data && npm run --prefix website dev", "website:build": "npm run build && npm run website:data && npm run --prefix website build", + "website:a11y": "npm run website:build && npm run --prefix website a11y", "website:preview": "npm run --prefix website preview" }, "repository": { diff --git a/website/README.md b/website/README.md index f64dccb39..350f2e70d 100644 --- a/website/README.md +++ b/website/README.md @@ -12,6 +12,14 @@ npm run website:dev # generate data + start the dev server npm run website:build # full production build ``` +## Accessibility + +The website has an automated axe-core + Playwright audit. Run it locally with `npm run website:a11y` from the repository root, or run `npm run a11y` from `website/` after building `dist` first. + +CI blocks on critical and serious violations. Minor and moderate best-practice issues are reported as non-blocking. + +Authoring conventions: resource cards use `div[role="listitem"]` wrappers, not `
`; only add `role="list"` to containers whose direct children are list items; do not nest interactive controls inside another focusable element; `.btn-primary` and ToC links must meet WCAG AA (4.5:1) contrast in both light and dark themes. + ## Social preview cards (LinkedIn, etc.) Shared links render as large preview cards driven by Open Graph / Twitter meta tags. diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 1df5c300e..75205b0d8 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -24,7 +24,13 @@ export default defineConfig({ title: "Awesome GitHub Copilot", favicon: "/images/favicon.svg", description: siteDescription, - social: [], + social: [ + { + icon: "github", + label: "View on GitHub", + href: "https://github.com/github/awesome-copilot", + }, + ], head: [ { tag: "meta", diff --git a/website/package-lock.json b/website/package-lock.json index 0890879b4..57f5bd13a 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -18,6 +18,10 @@ "jszip": "^3.10.1", "marked": "^18.0.2", "shiki": "^4.0.2" + }, + "devDependencies": { + "axe-core": "^4.10.2", + "playwright": "^1.49.0" } }, "node_modules/@astrojs/compiler-binding": { @@ -79,9 +83,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -98,9 +99,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -117,9 +115,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -136,9 +131,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -454,9 +446,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "optional": true, "os": [ "linux" @@ -469,9 +458,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "optional": true, "os": [ "linux" @@ -484,9 +470,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "optional": true, "os": [ "linux" @@ -499,9 +482,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "optional": true, "os": [ "linux" @@ -1182,9 +1162,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1201,9 +1178,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1220,9 +1194,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1239,9 +1210,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1258,9 +1226,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1277,9 +1242,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1296,9 +1258,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1315,9 +1274,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1334,9 +1290,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1359,9 +1312,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1384,9 +1334,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1409,9 +1356,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1434,9 +1378,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1459,9 +1400,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1484,9 +1422,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1509,9 +1444,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1863,9 +1795,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1882,9 +1811,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1901,9 +1827,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1920,9 +1843,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1939,9 +1859,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1958,9 +1875,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2477,6 +2391,16 @@ "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0" } }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -4212,9 +4136,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4235,9 +4156,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4258,9 +4176,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4281,9 +4196,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5762,6 +5674,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", diff --git a/website/package.json b/website/package.json index e4e5a18d3..a9e3aa9a1 100644 --- a/website/package.json +++ b/website/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "astro dev", "build": "astro build", + "a11y": "node ./scripts/a11y-audit.mjs", "preview": "astro preview", "astro": "astro" }, @@ -27,5 +28,9 @@ "jszip": "^3.10.1", "marked": "^18.0.2", "shiki": "^4.0.2" + }, + "devDependencies": { + "axe-core": "^4.10.2", + "playwright": "^1.49.0" } } diff --git a/website/scripts/a11y-audit.mjs b/website/scripts/a11y-audit.mjs new file mode 100644 index 000000000..e37dbcaf8 --- /dev/null +++ b/website/scripts/a11y-audit.mjs @@ -0,0 +1,384 @@ +import { execFile, spawn } from 'node:child_process'; +import { access } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from 'playwright'; + +const require = createRequire(import.meta.url); + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const websiteDir = path.resolve(scriptDir, '..'); +const distDir = path.join(websiteDir, 'dist'); +const axeMainPath = require.resolve('axe-core'); +const axeSourcePath = path.join(path.dirname(axeMainPath), 'axe.min.js'); + +// Resolve Astro's CLI entry point so the preview server can be launched with `node ` +// directly — no shell and no `npx` shim. This keeps signal delivery / detached-PGID shutdown +// predictable (see startPreviewServer) and works identically on Windows and POSIX (spawning a +// .cmd shim without a shell throws EINVAL on modern Node). +const astroPackageJsonPath = require.resolve('astro/package.json'); +const astroBinField = require(astroPackageJsonPath).bin; +const astroBinRelative = typeof astroBinField === 'string' ? astroBinField : astroBinField.astro; +const astroBinPath = path.join(path.dirname(astroPackageJsonPath), astroBinRelative); + +const routes = [ + '/', + '/agents/', + '/instructions/', + '/skills/', + '/hooks/', + '/workflows/', + '/extensions/', + '/plugins/', + '/tools/', + '/contributors/', + '/learning-hub/cookbook/', + '/learning-hub/github-copilot-app/', +]; + +const themes = ['dark', 'light']; +const blockingImpacts = new Set(['critical', 'serious']); +const serverTimeoutMs = 60_000; +const fetchTimeoutMs = 2_000; + +async function main() { + const port = getPort(); + const providedBaseUrl = process.env.A11Y_BASE_URL?.trim(); + const baseUrl = providedBaseUrl || `http://localhost:${port}/`; + let previewServer; + let browser; + + try { + await access(axeSourcePath); + + if (!providedBaseUrl) { + await access(distDir); + previewServer = startPreviewServer(port); + } + + await waitForServer(new URL('/', baseUrl).toString(), previewServer); + + browser = await chromium.launch(); + const violations = await auditSite(browser, baseUrl); + printReport(violations); + + // Gate only critical and serious violations; moderate/minor findings are reported but non-blocking. + const blockingCount = violations.filter((violation) => blockingImpacts.has(violation.impact)).length; + const nonBlockingCount = violations.length - blockingCount; + + console.log( + `\nSummary: ${blockingCount} blocking (critical/serious), ${nonBlockingCount} non-blocking (moderate/minor) violation(s).`, + ); + + if (blockingCount > 0) { + process.exitCode = 1; + console.error(`Accessibility audit FAILED — ${blockingCount} blocking violation(s)`); + } else { + process.exitCode = 0; + console.log('Accessibility audit PASSED (no critical/serious violations)'); + } + } catch (error) { + process.exitCode = 1; + console.error(`Accessibility audit ERROR — ${error instanceof Error ? error.message : String(error)}`); + } finally { + await closeBrowser(browser); + await stopPreviewServer(previewServer); + } +} + +function getPort() { + const port = Number.parseInt(process.env.A11Y_PORT || '4322', 10); + + if (!Number.isInteger(port) || port <= 0) { + throw new Error(`Invalid A11Y_PORT value: ${process.env.A11Y_PORT}`); + } + + return port; +} + +function startPreviewServer(port) { + // Launch `node preview` directly — no shell layer and no npx shim. A shell + // (shell:true) spawns an extra intermediate process that makes signal delivery / PGID-based + // shutdown (detached + process.kill(-pid) below) unreliable, and spawning the npx.cmd shim + // without a shell throws EINVAL on modern Node for Windows. Invoking the resolved bin with + // process.execPath sidesteps both problems on every platform. + const child = spawn(process.execPath, [astroBinPath, 'preview', '--port', String(port), '--host'], { + cwd: websiteDir, + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const server = { + child, + exited: false, + exitCode: null, + signal: null, + spawnError: null, + logs: [], + }; + + const rememberOutput = (chunk) => { + const lines = String(chunk) + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter(Boolean); + + server.logs.push(...lines); + + if (server.logs.length > 60) { + server.logs.splice(0, server.logs.length - 60); + } + }; + + child.stdout.on('data', rememberOutput); + child.stderr.on('data', rememberOutput); + child.on('error', (error) => { + server.spawnError = error; + rememberOutput(`Failed to start preview server: ${error.message}`); + }); + child.on('exit', (code, signal) => { + server.exited = true; + server.exitCode = code; + server.signal = signal; + }); + + return server; +} + +async function waitForServer(url, server) { + const deadline = Date.now() + serverTimeoutMs; + let lastError; + + while (Date.now() < deadline) { + if (server?.spawnError) { + throw new Error(`${server.spawnError.message}${formatServerLogs(server)}`); + } + + if (server?.exited) { + throw new Error( + `Astro preview exited before it was ready (code ${server.exitCode ?? 'null'}, signal ${ + server.signal ?? 'null' + }).${formatServerLogs(server)}`, + ); + } + + try { + const response = await fetchWithTimeout(url); + + if (response.ok) { + return; + } + + lastError = new Error(`HTTP ${response.status}`); + } catch (error) { + lastError = error; + } + + await delay(500); + } + + throw new Error( + `Timed out after ${serverTimeoutMs / 1000}s waiting for ${url}.${ + lastError ? ` Last error: ${lastError.message}` : '' + }${formatServerLogs(server)}`, + ); +} + +async function fetchWithTimeout(url) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs); + + try { + return await fetch(url, { signal: controller.signal }); + } finally { + clearTimeout(timeout); + } +} + +async function auditSite(browser, baseUrl) { + const page = await browser.newPage(); + const violations = []; + + for (const route of routes) { + for (const theme of themes) { + const url = new URL(route, baseUrl).toString(); + + // 'load' (not 'networkidle') keeps the audit robust: cards are server-rendered + // into the initial HTML, and lazy images / search-index requests can otherwise + // keep the network busy indefinitely and stall navigation. + const response = await page.goto(url, { waitUntil: 'load', timeout: 45_000 }); + + // Fail fast on broken/missing routes. page.goto() resolves even for 4xx/5xx responses, + // so without this check axe would run against an error page and the guardrail could + // "pass" while silently masking a broken route. + if (!response) { + throw new Error(`No navigation response for ${url} (theme: ${theme}).`); + } + + if (!response.ok()) { + throw new Error(`Route ${url} (theme: ${theme}) returned HTTP ${response.status()}.`); + } + + await page.waitForTimeout(500); + + // Disable CSS transitions/animations before switching themes. Theme changes animate + // background/text colors over ~0.2s; if axe runs mid-transition it samples intermediate + // colors (e.g. a dark card background bleeding through a light page) and reports + // false-positive contrast violations that no real user ever sees. Killing transitions + // makes axe measure the settled, steady-state colors — the actual conformance target. + await page.addStyleTag({ + content: '*,*::before,*::after{transition:none!important;animation:none!important;transition-duration:0s!important;animation-duration:0s!important;}', + }); + + // Force both theme modes so persisted user preference logic cannot hide regressions. + await page.evaluate((selectedTheme) => { + document.documentElement.setAttribute('data-theme', selectedTheme); + localStorage.setItem('awesome-copilot-theme', selectedTheme); + }, theme); + + // Let the forced theme settle (style recalc / reflow) before sampling colors. + await page.waitForTimeout(150); + + await page.addScriptTag({ path: axeSourcePath }); + + const results = await page.evaluate(async () => await axe.run(document, { resultTypes: ['violations'] })); + + for (const violation of results.violations) { + violations.push({ + route, + theme, + id: violation.id, + impact: violation.impact ?? 'unknown', + help: violation.help, + helpUrl: violation.helpUrl, + nodeCount: violation.nodes.length, + }); + } + } + } + + await page.close(); + return violations; +} + +function printReport(violations) { + console.log(`Audited ${routes.length} route(s) across ${themes.length} theme(s).`); + + if (violations.length === 0) { + console.log('\nNo axe violations found.'); + return; + } + + const groupedViolations = new Map(); + + for (const violation of violations) { + const pageTheme = `${violation.route} [${violation.theme}]`; + const pageViolations = groupedViolations.get(pageTheme) || []; + + pageViolations.push(violation); + groupedViolations.set(pageTheme, pageViolations); + } + + for (const [pageTheme, pageViolations] of groupedViolations) { + console.log(`\n${pageTheme}`); + + for (const violation of pageViolations) { + const gate = blockingImpacts.has(violation.impact) ? 'BLOCKING' : 'NON-BLOCKING'; + + console.log(` - ${violation.id} (${violation.impact}, ${gate})`); + console.log(` ${violation.help}`); + console.log(` ${violation.helpUrl}`); + console.log(` Nodes: ${violation.nodeCount}`); + } + } +} + +async function closeBrowser(browser) { + if (!browser) { + return; + } + + try { + await browser.close(); + } catch (error) { + process.exitCode = 1; + console.error(`Failed to close browser cleanly: ${error instanceof Error ? error.message : String(error)}`); + } +} + +async function stopPreviewServer(server) { + if (!server || server.exited || !server.child.pid) { + return; + } + + await new Promise((resolve) => { + let resolved = false; + const finish = () => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + resolve(); + } + }; + + const timeout = setTimeout(() => { + forceKill(server.child); + finish(); + }, 5_000); + + server.child.once('exit', finish); + + if (process.platform === 'win32') { + execFile('taskkill', ['/pid', String(server.child.pid), '/T', '/F'], (error) => { + if (error && !server.exited) { + server.child.kill(); + } + }); + return; + } + + try { + process.kill(-server.child.pid, 'SIGTERM'); + } catch { + server.child.kill('SIGTERM'); + } + }); +} + +function forceKill(child) { + if (!child.pid) { + return; + } + + try { + if (process.platform === 'win32') { + child.kill(); + } else { + process.kill(-child.pid, 'SIGKILL'); + } + } catch { + try { + child.kill('SIGKILL'); + } catch { + // The process already exited. + } + } +} + +function formatServerLogs(server) { + if (!server?.logs.length) { + return ''; + } + + return `\n\nAstro preview output:\n${server.logs.join('\n')}`; +} + +function delay(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +await main(); diff --git a/website/src/components/PageHeader.astro b/website/src/components/PageHeader.astro index 4719267af..ad87a7b95 100644 --- a/website/src/components/PageHeader.astro +++ b/website/src/components/PageHeader.astro @@ -1,23 +1,26 @@ --- import Icon from './Icon.astro'; +type Accent = 'ai' | 'docs' | 'power' | 'automation' | 'extension' | 'dev' | 'learn'; + interface Props { title: string; description: string; icon?: 'robot' | 'document' | 'lightning' | 'hook' | 'workflow' | 'plug' | 'wrench' | 'book'; + accent?: Accent; } -const { title, description, icon } = Astro.props; +const { title, description, icon, accent } = Astro.props; const contributingUrl = 'https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md'; --- -