summaryrefslogtreecommitdiff
path: root/cgithub
diff options
context:
space:
mode:
authorJoe Mou <dev@mou.fo>2026-08-11 17:41:01 -0400
committerJoe Mou <dev@mou.fo>2026-08-11 18:25:26 -0400
commit21310d0366c0f21e73c1f4f78d38370edfb59445 (patch)
treeb65c6bb2bfea1036266e29c308bf1132277bb993 /cgithub
parent866f99c414cb3d102483ce21c68a0e62b394baaa (diff)
Read the About box and latest release from JSON
GitHub rebuilt the repository sidebar, so the selectors parseRepoInfo used matched nothing and the About box rendered empty. Everything but the latest release is in the page's embedded payload as sidebarAbout; the latest release is only in the _sidebar JSON already fetched for languages, so it arrives with that fragment and is moved beside the release count.
Diffstat (limited to 'cgithub')
-rw-r--r--cgithub/public/static/refs.js10
-rw-r--r--cgithub/src/app.test.ts34
-rw-r--r--cgithub/src/app.ts6
-rw-r--r--cgithub/src/scraper.test.ts15
-rw-r--r--cgithub/src/scraper.ts126
-rw-r--r--cgithub/views/_sidebar.eta (renamed from cgithub/views/_languages.eta)5
-rw-r--r--cgithub/views/repo.eta10
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>