From 1866da82aea7be7b161f45cedc3737d2147f2c4c Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Wed, 15 Jul 2026 01:36:33 -0400 Subject: Owner page nav menu and layout restructure Add a menu bar of profile tab links, split the org repository list from pinned repos, and restructure the owner template to match repo pages. Co-Authored-By: Claude Fable 5 --- src/scraper.test.ts | 22 ++++++++++++++++++ src/scraper.ts | 57 +++++++++++++++++++++++++++++++++++---------- views/_ownerRepo.eta | 15 ++++++++++++ views/owner.eta | 65 +++++++++++++++++++++++++++++----------------------- 4 files changed, 118 insertions(+), 41 deletions(-) create mode 100644 views/_ownerRepo.eta diff --git a/src/scraper.test.ts b/src/scraper.test.ts index 3048915..d6991d7 100644 --- a/src/scraper.test.ts +++ b/src/scraper.test.ts @@ -285,6 +285,12 @@ describe("GitHub scraper", () => { assert.strictEqual(linux.language, "C"); assert.ok(linux.stars); assert.ok(linux.forks); + + assert.deepStrictEqual(data.repos, []); + const navTexts = data.nav.map((item) => item.text); + assert.deepStrictEqual(navTexts, ["Repositories", "Projects", "Packages"]); + const reposTab = data.nav.find((item) => item.text === "Repositories"); + assert.strictEqual(reposTab?.href, "/torvalds?tab=repositories"); }); it("should fetch user bio and links", async () => { @@ -294,6 +300,7 @@ describe("GitHub scraper", () => { assert.ok(data.bio && data.bio.length > 0); assert.ok(data.links.length > 0); assert.ok(data.links.every((link) => link.href.startsWith("https://"))); + assert.ok(data.nav.some((item) => item.text === "Sponsoring")); }); it("should fetch organization profile", async () => { @@ -310,8 +317,23 @@ describe("GitHub scraper", () => { assert.ok(data.links.some((link) => link.href === "https://github.com/about")); assert.ok(data.pinned.length > 0); + assert.ok(data.pinned.length <= 6, "pinned should not include the repository list"); assert.ok(data.pinned.every((repo) => repo.owner === "github")); assert.ok(data.pinned.some((repo) => repo.stars && repo.forks)); + + assert.ok(data.repos.length > 0); + assert.ok(data.repos.every((repo) => repo.owner === "github")); + + const navTexts = data.nav.map((item) => item.text); + assert.deepStrictEqual(navTexts, [ + "Repositories", + "Projects", + "Packages", + "People", + "Sponsoring", + ]); + const reposTab = data.nav.find((item) => item.text === "Repositories"); + assert.strictEqual(reposTab?.href, "/orgs/github/repositories"); }); it("should redirect to GitHub for non-profile pages", async () => { diff --git a/src/scraper.ts b/src/scraper.ts index 3e3ad75..760a306 100644 --- a/src/scraper.ts +++ b/src/scraper.ts @@ -292,7 +292,7 @@ export interface GitHubRelease extends GitHubCommon { release: Release; } -interface OwnerPinnedRepo { +interface OwnerRepo { owner: string; name: string; description?: string; @@ -301,6 +301,11 @@ interface OwnerPinnedRepo { forks?: string; } +interface OwnerNavItem { + text: string; + href: string; +} + export interface GitHubOwner { type: "user" | "organization"; login: string; @@ -313,7 +318,10 @@ export interface GitHubOwner { company: string | null; location: string | null; links: { text: string; href: string }[]; - pinned: OwnerPinnedRepo[]; + nav: OwnerNavItem[]; + pinned: OwnerRepo[]; + // The repository list on organization overviews; empty for users. + repos: OwnerRepo[]; } async function fetchGitHubPage(path: string): Promise { @@ -770,14 +778,8 @@ function selectText(selector: string, context: AnyNode): string | null { return textContent(elem).replace(/\s+/g, " ").trim() || null; } -// Pinned repositories appear as .pinned-item-list-item on user pages and as -// [itemprop='owns'] blocks on organization pages. -function parsePinnedRepos(document: AnyNode): OwnerPinnedRepo[] { - const pinned: OwnerPinnedRepo[] = []; - const items = cssSelect.selectAll( - ".pinned-item-list-item, [itemprop='owns']", - document, - ) as unknown as Element[]; +function parseRepoItems(items: Element[]): OwnerRepo[] { + const repos: OwnerRepo[] = []; for (const item of items) { const link = cssSelect.selectOne( "a:has(span.repo), a[itemprop~='codeRepository']", @@ -788,7 +790,7 @@ function parsePinnedRepos(document: AnyNode): OwnerPinnedRepo[] { const [owner, name] = href.replace(/^\//, "").split("/"); if (!owner || !name) continue; - pinned.push({ + repos.push({ owner, name, description: selectText("p.pinned-item-desc, [itemprop='description']", item) ?? undefined, @@ -797,7 +799,32 @@ function parsePinnedRepos(document: AnyNode): OwnerPinnedRepo[] { forks: selectText("a[href$='/forks']", item) ?? undefined, }); } - return pinned; + return repos; +} + +function parsePinnedRepos(document: AnyNode): OwnerRepo[] { + return parseRepoItems( + cssSelect.selectAll(".pinned-item-list-item", document) as unknown as Element[], + ); +} + +// The tabs we surface from the profile page's own navigation. Stars and +// Overview are deliberately excluded. +const OWNER_NAV_TABS = ["Repositories", "Projects", "Packages", "People", "Sponsoring"]; + +function parseOwnerNav(document: AnyNode): OwnerNavItem[] { + const nav: OwnerNavItem[] = []; + const seen = new Set(); + const anchors = cssSelect.selectAll("a.UnderlineNav-item", document) as unknown as Element[]; + for (const anchor of anchors) { + const href = getAttributeValue(anchor, "href"); + // Label text is e.g. "Repositories 12" including the counter. + const text = textContent(anchor).replace(/\s+/g, " ").trim().split(" ")[0]; + if (!href || seen.has(href) || !OWNER_NAV_TABS.includes(text)) continue; + seen.add(href); + nav.push({ text, href }); + } + return nav; } function parseUser(document: AnyNode, login: string): GitHubOwner { @@ -832,7 +859,9 @@ function parseUser(document: AnyNode, login: string): GitHubOwner { company: selectText("li[itemprop='worksFor']", document), location: selectText("li[itemprop='homeLocation']", document), links, + nav: parseOwnerNav(document), pinned: parsePinnedRepos(document), + repos: [], }; } @@ -870,7 +899,11 @@ function parseOrganization(document: AnyNode, owner: string): GitHubOwner { company: null, location: selectText(".orghead [itemprop='location']", document), links, + nav: parseOwnerNav(document), pinned: parsePinnedRepos(document), + repos: parseRepoItems( + cssSelect.selectAll("#org-repositories [itemprop='owns']", document) as unknown as Element[], + ), }; } diff --git a/views/_ownerRepo.eta b/views/_ownerRepo.eta new file mode 100644 index 0000000..a857aff --- /dev/null +++ b/views/_ownerRepo.eta @@ -0,0 +1,15 @@ +
+ + <% if (it.repo.description) { %> +
<%= it.repo.description %>
+ <% } %> + <% if (it.repo.language) { %> + <%= it.repo.language %> + <% } %> + <% if (it.repo.stars) { %> + stars <%= it.repo.stars %> + <% } %> + <% if (it.repo.forks) { %> + forks <%= it.repo.forks %> + <% } %> +
diff --git a/views/owner.eta b/views/owner.eta index 4426400..e5667fb 100644 --- a/views/owner.eta +++ b/views/owner.eta @@ -7,48 +7,55 @@ -
+
<% if (it.avatarUrl) { %> <% } %>
<%= it.name || it.login %>
-
<%= it.login %><%= it.type === "organization" ? " (organization)" : "" %>
- <% if (it.bio) { %> -
<%= it.bio %>
- <% } %> - <% if (it.followers) { %> -
<%= it.followers %> followers<%= it.following ? ` · ${it.following} following` : "" %>
- <% } %> - <% if (it.company) { %> -
<%= it.company %>
- <% } %> +
<%= it.login %><%= it.type === "organization" ? " (organization)" : "" %>
<% if (it.location) { %> -
<%= it.location %>
+
<%= it.location %>
<% } %> - <% for (const link of it.links) { %> - + <% if (it.nav.length > 0) { %> + <% } %>
+ <% if (it.bio || it.links.length > 0 || it.followers || it.company) { %> +
+ <% if (it.bio) { %> +
<%= it.bio %>
+ <% } %> + <% for (const link of it.links) { %> + + <% } %> + <% if (it.followers) { %> +
<%= it.followers %> followers<%= it.following ? ` · ${it.following} following` : "" %>
+ <% } %> + <% if (it.company) { %> +
<%= it.company %>
+ <% } %> +
+ <% } %> + <% if (it.pinned.length > 0) { %>

Pinned

<% for (const repo of it.pinned) { %> -
- - <% if (repo.description) { %> -
<%= repo.description %>
- <% } %> - <% if (repo.language) { %> - <%= repo.language %> - <% } %> - <% if (repo.stars) { %> - stars <%= repo.stars %> - <% } %> - <% if (repo.forks) { %> - forks <%= repo.forks %> - <% } %> -
+ <%~ include("./_ownerRepo", { repo, login: it.login }) %> + <% } %> +
+ <% } %> + + <% if (it.repos.length > 0) { %> +
+

Repositories

+ <% for (const repo of it.repos) { %> + <%~ include("./_ownerRepo", { repo, login: it.login }) %> <% } %>
<% } %> -- cgit v1.3.1