diff --git a/website/README.md b/website/README.md index 546e26eb7a..14cb348fc6 100644 --- a/website/README.md +++ b/website/README.md @@ -20,12 +20,138 @@ CI blocks on critical and serious violations. Minor and moderate best-practice i 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. +## Discovery and visual conventions + +The landing page prioritizes the Learning Hub, with the repository as its +secondary destination. Resource cards and navigation reuse their catalog pages' +branded icons, with visible text labels. Resources without branded artwork keep +their Octicons. Repeated SVGs use instance-specific clipping IDs. +Resource-card CTAs reveal their border, expand with the arrow moving right, then +fade the label into the padded space. Labels fade out before collapse rather +than sliding through the border. Touch and narrow layouts keep labels visible; +reduced-motion preferences skip the transitions. +Expanded card buttons inset their labels past the rounded ends; the hero +button keeps Primer's default padding. +The secondary hero CTA uses Primer's theme-aware background tokens for its +rest, hover, and pressed states instead of fixed gray fills. +The resource grid stays single-column below 768px, uses two columns up to +1280px, then three columns. Nested card padding decreases on narrower layouts +so full CTA labels remain on one line without shrinking their text. +At 1920px and wider, the homepage header and content share the catalogs' +centered 1296px frame; smaller viewports retain the same responsive gutters. +The landing hero concentrates a green-tinted square grid and soft glow around +the lower center, fading toward the headline and side edges rather than covering +the panel uniformly. Other heading panels use a static theme-token texture. Both treatments +are disabled in forced-colors mode. +The bottom learning panel links to YouTube and the GitHub Copilot docs; the +primary hero remains the entry point to the Learning Hub. Its outer frame shares +the banner's background token so the color reaches both side edges. +The shared footer pairs the GitHub wordmark with the official GitHub mark, +bundled locally as a theme-colored mask while retaining Primer's footer link. + +The catalog's user-facing label is **Extensions**, including navigation, +breadcrumbs, and search result types. A canvas is the interactive panel an +extension can supply, and a plugin can package it with other resources. +Existing `/extensions/` and `/extension//` URLs, schema fields, and install +identifiers remain unchanged. Menu links and search group headings use the +same artwork as the cards, at a compact 16px size. Individual search results +omit redundant type labels and icons. The desktop Learning Hub +link stays text-only; its icon appears on the primary hero CTA instead. +Search groups use stronger headings and solid dividers between groups. Hover +and keyboard selection fill the entire result-row width while text remains inset. +Arrow, Home, and End navigation keep the selected result visible inside scrollable +panels without moving focus away from the search field. +Dropdown links that leave the site have a trailing external-link arrow, including +Hooks, Workflows, and Tools (local redirects to GitHub), and the mobile Contribute button. + +Run the landing/navigation and reported image regressions against a built site: + +```bash +node website/scripts/site-refinement-regression.mjs +node website/scripts/resource-card-layout-regression.mjs +node website/scripts/mobile-nav-regression.mjs +``` + +These browser regressions default to Astro's `http://127.0.0.1:4321`. +Set `SITE_BASE_URL` to target another development or production-preview server. +On PowerShell, use `$env:SITE_BASE_URL = 'http://127.0.0.1:4331'`. + +The mobile menu spans the actual header width (excluding scrollbars), with +content aligned to the wordmark and a scrollable panel on short screens. +The mobile navigation regression checks menu bounds, padding, icon consistency, +and keyboard behavior. + +## Reading headers and catalog pagination + +Resource details and learning articles let their expanded title, description, +and actions scroll away. On roomy desktop viewports, an observer reveals a +compact title with a small, right-aligned primary install action where available. +Its right edge aligns with the top bar's trailing actions. +The full install menu stays in the expanded hero. The strip fades in as the +remaining hero reaches the strip's height, without changing document height or +briefly exposing a bare progress line. Hidden install actions are inert. +The title releases on mobile, short viewports, and enlarged +text; anchor and sidebar offsets share its measured height. Back to top restores +heading focus and respects reduced motion. The green progress line celebrates +when the article's bottom is visible, consistently across resource details and +learning articles, rather than waiting for the footer or stopping at a heading. + +All six catalogs share the pagination control alignment correction, preserving +Primer's current-page styling and the keyboard scroll/focus handoff to `#catalog`. +With the website running locally, run the focused browser regressions: + +```bash +node website/scripts/reading-catalog-regression.mjs +node website/scripts/reading-header-regression.mjs +``` + +These use the same `SITE_BASE_URL` setting and default as the landing-page regressions. + ## Security hardening notes - The site ships with a baseline meta CSP and `referrer` policy in `src/components/Head.astro`. - Because the site is hosted on GitHub Pages, response headers are not controllable in-repo. For stricter enforcement (for example, header-based CSP with nonce/hashes), place the site behind infrastructure that can set HTTP security headers. - Markdown rendered for detail/file-browser experiences is sanitized with the shared `sanitizeHtml()` helper before insertion. +## Search locale + +Both catalog and Pagefind results are scoped to the rendered page's ``, +before grouping and result limits. English fallback pages search English even +when their URL has another locale prefix. Real translated pages do not mix in +English catalog records; without Pagefind, they show no results rather than +silently searching another language. + +Run `node --test website/scripts/search.test.mjs` for adapter coverage and +`node --test website/scripts/search-browser.test.mjs` after a production build +for desktop/mobile locale and search interaction coverage. + +## Embedded markdown images + +Standalone documentation images receive 24px of vertical margin through a +shared reading-content rule. The markdown sanitizer marks image-only blocks, +including linked images and themed pictures; inline icons and badge rows retain +their existing layout. Component-owned hero and gallery images are unaffected. + +Resource details and skill/cookbook file previews resolve embedded `src` and +picture/image `srcset` URLs from the originating repository document directory +and revision, not the website route or locale. Canvas READMEs use the same +generated commit ref as their preview image; other curated documents use their +existing `main` source. Site-root `/images/...` assets and HTTP(S) images remain +valid, and ordinary image hyperlinks remain links. + +External catalog entries do not currently fetch README bodies. Any future +cached external README must supply its actual raw repository base, revision, +and document path to `readResourceMarkdown`; its local cache filename is not +source provenance. Missing or invalid provenance for relative images is +reported and the image URL is removed rather than guessed. Sanitization still +runs on the resolved attributes. + +Run focused media regressions from the repository root with Node 24 or later: + +```bash +node --test website/scripts/markdown-images.test.mjs +``` + ## Social preview cards (LinkedIn, etc.) Shared links render as large preview cards driven by Open Graph / Twitter meta tags. diff --git a/website/scripts/markdown-images.test.mjs b/website/scripts/markdown-images.test.mjs new file mode 100644 index 0000000000..d6f94b6659 --- /dev/null +++ b/website/scripts/markdown-images.test.mjs @@ -0,0 +1,219 @@ +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { registerHooks } from "node:module"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { marked } from "marked"; + +// Node's native TS support needs extensions; Astro/Vite imports omit them. +const lib = new URL("../src/lib/", import.meta.url).href; +registerHooks({ + resolve(specifier, context, nextResolve) { + if (context.parentURL?.startsWith(lib) && specifier.startsWith(".")) { + const candidate = new URL(`${specifier}.ts`, context.parentURL); + if (existsSync(candidate)) return nextResolve(candidate.href, context); + } + return nextResolve(specifier, context); + }, +}); + +process.chdir(fileURLToPath(new URL("..", import.meta.url))); +const { resolveMarkdownImage, resolveMarkdownSrcset } = + await import("../src/lib/markdown-images.ts"); +const { sanitizeHtml } = await import("../src/lib/sanitize-html.ts"); +const { readResourceMarkdown } = await import("../src/lib/detail-page.ts"); + +const revision = "a".repeat(40); +const rawBase = `https://raw.githubusercontent.com/github/awesome-copilot/${revision}`; +const source = { + rawBase, + filePath: "extensions/pr-artifact-explorer/README.md", +}; +const render = (markdown, provenance = source) => + sanitizeHtml(marked.parse(markdown, { async: false }), provenance); + +test("standalone documentation images receive the shared spacing marker", () => { + for (const markdown of [ + "![Preview](assets/preview.png)", + "[![Preview](assets/preview.png)](https://example.com)", + 'Preview', + 'Preview', + ]) { + const html = render(markdown); + assert.match(html, /]*data-markdown-block-image=""/); + assert.equal(sanitizeHtml(html), html, "Repeated sanitization preserves block classification"); + } + assert.match(readResourceMarkdown(source.filePath, source).markdownHtml, + /]*data-markdown-block-image=""/); +}); + +test("inline icons, text, and badge rows do not receive block image spacing", () => { + for (const markdown of [ + "Click ![Icon](assets/preview.png) to continue.", + "**Before** ![Icon](assets/preview.png) **after**", + "[![Build](assets/preview.png)](https://example.com) [![Version](assets/preview.png)](https://example.com)", + 'Icon', + '
Click Icon to continue.
', + '

