Repository navigation
Load external plugin READMEs from source repositories #4945
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sbroenne
wants to merge
8
commits into
github:main
Choose a base branch
from
sbroenne:sbroenne-excel-plugin-readme-display
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+636
−110
Open
Changes from 7 commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
252a72f
Display reviewed READMEs for external plugins
sbroenne f761be9
Copy the original Excel MCP README unchanged
sbroenne 8490018
Load external plugin READMEs from their source repositories
sbroenne 605265e
Fix external README review findings
sbroenne 9c41198
Sanitize markdown after accessibility updates
sbroenne 2dfc241
Sanitize plugin HTML after TOC processing
sbroenne 7d949b4
Remove CSS from external plugin READMEs
sbroenne e748a07
Fall back when external README finalization fails
sbroenne File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| const README_NAMES = ["README.md", "readme.md", "Readme.md"]; | ||
| const MAX_README_BYTES = 512 * 1024; | ||
|
|
||
| /** | ||
| * Fetch documentation only from GitHub's raw-content host. Follow the listing's | ||
| * revision rather than silently showing documentation for a different release. | ||
| */ | ||
| export async function fetchExternalPluginReadme( | ||
| source, | ||
| { fetchImpl = fetch, warn = console.warn } = {}, | ||
| ) { | ||
| if (source?.source !== "github") return null; | ||
| const { repo, path: pluginPath = "", sha, ref } = source; | ||
| const revision = sha || ref || "HEAD"; | ||
| const folder = pluginPath === "/" ? "" : pluginPath; | ||
| try { | ||
| if ( | ||
| typeof repo !== "string" || | ||
| !/^[A-Za-z0-9_-][A-Za-z0-9._-]*\/[A-Za-z0-9_-][A-Za-z0-9._-]*$/.test(repo) || | ||
| typeof revision !== "string" || | ||
| !revision.trim() || | ||
| revision.split("/").some((part) => part === "." || part === "..") || | ||
| /[\x00-\x1f\x7f]/.test(revision) || | ||
| typeof folder !== "string" || | ||
| folder.includes("//") || | ||
| folder.endsWith("/") || | ||
| folder.split("/").some((part) => part === "." || part === "..") || | ||
| /[\\?#]/.test(folder) || | ||
| folder.startsWith("/") | ||
| ) { | ||
| throw new Error("Invalid GitHub source for external plugin README"); | ||
| } | ||
|
|
||
| const locator = encodeURIComponent(revision); | ||
| const roots = folder ? [folder, ""] : [""]; | ||
| for (const root of roots) { | ||
| for (const name of README_NAMES) { | ||
| const file = [...(root ? root.split("/") : []), name] | ||
| .map(encodeURIComponent).join("/"); | ||
| const rawUrl = `https://raw.githubusercontent.com/${repo}/${locator}/${file}`; | ||
| const response = await fetchImpl(rawUrl, { | ||
| signal: AbortSignal.timeout(10_000), | ||
| redirect: "error", | ||
| }); | ||
| if (response.status === 404) { | ||
| await response.body?.cancel(); | ||
| continue; | ||
| } | ||
| if (!response.ok) { | ||
| await response.body?.cancel(); | ||
| throw new Error(`HTTP ${response.status} for ${rawUrl}`); | ||
| } | ||
| if (Number(response.headers.get("content-length")) > MAX_README_BYTES) { | ||
| await response.body?.cancel(); | ||
| throw new Error(`README exceeds ${MAX_README_BYTES} bytes: ${rawUrl}`); | ||
| } | ||
| const chunks = []; | ||
| let bytes = 0; | ||
| if (response.body) { | ||
| for await (const chunk of response.body) { | ||
| bytes += chunk.byteLength; | ||
| if (bytes > MAX_README_BYTES) { | ||
| throw new Error(`README exceeds ${MAX_README_BYTES} bytes: ${rawUrl}`); | ||
| } | ||
| chunks.push(chunk); | ||
| } | ||
| } | ||
| const markdown = Buffer.concat(chunks).toString("utf8"); | ||
| if (!markdown.trim()) throw new Error(`Empty README: ${rawUrl}`); | ||
| return { | ||
| markdown, | ||
| sourceUrl: `https://github.com/${repo}/blob/${locator}/${file}`, | ||
| rawUrl, | ||
| }; | ||
| } | ||
| } | ||
| warn(`[external-plugin-readme] No Markdown README found for ${repo} at ${revision}${folder ? ` (${folder})` : ""}`); | ||
| } catch (error) { | ||
| warn(`[external-plugin-readme] Could not load ${repo} at ${revision}: ${error.message}`); | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| export async function loadExternalPluginReadmes(plugins) { | ||
| const external = plugins.filter((plugin) => plugin.external); | ||
| let next = 0; | ||
| await Promise.all(Array.from({ length: Math.min(6, external.length) }, async () => { | ||
| while (next < external.length) { | ||
| const plugin = external[next++]; | ||
| plugin.readme = await fetchExternalPluginReadme(plugin.source); | ||
| } | ||
| })); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| import assert from "node:assert/strict"; | ||
| import { test } from "node:test"; | ||
| import { fetchExternalPluginReadme, loadExternalPluginReadmes } from "./external-plugin-readme.mjs"; | ||
|
|
||
| const source = { source: "github", repo: "owner/repo", path: "plugins/example", ref: "v1.0.0" }; | ||
|
|
||
| function fixture(responses) { | ||
| const calls = []; | ||
| const warnings = []; | ||
| return { | ||
| calls, | ||
| warnings, | ||
| options: { | ||
| warn: (message) => warnings.push(message), | ||
| fetchImpl: async (url, options) => { | ||
| calls.push(url); | ||
| assert.equal(options.redirect, "error"); | ||
| assert.ok(options.signal); | ||
| const response = responses.shift(); | ||
| if (response instanceof Error) throw response; | ||
| return response ?? new Response("", { status: 404 }); | ||
| }, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| test("infers the plugin README and prefers the listing SHA to its ref", async () => { | ||
| const f = fixture([new Response("# Plugin")]); | ||
| const result = await fetchExternalPluginReadme({ ...source, sha: "a".repeat(40) }, f.options); | ||
| assert.equal(result.markdown, "# Plugin"); | ||
| assert.equal(result.rawUrl, `https://raw.githubusercontent.com/owner/repo/${"a".repeat(40)}/plugins/example/README.md`); | ||
| assert.equal(result.sourceUrl, `https://github.com/owner/repo/blob/${"a".repeat(40)}/plugins/example/README.md`); | ||
| assert.deepEqual(f.warnings, []); | ||
| }); | ||
|
|
||
| test("uses a root path and HEAD for an unpinned listing", async () => { | ||
| const f = fixture([new Response("# Root")]); | ||
| await fetchExternalPluginReadme({ source: "github", repo: "owner/repo", path: "/" }, f.options); | ||
| assert.deepEqual(f.calls, ["https://raw.githubusercontent.com/owner/repo/HEAD/README.md"]); | ||
| }); | ||
|
|
||
| test("encodes refs and path segments", async () => { | ||
| const f = fixture([new Response("# Plugin")]); | ||
| await fetchExternalPluginReadme({ ...source, path: "plugin docs", ref: "refs/tags/v1+docs" }, f.options); | ||
| assert.equal(f.calls[0], "https://raw.githubusercontent.com/owner/repo/refs%2Ftags%2Fv1%2Bdocs/plugin%20docs/README.md"); | ||
| }); | ||
|
|
||
| test("accepts ref-only package-style tags used by the catalog", async () => { | ||
| const f = fixture([new Response("# Radius")]); | ||
| const result = await fetchExternalPluginReadme({ ...source, ref: "radius@0.2.1" }, f.options); | ||
| assert.equal(result.markdown, "# Radius"); | ||
| assert.match(f.calls[0], /\/radius%400\.2\.1\/plugins\/example\/README\.md$/); | ||
| assert.deepEqual(f.warnings, []); | ||
| }); | ||
|
|
||
| test("tries common Markdown filename casing before the repository root", async () => { | ||
| const f = fixture([undefined, new Response("# Lowercase")]); | ||
| assert.equal((await fetchExternalPluginReadme(source, f.options)).markdown, "# Lowercase"); | ||
| assert.match(f.calls[1], /plugins\/example\/readme\.md$/); | ||
| const root = fixture([undefined, undefined, undefined, new Response("# Root")]); | ||
| await fetchExternalPluginReadme(source, root.options); | ||
| assert.match(root.calls[3], /v1\.0\.0\/README\.md$/); | ||
| }); | ||
|
|
||
| test("logs missing READMEs and retains a null fallback without changing revision", async () => { | ||
| const f = fixture([]); | ||
| assert.equal(await fetchExternalPluginReadme(source, f.options), null); | ||
| assert.equal(f.calls.length, 6); | ||
| assert.ok(f.calls.every((url) => url.includes("/v1.0.0/"))); | ||
| assert.match(f.warnings[0], /No Markdown README found/); | ||
| }); | ||
|
|
||
| test("logs HTTP, timeout, and network failures without trying another revision", async () => { | ||
| for (const response of [new Response("", { status: 403 }), new Response("", { status: 500 }), new Error("Timed out"), new Error("Network unavailable")]) { | ||
| const f = fixture([response]); | ||
| assert.equal(await fetchExternalPluginReadme(source, f.options), null); | ||
| assert.equal(f.calls.length, 1); | ||
| assert.match(f.warnings[0], /Could not load/); | ||
| } | ||
| }); | ||
|
|
||
| test("limits declared and streamed response sizes and rejects empty files", async () => { | ||
| for (const response of [ | ||
| new Response("large", { headers: { "content-length": String(512 * 1024 + 1) } }), | ||
| new Response("x".repeat(512 * 1024 + 1)), | ||
| new Response(" \n"), | ||
| ]) { | ||
| const f = fixture([response]); | ||
| assert.equal(await fetchExternalPluginReadme(source, f.options), null); | ||
| assert.equal(f.warnings.length, 1); | ||
| } | ||
| }); | ||
|
|
||
| test("does not fetch non-GitHub sources or malformed GitHub paths", async () => { | ||
| const f = fixture([]); | ||
| assert.equal(await fetchExternalPluginReadme({ source: "git" }, f.options), null); | ||
| for (const overrides of [{ repo: "../bad" }, { repo: "host/repo/extra" }, { path: "../outside" }, { path: "/outside" }, { path: "a\\b" }, { path: null }, { path: "a//b" }, { ref: "../main" }, { ref: "." }]) { | ||
| assert.equal(await fetchExternalPluginReadme({ ...source, ...overrides }, f.options), null); | ||
| } | ||
| assert.deepEqual(f.calls, []); | ||
| assert.equal(f.warnings.length, 9); | ||
| assert.ok(f.warnings.every((message) => message.includes("Invalid GitHub source"))); | ||
| }); | ||
|
|
||
| test("loads only external plugins with bounded concurrency", async (t) => { | ||
| let active = 0; | ||
| let maximum = 0; | ||
| t.mock.method(globalThis, "fetch", async () => { | ||
| active++; | ||
| maximum = Math.max(maximum, active); | ||
| await new Promise((resolve) => setTimeout(resolve, 5)); | ||
| active--; | ||
| return new Response("# Remote"); | ||
| }); | ||
| const plugins = [ | ||
| { id: "built-in", readmeFile: "plugins/built-in/README.md" }, | ||
| ...Array.from({ length: 14 }, () => ({ external: true, source })), | ||
| ]; | ||
| await loadExternalPluginReadmes(plugins); | ||
| assert.equal(plugins[0].readme, undefined); | ||
| assert.equal(plugins[0].readmeFile, "plugins/built-in/README.md"); | ||
| assert.ok(plugins.slice(1).every((plugin) => plugin.readme.markdown === "# Remote")); | ||
| assert.equal(maximum, 6); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.