From 371f558ed70d29f63e9fbee48c8db7ffab2f7cba Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Wed, 11 Feb 2026 02:09:26 -0500 Subject: Add single release detail pages and refactor templates Refactored releases display into reusable partial template (_release.eta) and added support for viewing individual releases at /:owner/:repo/releases/tag/:tag with navigation back to all releases. Co-Authored-By: Claude Sonnet 4.5 --- cgithub/src/index.ts | 6 +++ cgithub/src/scraper.test.ts | 13 +++++++ cgithub/src/scraper.ts | 93 ++++++++++++++++++++++++++++++++++----------- cgithub/static/style.css | 4 ++ cgithub/views/_release.eta | 23 +++++++++++ cgithub/views/release.eta | 7 ++++ cgithub/views/releases.eta | 20 +--------- 7 files changed, 124 insertions(+), 42 deletions(-) create mode 100644 cgithub/views/_release.eta create mode 100644 cgithub/views/release.eta (limited to 'cgithub') diff --git a/cgithub/src/index.ts b/cgithub/src/index.ts index 2f8efef..59c98ee 100644 --- a/cgithub/src/index.ts +++ b/cgithub/src/index.ts @@ -9,6 +9,7 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, + getGitHubRelease, getGitHubReleases, getGitHubRepo, getGitHubTree, @@ -74,6 +75,11 @@ app.get("/:owner/:repo/commits/:branch/:path{.*}?", async (c) => { return tryRender(c, "commits.eta", getGitHubCommits(owner, repo, branch, path)); }); +app.get("/:owner/:repo/releases/tag/:tag", async (c) => { + const { owner, repo, tag } = c.req.param(); + return tryRender(c, "release.eta", getGitHubRelease(owner, repo, tag)); +}); + app.get("/:owner/:repo/releases", async (c) => { const { owner, repo } = c.req.param(); return tryRender(c, "releases.eta", getGitHubReleases(owner, repo)); diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts index 33f00f1..0b91b6a 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -4,6 +4,7 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, + getGitHubRelease, getGitHubReleases, getGitHubRepo, getGitHubTree, @@ -174,6 +175,18 @@ describe("GitHub scraper", () => { assert.ok(firstRelease.publishedAt.length > 0); assert.ok(firstRelease.bodyHtml.length > 0); }); + + it("should fetch single release", async () => { + const data = await getGitHubRelease("actions", "deploy-pages", "v4.0.5"); + + assert.strictEqual(data.repo.owner, "actions"); + assert.strictEqual(data.repo.name, "deploy-pages"); + + assert.ok(data.release.tagName.length > 0); + assert.ok(data.release.title.length > 0); + assert.ok(data.release.publishedAt.length > 0); + assert.ok(data.release.bodyHtml.length > 0); + }); }); describe("rich file types", () => { diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index af39660..acfaac1 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -200,6 +200,10 @@ export interface GitHubReleases extends GitHubCommon { releases: Release[]; } +export interface GitHubRelease extends GitHubCommon { + release: Release; +} + async function fetchGitHubPage(path: string): Promise { // GitHub throttles/blocks requests without realistic browser headers. // These headers make the request appear as a standard browser visit. @@ -420,6 +424,33 @@ export async function getGitHubCommits( }; } +function parseRelease(section: Element, title: string): Release { + // Extract tag name from link + const tagLink = cssSelect.selectOne('a[href*="/tree/"]', section); + let tagName = title; + if (tagLink) { + const href = getAttributeValue(tagLink, "href"); + if (href) { + tagName = href.split("/").pop() || title; + } + } + + // Extract published date + const relativeTime = cssSelect.selectOne("relative-time", section); + const publishedAt = relativeTime ? getAttributeValue(relativeTime, "datetime") || "" : ""; + + // Extract body HTML (markdown content) + const body = cssSelect.selectOne(".markdown-body", section); + const bodyHtml = body ? getInnerHTML(body).trim() : ""; + + return { + tagName, + title, + publishedAt, + bodyHtml, + }; +} + export async function getGitHubReleases(owner: string, repo: string): Promise { const html = await fetchGitHubPage(`${owner}/${repo}/releases`); @@ -436,37 +467,53 @@ export async function getGitHubReleases(owner: string, repo: string): Promise { + const html = await fetchGitHubPage(`${owner}/${repo}/releases/tag/${tag}`); - // Extract body HTML (markdown content) - const body = cssSelect.selectOne(".markdown-body", section); - const bodyHtml = body ? getInnerHTML(body).trim() : ""; + // Find the h1 tag with the release title + const h1Match = html.match(/]*class="[^"]*d-inline[^"]*"[^>]*>([^<]*)<\/h1>/); + const title = h1Match?.[1]?.trim() || tag; - releases.push({ - tagName, - title, - publishedAt, - bodyHtml, - }); - } + // Extract tag name from tree link + const tagLinkMatch = html.match(/]*href="[^"]*\/tree\/([^"]+)"[^>]*>/); + const tagName = tagLinkMatch?.[1] || tag; + + // Extract published date + const dateMatch = html.match(/]*datetime="([^"]*)"[^>]*>/); + const publishedAt = dateMatch?.[1] || ""; + + // Extract body HTML (markdown content) - find the markdown-body div + const bodyMatch = html.match( + /]*class="[^"]*markdown-body[^"]*"[^>]*>([\s\S]*?)<\/div>\s*]*class="mb-3"/, + ); + const bodyHtml = bodyMatch?.[1]?.trim() || ""; return { repo: { owner, name: repo, }, - releases, + release: { + tagName, + title, + publishedAt, + bodyHtml, + }, }; } diff --git a/cgithub/static/style.css b/cgithub/static/style.css index fdf3285..896f138 100644 --- a/cgithub/static/style.css +++ b/cgithub/static/style.css @@ -232,6 +232,10 @@ pre.box { font-size: 0.8rem; } +.release-nav { + margin-bottom: 1rem; +} + .release { margin-bottom: 2rem; } diff --git a/cgithub/views/_release.eta b/cgithub/views/_release.eta new file mode 100644 index 0000000..85126a2 --- /dev/null +++ b/cgithub/views/_release.eta @@ -0,0 +1,23 @@ +
+
+

+ <% if (it.linkToDetail) { %> + + <%= it.release.title %> + + <% } else { %> + <%= it.release.title %> + <% } %> +

+ <% if (it.release.publishedAt) { %> + + <% } %> +
+ <% if (it.release.bodyHtml) { %> +
+ <%~ it.release.bodyHtml %> +
+ <% } %> +
diff --git a/cgithub/views/release.eta b/cgithub/views/release.eta new file mode 100644 index 0000000..b8c0fe6 --- /dev/null +++ b/cgithub/views/release.eta @@ -0,0 +1,7 @@ +<% layout("./layout.eta") %> + + + +<%~ include("./_release.eta", { repo: it.repo, release: it.release, linkToDetail: false }) %> diff --git a/cgithub/views/releases.eta b/cgithub/views/releases.eta index 39850d8..b3cb254 100644 --- a/cgithub/views/releases.eta +++ b/cgithub/views/releases.eta @@ -3,23 +3,5 @@

Releases

<% for (const release of it.releases) { %> -
-
-

- - <%= release.title %> - -

- <% if (release.publishedAt) { %> - - <% } %> -
- <% if (release.bodyHtml) { %> -
- <%~ release.bodyHtml %> -
- <% } %> -
+ <%~ include("./_release.eta", { repo: it.repo, release, linkToDetail: true }) %> <% } %> -- cgit v1.3.1