Click Icon to continue.

', + ]) { + assert.doesNotMatch(render(markdown), /data-markdown-block-image/); + } +}); + +test("relative images resolve against the document directory and revision", () => { + for (const [input, expected] of [ + ["assets/preview.png", "extensions/pr-artifact-explorer/assets/preview.png"], + ["./assets/preview.png", "extensions/pr-artifact-explorer/assets/preview.png"], + ["../shared/image.png", "extensions/shared/image.png"], + ["../../shared/image.png", "shared/image.png"], + ["assets/a b.png?raw=true#dark", "extensions/pr-artifact-explorer/assets/a%20b.png?raw=true#dark"], + ["assets/a%20b.png", "extensions/pr-artifact-explorer/assets/a%20b.png"], + ]) { + assert.equal(resolveMarkdownImage(input, source), `${rawBase}/${expected}`); + } +}); + +test("site-root, absolute, and already pinned assets retain their destinations", () => { + for (const url of [ + "/images/learning-hub/example.png", + "https://example.com/image.png?q=1#preview", + "http://example.com/image.png", + `${rawBase}/extensions/pr-artifact-explorer/assets/preview.png`, + "https://github.com/user-attachments/assets/image-id", + "https://github.com/owner/repo/blob/feature/branch/image.png?raw=true", + ]) { + assert.equal(resolveMarkdownImage(url, null), url); + } + assert.equal(resolveMarkdownImage("//example.com/image.png", null), "https://example.com/image.png"); +}); + +test("only unambiguous commit-pinned embedded blob URLs become raw URLs", () => { + const blob = `https://github.com/owner/repo/blob/${revision}/docs/image.png?raw=true#dark`; + assert.equal( + resolveMarkdownImage(blob, source), + `https://raw.githubusercontent.com/owner/repo/${revision}/docs/image.png?raw=true#dark`, + ); + const html = render(`[An image link](${blob})\n\n![An embedded image](${blob})`); + assert.ok(html.includes(`href="${blob}"`)); + assert.equal((html.match(/ { + const external = { + rawBase: `https://raw.githubusercontent.com/third-party/plugin/${revision}`, + filePath: "packages/canvas/docs/README.md", + }; + const result = readResourceMarkdown(source.filePath, external); + assert.ok(result.markdownHtml.includes( + `src="${external.rawBase}/packages/canvas/docs/assets/preview.png"`, + )); + assert.ok(!/src(?:set)?="https:\/\/raw\.githubusercontent\.com\/github\/awesome-copilot/.test( + result.markdownHtml, + )); + assert.equal( + resolveMarkdownImage("../image.png", { + ...external, + rawBase: "https://raw.githubusercontent.com/third-party/plugin/release%2Fv1", + }), + "https://raw.githubusercontent.com/third-party/plugin/release%2Fv1/packages/canvas/image.png", + ); +}); + +test("Artifact Explorer README rewrites img and both themed picture sources", () => { + const { markdownHtml } = readResourceMarkdown(source.filePath, source); + const assets = `${rawBase}/extensions/pr-artifact-explorer/assets`; + assert.ok(markdownHtml.includes(`src="${assets}/preview.png"`)); + assert.ok(markdownHtml.includes(`srcset="${assets}/preview-dark.png"`)); + assert.ok(markdownHtml.includes(`srcset="${assets}/preview.png"`)); + assert.ok(markdownHtml.includes('alt="Artifact Explorer showing pull request workflow artifacts')); + assert.ok(!markdownHtml.includes(" { + const { markdownHtml } = readResourceMarkdown("plugins/napkin/README.md"); + assert.ok(markdownHtml.includes( + 'src="https://raw.githubusercontent.com/github/awesome-copilot/main/skills/napkin/assets/step1-activate.svg"', + )); + assert.ok(render('![A & B](assets/preview.png "Preview")').includes( + `src="${rawBase}/extensions/pr-artifact-explorer/assets/preview.png" alt="A & B" title="Preview"`, + )); +}); + +test("file previews use each selected file path even when contents are identical", () => { + for (const filePath of [ + "skills/example/docs/guide.md", + "skills/example/other/guide.md", + "cookbook/copilot-sdk/nodejs/guide.md", + ]) { + const directory = filePath.slice(0, filePath.lastIndexOf("/")); + assert.ok(render("![Diagram](./diagram.png)", { rawBase, filePath }).includes( + `src="${rawBase}/${directory}/diagram.png"`, + )); + } +}); + +test("srcset handles descriptors, whitespace, parent paths, and commas in URLs", () => { + assert.equal( + resolveMarkdownSrcset("assets/a.png 1x, ../b.png 2x", source), + `${rawBase}/extensions/pr-artifact-explorer/assets/a.png 1x, ${rawBase}/extensions/b.png 2x`, + ); + assert.equal( + resolveMarkdownSrcset("/images/a.png 320w, https://example.com/a,b.png 640w", source), + "/images/a.png 320w, https://example.com/a,b.png 640w", + ); + assert.equal( + resolveMarkdownSrcset("assets/a.png, \n assets/b.png", source), + `${rawBase}/extensions/pr-artifact-explorer/assets/a.png, ${rawBase}/extensions/pr-artifact-explorer/assets/b.png`, + ); +}); + +test("unsafe URLs and ambiguous or escaping provenance fail explicitly", () => { + for (const url of [ + "javascript:alert(1)", "data:image/png;base64,AA", "file:///tmp/a.png", + "vbscript:bad", "java\nscript:bad", "\\\\evil.test\\a.png", "", + "../../../outside.png", "%2e%2e/%2e%2e/%2e%2e/outside.png", + ]) { + assert.throws(() => resolveMarkdownImage(url, source)); + } + assert.throws(() => resolveMarkdownImage("image.png", null), /no document source/); + for (const invalidSource of [ + { ...source, rawBase: "https://github.com/owner/repo/tree/main" }, + { ...source, rawBase: "https://raw.githubusercontent.com/owner/repo" }, + { ...source, rawBase: "https://raw.githubusercontent.com/owner/repo/feature/branch" }, + { ...source, filePath: "../README.md" }, + ]) { + assert.throws(() => resolveMarkdownImage("image.png", invalidSource)); + } +}); + +test("final markup stays sanitized, preserves alt/links, and isolates render sources", (t) => { + const warnings = []; + t.mock.method(console, "warn", (message) => warnings.push(message)); + const html = render(` +Keep me +No data + +Unsafe link +Ordinary image link`); + assert.ok(!/javascript|data:image|onerror|