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/views/_release.eta | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 cgithub/views/_release.eta (limited to 'cgithub/views/_release.eta') 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 %> +
+ <% } %> +
-- cgit v1.3.1 From 4b86cf887f30988232c4877e828b231777ce3f4e Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Sat, 21 Feb 2026 16:46:11 +1100 Subject: Release styling --- cgithub/static/style.css | 58 ++++++++-------------------------------------- cgithub/views/_release.eta | 22 +++++++----------- cgithub/views/layout.eta | 1 + cgithub/views/release.eta | 4 ---- cgithub/views/releases.eta | 2 -- 5 files changed, 20 insertions(+), 67 deletions(-) (limited to 'cgithub/views/_release.eta') diff --git a/cgithub/static/style.css b/cgithub/static/style.css index 896f138..fdf1b34 100644 --- a/cgithub/static/style.css +++ b/cgithub/static/style.css @@ -167,7 +167,7 @@ pre.box { width: fit-content; } -.overview { +.overview, .release { overflow-wrap: break-word; /* This is a bit convoluted: we want the effect of padding but using margins @@ -193,7 +193,7 @@ pre.box { border-bottom: 1px solid var(--border-color); } - img { + img, video { max-width: 100%; } @@ -207,6 +207,14 @@ pre.box { } } +.release > header { + display: flex; + align-items: baseline; + > :first-child { + flex-grow: 1; + } +} + .timestamp { white-space: nowrap; font-size: .8rem; @@ -232,52 +240,6 @@ pre.box { font-size: 0.8rem; } -.release-nav { - margin-bottom: 1rem; -} - -.release { - margin-bottom: 2rem; -} - -.release-header { - display: flex; - justify-content: space-between; - align-items: baseline; - margin-bottom: 1rem; - padding-bottom: .5rem; - border-bottom: 1px solid var(--border-color); - - h3 { - margin: 0; - } -} - -.release-body { - overflow-wrap: break-word; - - a { - text-decoration: underline; - } - - h1, h2 { - border-bottom: 1px solid var(--border-color); - } - - img { - max-width: 100%; - } - - code, pre { - background: var(--shade-background); - } - - pre { - overflow-x: auto; - scrollbar-color: var(--border-color) var(--shade-background); - } -} - /* TODO Strip permalinks? */ svg.octicon-link { display: none; diff --git a/cgithub/views/_release.eta b/cgithub/views/_release.eta index 85126a2..4769f9b 100644 --- a/cgithub/views/_release.eta +++ b/cgithub/views/_release.eta @@ -1,23 +1,19 @@
-
-

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

+ + <% } else { %> + <%= it.release.title %> + <% } %> <% if (it.release.publishedAt) { %> <% } %> -
+ <% if (it.release.bodyHtml) { %> -
- <%~ it.release.bodyHtml %> -
+ <%~ it.release.bodyHtml %> <% } %>
diff --git a/cgithub/views/layout.eta b/cgithub/views/layout.eta index a404437..3910d4b 100644 --- a/cgithub/views/layout.eta +++ b/cgithub/views/layout.eta @@ -16,6 +16,7 @@ git clone git@github.com:<%= it.repo.owner %>/<%= it.repo.name %>.git Commits <% } %> + Releases diff --git a/cgithub/views/release.eta b/cgithub/views/release.eta index b8c0fe6..46dd5db 100644 --- a/cgithub/views/release.eta +++ b/cgithub/views/release.eta @@ -1,7 +1,3 @@ <% 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 b3cb254..840908c 100644 --- a/cgithub/views/releases.eta +++ b/cgithub/views/releases.eta @@ -1,7 +1,5 @@ <% layout("./layout.eta") %> -

Releases

- <% for (const release of it.releases) { %> <%~ include("./_release.eta", { repo: it.repo, release, linkToDetail: true }) %> <% } %> -- cgit v1.3.1 From 0d95c588319e6f25f4b24612e730f662e209b5dc Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Sat, 21 Feb 2026 21:53:32 +1100 Subject: Release assets Also fix release content parsing. --- cgithub/src/scraper.test.ts | 6 +++ cgithub/src/scraper.ts | 100 ++++++++++++++++++++++++++++++++------------ cgithub/static/style.css | 14 ++++++- cgithub/views/_release.eta | 13 ++++++ cgithub/views/layout.eta | 2 +- 5 files changed, 106 insertions(+), 29 deletions(-) (limited to 'cgithub/views/_release.eta') diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts index 5bd609a..125e6a6 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -188,6 +188,12 @@ describe("GitHub scraper", () => { assert.ok(data.release.title.length > 0); assert.ok(data.release.publishedAt.length > 0); assert.ok(data.release.bodyHtml.length > 0); + assert.ok(data.release.assets && data.release.assets.length >= 2); + assert.strictEqual(data.release.assets[0].name, "Source code (zip)"); + assert.strictEqual( + data.release.assets[0].url, + "https://github.com/actions/deploy-pages/archive/refs/tags/v4.0.5.zip", + ); }); }); diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index 0ed77b5..769b870 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -196,6 +196,14 @@ interface Release { title: string; publishedAt: string; bodyHtml: string; + assets?: ReleaseAsset[]; +} + +export interface ReleaseAsset { + name: string; + url: string; + size?: string; + publishedAt?: string; } export interface GitHubReleases extends GitHubCommon { @@ -259,14 +267,14 @@ function parseRepoInfo(html: string): RepoInfo { const websiteElem = cssSelect.selectOne( "a[rel='noopener noreferrer nofollow']", parent, - ) as Element; + ) as Element | null; if (websiteElem) { website = getAttributeValue(websiteElem, "href") || null; } } } - const starsElem = cssSelect.selectOne("#repo-stars-counter-star", document) as unknown as Element; + const starsElem = cssSelect.selectOne("#repo-stars-counter-star", document) as Element | null; const stars = starsElem ? getAttributeValue(starsElem, "title") || textContent(starsElem).trim() : null; @@ -274,7 +282,7 @@ function parseRepoInfo(html: string): RepoInfo { const watchersElem = cssSelect.selectOne("a[href$='/watchers'] strong", document); const watchers = watchersElem ? textContent(watchersElem).trim() : null; - const forksElem = cssSelect.selectOne("#repo-network-counter", document) as unknown as Element; + const forksElem = cssSelect.selectOne("#repo-network-counter", document) as Element | null; const forks = forksElem ? getAttributeValue(forksElem, "title") || textContent(forksElem).trim() : null; @@ -282,7 +290,7 @@ function parseRepoInfo(html: string): RepoInfo { const releaseElem = cssSelect.selectOne( "a[href$='/releases'] .Counter", document, - ) as unknown as Element; + ) as Element | null; let numReleases: number | undefined; let latestReleaseTag: string | undefined; let latestReleaseDate: string | undefined; @@ -520,7 +528,6 @@ export async function getGitHubReleases(owner: string, repo: string): Promise { const html = await fetchGitHubPage(`${owner}/${repo}/releases/tag/${tag}`); - // Find the h1 tag with the release title - const h1Match = html.match(/]*class="[^"]*d-inline[^"]*"[^>]*>([^<]*)<\/h1>/); - const title = h1Match?.[1]?.trim() || tag; + const document = parseDocument(html); - // Extract tag name from tree link - const tagLinkMatch = html.match(/]*href="[^"]*\/tree\/([^"]+)"[^>]*>/); - const tagName = tagLinkMatch?.[1] || tag; + const box = cssSelect.selectOne(".Box:has(h1)", document) as Element | null; + if (!box) throw new Error("Could not find release box"); - // Extract published date - const dateMatch = html.match(/]*datetime="([^"]*)"[^>]*>/); - const publishedAt = dateMatch?.[1] || ""; + const h1 = cssSelect.selectOne("h1", box); + const title = h1 ? textContent(h1).trim() : ""; - // 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() || ""; + const release = parseRelease(box, title); return { repo: { owner, name: repo, }, - release: { - tagName, - title, - publishedAt, - bodyHtml, - }, + release, }; } + +export async function getGitHubReleaseAssets( + owner: string, + repo: string, + tag: string, +): Promise { + const html = await fetchGitHubPage(`${owner}/${repo}/releases/expanded_assets/${tag}`); + const document = parseDocument(html); + const assets: ReleaseAsset[] = []; + + for (const row of cssSelect.selectAll("li", document) as unknown as Element[]) { + const link = cssSelect.selectOne("a[href]", row); + if (!link) continue; + + const url = getAttributeValue(link, "href") || ""; + // Filename is in the link, often split into multiple spans + const name = textContent(link).replace(/\s+/g, " ").trim(); + + let size: string | undefined; + let publishedAt: string | undefined; + for (const s of cssSelect.selectAll("span.color-fg-muted.text-right", row)) { + // Size is in a span with color-fg-muted and text-right, but avoid the one with relative-time + const timeElem = cssSelect.selectOne("relative-time", row); + if (timeElem) { + publishedAt = timeElem ? getAttributeValue(timeElem, "datetime") || "" : ""; + } else { + const text = textContent(s).trim(); + if (text) size = text; + } + } + + assets.push({ + name, + url: url.startsWith("/") ? `https://github.com${url}` : url, + size, + publishedAt, + }); + } + + return assets; +} + +export async function getGitHubRelease( + owner: string, + repo: string, + tag: string, +): Promise { + const [base, assets] = await Promise.all([ + getGitHubReleaseBase(owner, repo, tag), + getGitHubReleaseAssets(owner, repo, tag), + ]); + base.release.assets = assets; + return base; +} diff --git a/cgithub/static/style.css b/cgithub/static/style.css index fdf1b34..b3ebb46 100644 --- a/cgithub/static/style.css +++ b/cgithub/static/style.css @@ -111,11 +111,16 @@ section { margin-bottom: 0; padding: .5rem; border-bottom: 1px solid var(--border-color); - font-weight: bold; + } + + > footer:last-child { + margin: 0 calc(-1 * var(--box-padding)); + padding: .5rem; + border-top: 1px solid var(--border-color); } } -footer { +body > footer { margin-top: 1rem; text-align: center; } @@ -207,11 +212,16 @@ pre.box { } } +.overview > header { + font-weight: bold; +} + .release > header { display: flex; align-items: baseline; > :first-child { flex-grow: 1; + font-weight: bold; } } diff --git a/cgithub/views/_release.eta b/cgithub/views/_release.eta index 4769f9b..a8838fb 100644 --- a/cgithub/views/_release.eta +++ b/cgithub/views/_release.eta @@ -16,4 +16,17 @@ <% if (it.release.bodyHtml) { %> <%~ it.release.bodyHtml %> <% } %> + <% if (it.release.assets && it.release.assets.length > 0) { %> +
+

Assets

+ <% for (const asset of it.release.assets) { %> +
+ <%= asset.name %> + <% if (asset.publishedAt) { %> + + <% } %> +
+ <% } %> +
+ <% } %> diff --git a/cgithub/views/layout.eta b/cgithub/views/layout.eta index 3910d4b..c42d593 100644 --- a/cgithub/views/layout.eta +++ b/cgithub/views/layout.eta @@ -13,10 +13,10 @@ git clone git@github.com:<%= it.repo.owner %>/<%= it.repo.name %>.git Code Issues + Releases <% if (it.branch) { %> Commits <% } %> - Releases -- cgit v1.3.1