summaryrefslogtreecommitdiff
path: root/cgithub
diff options
context:
space:
mode:
Diffstat (limited to 'cgithub')
-rw-r--r--cgithub/CLAUDE.md1
-rw-r--r--cgithub/src/index.ts9
-rw-r--r--cgithub/src/scraper.test.ts49
-rw-r--r--cgithub/src/scraper.ts96
-rw-r--r--cgithub/static/style.css5
-rw-r--r--cgithub/views/home.eta2
-rw-r--r--cgithub/views/user.eta58
7 files changed, 218 insertions, 2 deletions
diff --git a/cgithub/CLAUDE.md b/cgithub/CLAUDE.md
index 8feb529..f6d8524 100644
--- a/cgithub/CLAUDE.md
+++ b/cgithub/CLAUDE.md
@@ -26,6 +26,7 @@ pnpm test
**Templates:** `views/*.eta` - Eta templates with layout inheritance (`layout.eta` as base)
**Routes:**
+- `GET /:user` - User profile with pinned repositories
- `GET /:owner/:repo` - Repository info with directory listing
- `GET /:owner/:repo/tree/:branch/:path*` - Directory listing
- `GET /:owner/:repo/blob/:branch/:path` - File content view
diff --git a/cgithub/src/index.ts b/cgithub/src/index.ts
index 514f2e2..d8bdfec 100644
--- a/cgithub/src/index.ts
+++ b/cgithub/src/index.ts
@@ -14,6 +14,7 @@ import {
getGitHubReleases,
getGitHubRepo,
getGitHubTree,
+ getGitHubUser,
GitHubHTTPError,
RedirectError,
} from "./scraper.ts";
@@ -35,6 +36,9 @@ async function tryRender<T extends object>(c: Context, template: string, promise
data = await promise;
} catch (e) {
if (e instanceof RedirectError) {
+ if (e.location.startsWith("https://")) {
+ c.header("Referrer-Policy", "no-referrer");
+ }
return c.redirect(e.location);
} else if (e instanceof GitHubHTTPError) {
c.status(e.status as StatusCode);
@@ -48,6 +52,11 @@ async function tryRender<T extends object>(c: Context, template: string, promise
return c.html(await eta.renderAsync(template, data));
}
+app.get("/:user", async (c) => {
+ const { user } = c.req.param();
+ return tryRender(c, "user.eta", getGitHubUser(user));
+});
+
app.get("/:owner/:repo", async (c) => {
const { owner, repo } = c.req.param();
return tryRender(c, "repo.eta", getGitHubRepo(owner, repo));
diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts
index a4b834e..4a01853 100644
--- a/cgithub/src/scraper.test.ts
+++ b/cgithub/src/scraper.test.ts
@@ -9,6 +9,7 @@ import {
getGitHubReleases,
getGitHubRepo,
getGitHubTree,
+ getGitHubUser,
GitHubHTTPError,
RedirectError,
} from "./scraper.ts";
@@ -264,6 +265,54 @@ describe("GitHub scraper", () => {
});
});
+ describe("user pages", () => {
+ it("should fetch user profile", async () => {
+ const data = await getGitHubUser("torvalds");
+
+ assert.strictEqual(data.login, "torvalds");
+ assert.strictEqual(data.name, "Linus Torvalds");
+ assert.strictEqual(data.company, "Linux Foundation");
+ assert.strictEqual(data.location, "Portland, OR");
+ assert.ok(data.avatarUrl?.startsWith("https://avatars.githubusercontent.com/"));
+ assert.ok(data.followers);
+ assert.ok(data.following !== null);
+
+ assert.ok(data.pinned.length > 0);
+ const linux = data.pinned.find((repo) => repo.name === "linux");
+ assert.ok(linux);
+ assert.strictEqual(linux.owner, "torvalds");
+ assert.strictEqual(linux.description, "Linux kernel source tree");
+ assert.strictEqual(linux.language, "C");
+ assert.ok(linux.stars);
+ assert.ok(linux.forks);
+ });
+
+ it("should fetch user bio and links", async () => {
+ const data = await getGitHubUser("sindresorhus");
+
+ assert.strictEqual(data.login, "sindresorhus");
+ assert.ok(data.bio && data.bio.length > 0);
+ assert.ok(data.links.length > 0);
+ assert.ok(data.links.every((link) => link.href.startsWith("https://")));
+ });
+
+ it("should redirect to GitHub for organizations", async () => {
+ await assert.rejects(getGitHubUser("github"), (err) => {
+ assert(err instanceof RedirectError, "error should be a RedirectError");
+ assert.strictEqual(err.location, "https://github.com/github");
+ return true;
+ });
+ });
+
+ it("should throw for non-existent user", async () => {
+ await assert.rejects(getGitHubUser("nosuchuserzzzz"), (err) => {
+ assert(err instanceof GitHubHTTPError, "error should be an HTTPError");
+ assert.strictEqual(err.status, 404);
+ return true;
+ });
+ });
+ });
+
describe("error handling", () => {
it("should throw for non-existent repository", async () => {
await assert.rejects(getGitHubRepo("nosuchowner", "nosuchrepo"), (err) => {
diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts
index f32a8da..a44f0a7 100644
--- a/cgithub/src/scraper.ts
+++ b/cgithub/src/scraper.ts
@@ -1,5 +1,5 @@
import { parseDocument } from "htmlparser2";
-import { type Element } from "domhandler";
+import { type AnyNode, type Element } from "domhandler";
import { getAttributeValue, getInnerHTML, textContent } from "domutils";
import * as cssSelect from "css-select";
@@ -293,6 +293,28 @@ export interface GitHubRelease extends GitHubCommon {
release: Release;
}
+interface UserPinnedRepo {
+ owner: string;
+ name: string;
+ description?: string;
+ language?: string;
+ stars?: string;
+ forks?: string;
+}
+
+export interface GitHubUser {
+ login: string;
+ name: string | null;
+ bio: string | null;
+ avatarUrl: string | null;
+ followers: string | null;
+ following: string | null;
+ company: string | null;
+ location: string | null;
+ links: { text: string; href: string }[];
+ pinned: UserPinnedRepo[];
+}
+
async function fetchGitHubPage(path: string): Promise<string> {
// GitHub throttles/blocks requests without realistic browser headers.
// These headers make the request appear as a standard browser visit.
@@ -744,3 +766,75 @@ export async function getGitHubRelease(
base.release.assets = assets;
return base;
}
+
+function selectText(selector: string, context: AnyNode): string | null {
+ const elem = cssSelect.selectOne(selector, context);
+ if (!elem) return null;
+ return textContent(elem).replace(/\s+/g, " ").trim() || null;
+}
+
+export async function getGitHubUser(user: string): Promise<GitHubUser> {
+ const html = await fetchGitHubPage(user);
+ const document = parseDocument(html);
+
+ // Organizations and other non-profile pages lack the user vcard.
+ const login = selectText(".p-nickname.vcard-username", document);
+ if (!login) {
+ throw new RedirectError(`https://github.com/${user}`);
+ }
+
+ const avatarElem = cssSelect.selectOne("a[itemprop='image'] img", document) as Element | null;
+ const avatarUrl = avatarElem ? getAttributeValue(avatarElem, "src") || null : null;
+
+ const followers =
+ selectText("a[href$='tab=followers']", document)?.replace(/ followers?$/, "") ?? null;
+ const following =
+ selectText("a[href$='tab=following']", document)?.replace(/ following$/, "") ?? null;
+
+ const links: { text: string; href: string }[] = [];
+ const linkItems = cssSelect.selectAll(
+ "li[itemprop='url'], li[itemprop='social']",
+ document,
+ ) as unknown as Element[];
+ for (const item of linkItems) {
+ const anchor = cssSelect.selectOne("a[href]", item) as Element | null;
+ const href = anchor ? getAttributeValue(anchor, "href") : undefined;
+ if (!anchor || !href) continue;
+ links.push({ text: textContent(anchor).replace(/\s+/g, " ").trim(), href });
+ }
+
+ const pinned: UserPinnedRepo[] = [];
+ const pinnedItems = cssSelect.selectAll(
+ ".pinned-item-list-item",
+ document,
+ ) as unknown as Element[];
+ for (const item of pinnedItems) {
+ const link = cssSelect.selectOne("a:has(span.repo)", item) as Element | null;
+ const href = link ? getAttributeValue(link, "href") : undefined;
+ if (!href) continue;
+ const [owner, name] = href.replace(/^\//, "").split("/");
+ if (!owner || !name) continue;
+
+ pinned.push({
+ owner,
+ name,
+ description: selectText("p.pinned-item-desc", item) ?? undefined,
+ language: selectText("[itemprop='programmingLanguage']", item) ?? undefined,
+ stars: selectText("a[href$='/stargazers']", item) ?? undefined,
+ forks: selectText("a[href$='/forks']", item) ?? undefined,
+ });
+ }
+
+ return {
+ login,
+ name: selectText(".p-name.vcard-fullname", document),
+ bio: selectText(".p-note.user-profile-bio", document),
+ avatarUrl,
+ followers,
+ following,
+ company: selectText("li[itemprop='worksFor']", document),
+ location: selectText("li[itemprop='homeLocation']", document),
+ links,
+ pinned,
+ };
+}
diff --git a/cgithub/static/style.css b/cgithub/static/style.css
index 86a67d8..e698993 100644
--- a/cgithub/static/style.css
+++ b/cgithub/static/style.css
@@ -148,6 +148,11 @@ body > footer {
font-weight: bold;
}
+.avatar {
+ float: right;
+ clip-path: circle();
+}
+
.branch {
display: inline-block;
margin-right: 1rem;
diff --git a/cgithub/views/home.eta b/cgithub/views/home.eta
index adb71f2..0026ea3 100644
--- a/cgithub/views/home.eta
+++ b/cgithub/views/home.eta
@@ -12,7 +12,7 @@
</header>
<main>
- <p>Browse any GitHub repository by navigating to <code>/:owner/:repo</code></p>
+ <p>Browse any GitHub repository by navigating to <code>/:owner/:repo</code>, or a user profile at <code>/:user</code></p>
</main>
<h2>Credits</h2>
diff --git a/cgithub/views/user.eta b/cgithub/views/user.eta
new file mode 100644
index 0000000..cf2da94
--- /dev/null
+++ b/cgithub/views/user.eta
@@ -0,0 +1,58 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title><%= it.name ? `${it.name} (${it.login})` : it.login %></title>
+ <link rel="stylesheet" href="/static/style.css">
+</head>
+<body>
+ <div class="box repo-info">
+ <% if (it.avatarUrl) { %>
+ <img class="avatar" src="<%= it.avatarUrl %>" alt="" width="64" height="64">
+ <% } %>
+ <div class="repo"><%= it.name || it.login %></div>
+ <div class="detail"><%= it.login %></div>
+ <% if (it.bio) { %>
+ <div class="description"><%= it.bio %></div>
+ <% } %>
+ <% if (it.followers) { %>
+ <div class="detail"><img src="/static/icons/eye.svg" alt="" class="icon"> <%= it.followers %> followers<%= it.following ? ` ยท ${it.following} following` : "" %></div>
+ <% } %>
+ <% if (it.company) { %>
+ <div class="detail"><%= it.company %></div>
+ <% } %>
+ <% if (it.location) { %>
+ <div class="detail"><%= it.location %></div>
+ <% } %>
+ <% for (const link of it.links) { %>
+ <div class="detail"><img src="/static/icons/link.svg" alt="" class="icon"> <a href="<%= link.href %>" rel="noopener noreferrer"><%= link.text %></a></div>
+ <% } %>
+ </div>
+
+ <% if (it.pinned.length > 0) { %>
+ <section>
+ <h2>Pinned</h2>
+ <% for (const repo of it.pinned) { %>
+ <div class="box repo-info">
+ <div><a href="/<%= repo.owner %>/<%= repo.name %>"><%= repo.owner === it.login ? repo.name : `${repo.owner}/${repo.name}` %></a></div>
+ <% if (repo.description) { %>
+ <div class="description"><%= repo.description %></div>
+ <% } %>
+ <% if (repo.language) { %>
+ <span class="detail"><%= repo.language %></span>
+ <% } %>
+ <% if (repo.stars) { %>
+ <span class="detail"><img src="/static/icons/star.svg" alt="stars" class="icon"> <%= repo.stars %></span>
+ <% } %>
+ <% if (repo.forks) { %>
+ <span class="detail"><img src="/static/icons/git-fork.svg" alt="forks" class="icon"> <%= repo.forks %></span>
+ <% } %>
+ </div>
+ <% } %>
+ </section>
+ <% } %>
+
+ <footer>Generated by cgithub</footer>
+</body>
+</html>