diff options
| -rw-r--r-- | cgithub/public/static/refs.js | 10 | ||||
| -rw-r--r-- | cgithub/src/app.test.ts | 34 | ||||
| -rw-r--r-- | cgithub/src/app.ts | 6 | ||||
| -rw-r--r-- | cgithub/src/scraper.test.ts | 15 | ||||
| -rw-r--r-- | cgithub/src/scraper.ts | 126 | ||||
| -rw-r--r-- | cgithub/views/_sidebar.eta (renamed from cgithub/views/_languages.eta) | 5 | ||||
| -rw-r--r-- | cgithub/views/repo.eta | 10 |
7 files changed, 105 insertions, 101 deletions
diff --git a/cgithub/public/static/refs.js b/cgithub/public/static/refs.js index f3e07e8..1035593 100644 --- a/cgithub/public/static/refs.js +++ b/cgithub/public/static/refs.js @@ -52,10 +52,20 @@ document.addEventListener("DOMContentLoaded", () => { .then((html) => { if (html.trim()) { placeholder.outerHTML = html; + relocate(); } else { placeholder.remove(); } }) .catch(() => placeholder.remove()); } + + // A fragment may carry a node that belongs elsewhere on the page, where an + // empty element of that id holds its spot: the sidebar's latest release + // reads as part of the release count, far from the languages it arrives with. + function relocate() { + for (const node of document.querySelectorAll("[data-target]")) { + document.getElementById(node.dataset.target)?.replaceWith(node); + } + } }); diff --git a/cgithub/src/app.test.ts b/cgithub/src/app.test.ts index 6544f03..5474f8d 100644 --- a/cgithub/src/app.test.ts +++ b/cgithub/src/app.test.ts @@ -58,6 +58,40 @@ describe("branch picker", () => { }); }); +describe("sidebar fragment", () => { + const repo = { owner: "actions", name: "deploy-pages" }; + const languages = [{ name: "JavaScript", percentage: 100, color: "#f1e05a" }]; + const latestRelease = { tag: "v5.0.0", publishedAt: "2026-03-25T16:59:14Z" }; + + // The release replaces the empty element it names, beside the release count. + it("should render the latest release and the languages", () => { + const html = eta.render("_sidebar.eta", { repo, languages, latestRelease }); + + assert.match(html, /<span data-target="latest-release">\(<a href/); + assert.match(html, /href="\/actions\/deploy-pages\/releases\/tag\/v5.0.0"/); + assert.match(html, /<time class="timestamp" title="2026-03-25T16:59:14Z">2026-03-25<\/time>/); + assert.match(html, /<div class="languages box">/); + }); + + it("should name a spot the repo page holds open", () => { + const html = eta.render("repo.eta", { + repo, + branch: "main", + path: "/", + items: [], + info: { description: null, website: null, stars: null, forks: null, numReleases: 38 }, + }); + + assert.match(html, /<span id="latest-release"><\/span>/); + }); + + it("should render nothing when the repository has neither", () => { + const html = eta.render("_sidebar.eta", { repo, languages: [], latestRelease: null }); + + assert.strictEqual(html.trim(), ""); + }); +}); + describe("redirects to GitHub", () => { // An HTTP redirect would be caught by the extension's declarativeNetRequest // rules and bounced straight back here. diff --git a/cgithub/src/app.ts b/cgithub/src/app.ts index 81e47fd..e27db59 100644 --- a/cgithub/src/app.ts +++ b/cgithub/src/app.ts @@ -6,7 +6,7 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, - getGitHubLanguages, + getGitHubSidebar, getGitHubLatestCommit, getGitHubOwner, getGitHubPulls, @@ -178,9 +178,9 @@ export function createApp(eta: Eta) { ); }); - app.get("/api/:owner/:repo/languages", async (c) => { + app.get("/api/:owner/:repo/sidebar", async (c) => { const { owner, repo } = c.req.param(); - return tryRenderFragment(c, "_languages.eta", getGitHubLanguages(owner, repo)); + return tryRenderFragment(c, "_sidebar.eta", getGitHubSidebar(owner, repo)); }); app.get("/:owner/:repo/releases/tag/:tag", async (c) => { diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts index 5b5e78b..24fdfc0 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -4,7 +4,7 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, - getGitHubLanguages, + getGitHubSidebar, getGitHubLatestCommit, getGitHubPulls, getGitHubRefs, @@ -35,8 +35,6 @@ describe("GitHub scraper", () => { assert.strictEqual(data.info.stars?.length, 3); assert.strictEqual(data.info.forks?.length, 3); assert.strictEqual(data.info.numReleases, 38); - assert.strictEqual(data.info.latestReleaseTag, "v5.0.0"); - assert.ok(data.info.latestReleaseDate?.startsWith("2026-03-25")); assert.ok(data.items.length > 0); assert.ok(typeof data.overviewHtml?.["README.md"] === "string"); @@ -220,8 +218,8 @@ describe("GitHub scraper", () => { assert.ok(data.commit.authors.length > 0); }); - it("should fetch languages", async () => { - const data = await getGitHubLanguages("actions", "deploy-pages"); + it("should fetch the sidebar", async () => { + const data = await getGitHubSidebar("actions", "deploy-pages"); assert.strictEqual(data.repo.owner, "actions"); assert.strictEqual(data.repo.name, "deploy-pages"); @@ -229,10 +227,13 @@ describe("GitHub scraper", () => { assert.deepStrictEqual(data.languages, [ { name: "JavaScript", percentage: 100, color: "#f1e05a" }, ]); + + assert.strictEqual(data.latestRelease?.tag, "v5.0.0"); + assert.ok(data.latestRelease?.publishedAt?.startsWith("2026-03-25")); }); - it("should fetch languages for a multi-language repository", async () => { - const data = await getGitHubLanguages("rust-lang", "rust"); + it("should fetch the sidebar for a multi-language repository", async () => { + const data = await getGitHubSidebar("rust-lang", "rust"); const rust = data.languages.find((language) => language.name === "Rust"); assert.ok(rust); diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index 223839f..4220dc4 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -143,7 +143,17 @@ interface CodeViewBlobRoute { richText: string | null; } +interface SidebarAbout { + description?: string | null; + website?: string | null; + stargazerCount?: number; + forksCount?: number; + // A section is false when the repository has none of that thing. + sections?: { releases?: { releaseCount?: number } | false }; +} + interface AppPayload { + sidebarAbout?: SidebarAbout; codeViewLayoutRoute?: CodeViewLayoutRoute; codeViewRepoRoute?: CodeViewRepoRoute; codeViewTreeRoute?: CodeViewTreeRoute; @@ -230,8 +240,6 @@ export interface RepoInfo { stars: string | null; forks: string | null; numReleases?: number; - latestReleaseTag?: string; - latestReleaseDate?: string; } export interface GitHubRepo extends GitHubTree { @@ -292,8 +300,14 @@ export interface Language { color: string | null; } -export interface GitHubLanguages extends GitHubCommon { +export interface LatestRelease { + tag: string; + publishedAt: string | null; +} + +export interface GitHubSidebar extends GitHubCommon { languages: Language[]; + latestRelease: LatestRelease | null; } export interface Ref { @@ -425,81 +439,6 @@ function parsePayload(html: string): AppPayload | null { return parseEmbeddedPayload<AppPayload>(html); } -function parseRepoInfo(html: string): RepoInfo { - const document = parseDocument(html); - - const aboutSection = cssSelect.selectOne(".BorderGrid-cell h2:contains('About')", document); - let description: string | null = null; - let website: string | null = null; - - if (aboutSection) { - const parent = aboutSection.parent; - if (parent) { - const descElem = cssSelect.selectOne("p.f4", parent); - if (descElem) { - description = textContent(descElem).trim(); - } - const websiteElem = cssSelect.selectOne( - "a[rel='noopener noreferrer nofollow']", - parent, - ) as Element | null; - if (websiteElem) { - website = getAttributeValue(websiteElem, "href") || null; - } - } - } - - const starsElem = cssSelect.selectOne("#repo-stars-counter-star", document) as Element | null; - const stars = starsElem - ? getAttributeValue(starsElem, "title") || textContent(starsElem).trim() - : null; - - const forksElem = cssSelect.selectOne("#repo-network-counter", document) as Element | null; - const forks = forksElem - ? getAttributeValue(forksElem, "title") || textContent(forksElem).trim() - : null; - - const releaseElem = cssSelect.selectOne( - "a[href$='/releases'] .Counter", - document, - ) as Element | null; - let numReleases: number | undefined; - let latestReleaseTag: string | undefined; - let latestReleaseDate: string | undefined; - - if (releaseElem) { - const releaseText = getAttributeValue(releaseElem, "title") || textContent(releaseElem); - numReleases = parseInt(releaseText.replace(/,/g, ""), 10); - - // Find the latest release tag and date if available - const cell = (releaseElem.parent as Element)?.parent?.parent as Element; // span -> a -> h2 -> cell - if (cell) { - const latestLink = cssSelect.selectOne("a[href*='/releases/tag/']", cell) as Element; - if (latestLink) { - const tagElem = cssSelect.selectOne(".css-truncate-target", latestLink); - if (tagElem) { - latestReleaseTag = textContent(tagElem).trim(); - } - - const timeElem = cssSelect.selectOne("relative-time", latestLink); - if (timeElem) { - latestReleaseDate = getAttributeValue(timeElem, "datetime") || undefined; - } - } - } - } - - return { - description, - website, - stars, - forks, - numReleases, - latestReleaseTag, - latestReleaseDate, - }; -} - function extractOverviewHtml( overviewFiles: OverviewFile[] = [], ): Record<string, string | null> | undefined { @@ -539,7 +478,15 @@ export async function getGitHubRepo(owner: string, repo: string): Promise<GitHub throw new Error("Could not find tree data in embedded JSON"); } - const info = parseRepoInfo(html); + const about = payload?.sidebarAbout; + const releases = about?.sections?.releases; + const info = { + description: about?.description ?? null, + website: about?.website ?? null, + stars: about?.stargazerCount?.toLocaleString("en-US") ?? null, + forks: about?.forksCount?.toLocaleString("en-US") ?? null, + numReleases: releases === false ? undefined : releases?.releaseCount, + }; const overviewHtml = extractOverviewHtml(repoRoute.overview?.overviewFiles); return extractGitHub(payload, { items: repoRoute.tree.items, info, overviewHtml }); @@ -742,11 +689,16 @@ interface SidebarPayload { color?: string; }[]; }; + releases?: { + // The name is the release title, which need not be the tag; the path is. + latestRelease?: { name?: string; path: string; publishedAt?: string } | null; + }; } -// The repository sidebar (languages, contributors, ...) is not rendered into -// the repo page at all; GitHub's own UI lazily fetches it as JSON from here. -export async function getGitHubLanguages(owner: string, repo: string): Promise<GitHubLanguages> { +// The repository sidebar (languages, releases, contributors, ...) is not +// rendered into the repo page at all; GitHub's own UI lazily fetches it as +// JSON from here. +export async function getGitHubSidebar(owner: string, repo: string): Promise<GitHubSidebar> { const payload = await fetchGitHubJson<SidebarPayload>(`${owner}/${repo}/_sidebar`); const languages: Language[] = []; @@ -758,7 +710,15 @@ export async function getGitHubLanguages(owner: string, repo: string): Promise<G }); } - return { repo: { owner, name: repo }, languages }; + const release = payload.releases?.latestRelease; + const latestRelease = release + ? { + tag: decodeURIComponent(release.path.split("/releases/tag/")[1] ?? release.name ?? ""), + publishedAt: release.publishedAt ?? null, + } + : null; + + return { repo: { owner, name: repo }, languages, latestRelease }; } interface BranchRefEntry { diff --git a/cgithub/views/_languages.eta b/cgithub/views/_sidebar.eta index 8aca447..bceef7c 100644 --- a/cgithub/views/_languages.eta +++ b/cgithub/views/_sidebar.eta @@ -1,3 +1,8 @@ +<% if (it.latestRelease) { %> + <span data-target="latest-release">(<a href="/<%= it.repo.owner %>/<%= it.repo.name %>/releases/tag/<%= it.latestRelease.tag %>"><%= it.latestRelease.tag %></a><% + if (it.latestRelease.publishedAt) { %> <time class="timestamp" title="<%= it.latestRelease.publishedAt %>"><%= it.latestRelease.publishedAt.split("T")[0] %></time><% + } %>)</span> +<% } %> <% if (it.languages.length > 0) { %> <div class="languages box"> <div class="language-bar"> diff --git a/cgithub/views/repo.eta b/cgithub/views/repo.eta index 1bff97f..5dc6beb 100644 --- a/cgithub/views/repo.eta +++ b/cgithub/views/repo.eta @@ -17,19 +17,13 @@ <% if (it.info.numReleases) { %> <div class="detail"> <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/releases"><%= it.info.numReleases %> releases</a> - <% if (it.info.latestReleaseTag) { %> - (<a href="/<%= it.repo.owner %>/<%= it.repo.name %>/releases/tag/<%= it.info.latestReleaseTag %>"><%= it.info.latestReleaseTag %></a> - <% if (it.info.latestReleaseDate) { %> - <time class="timestamp" title="<%= it.info.latestReleaseDate %>"><%= it.info.latestReleaseDate.split("T")[0] %></time> - <% } %> - ) - <% } %> + <span id="latest-release"></span> </div> <% } %> </div> <% } %> -<div class="placeholder languages box" data-src="/api/<%= it.repo.owner %>/<%= it.repo.name %>/languages"> +<div class="placeholder languages box" data-src="/api/<%= it.repo.owner %>/<%= it.repo.name %>/sidebar"> <div class="language-bar"><span style="width: 100%; background: #ededed"></span></div> <ul class="language-list"><li>…</li></ul> </div> |
