language: TypeScript
34.6 KB / 1228 lines / 1055 loc
import { parseDocument } from "htmlparser2";
import { isText, type AnyNode, type Element } from "domhandler";
import { getAttributeValue, getInnerHTML, textContent } from "domutils";
import * as cssSelect from "css-select";
export class GitHubHTTPError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "GitHubHTTPError";
this.status = status;
}
}
export abstract class RedirectError extends Error {
location: string;
constructor(location: string) {
super(`Redirect to ${location}`);
this.name = this.constructor.name;
this.location = location;
}
}
export class InternalRedirectError extends RedirectError {}
export class ExternalRedirectError extends RedirectError {}
interface TreeItem {
contentType: "directory" | "file";
name: string;
path: string;
}
interface OverviewFile {
displayName: string;
// null when GitHub includes the file without rendered content.
richText?: string | null;
}
interface IssueNode {
number: number;
title?: string;
titleHtml?: string;
author?: {
login: string;
};
createdAt: string;
state: string;
labels?: {
edges: {
node: {
name: string;
};
}[];
};
}
interface IssueIndexPageQuery {
queryName: "IssueIndexPageQuery";
result: {
data: {
repository: {
search: {
edges: {
node: IssueNode;
}[];
};
};
};
};
}
type StylingDirective = [number, number, string];
export interface Commit {
oid: string;
url: string;
authoredDate: string;
committedDate: string;
shortMessage: string;
bodyMessageHtml: string;
authors: {
login: string;
displayName: string;
avatarUrl: string;
path: string;
}[];
}
export interface CommitGroup {
title: string;
commits: Commit[];
}
interface CodeViewLayoutRoute {
repo: {
ownerLogin: string;
name: string;
};
refInfo: {
name: string;
};
path: string;
}
interface CodeViewRepoRoute {
tree: {
items: TreeItem[];
};
overview?: {
overviewFiles?: OverviewFile[];
};
}
interface CodeViewTreeRoute {
tree: {
items: TreeItem[];
readme?: OverviewFile;
};
}
interface BlobHeaderInfo {
blobSize: string;
lineInfo?: {
truncatedLoc: string | null;
truncatedSloc: string | null;
};
}
interface CodeViewBlobLayoutRoute {
blob: {
headerInfo: BlobHeaderInfo;
language: string | null;
image: boolean;
};
}
interface CodeViewBlobLayoutRouteStyledBlob {
rawLines: string[] | null;
colorizedLines: string[] | null;
stylingDirectives: StylingDirective[][] | null;
}
interface CommitsRefRoute {
commitGroups: CommitGroup[];
}
interface CodeViewBlobRoute {
richText: string | null;
renderedFileInfo?: { renderFileType: string | null } | 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;
codeViewBlobLayoutRoute?: CodeViewBlobLayoutRoute;
"codeViewBlobLayoutRoute.StyledBlob"?: CodeViewBlobLayoutRouteStyledBlob;
codeViewBlobRoute?: CodeViewBlobRoute;
commitsRefRoute?: CommitsRefRoute;
// Actually there may be other query types.
preloadedQueries?: IssueIndexPageQuery[];
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// Renders directives[i..] within [rangeStart, rangeEnd], producing nested spans.
// Returns the rendered content and the index of the first directive not consumed.
function renderRange(
line: string,
directives: StylingDirective[],
i: number,
rangeStart: number,
rangeEnd: number,
): [string, number] {
let result = "";
let pos = rangeStart;
while (i < directives.length) {
const [start, end, className] = directives[i];
// Stop at directives that start outside or extend past this range.
if (start >= rangeEnd || end > rangeEnd) break;
// Combine classes for directives with identical ranges.
let classes = className;
let j = i + 1;
while (j < directives.length && directives[j][0] === start && directives[j][1] === end) {
classes += " " + directives[j][2];
j++;
}
result += escapeHtml(line.substring(pos, start));
const [innerContent, nextI] = renderRange(line, directives, j, start, end);
result += `<span class="${classes}">${innerContent}</span>`;
pos = end;
i = nextI;
}
result += escapeHtml(line.substring(pos, rangeEnd));
return [result, i];
}
function applyStyling(line: string, directives: StylingDirective[]): string {
// Sort by start ascending, then end descending so outer spans precede nested ones.
const sorted = [...directives].sort((a, b) => (a[0] !== b[0] ? a[0] - b[0] : b[1] - a[1]));
const [result] = renderRange(line, sorted, 0, 0, line.length);
return result;
}
interface GitHubCommon {
repo: {
owner: string;
name: string;
};
}
interface GitHubNav extends GitHubCommon {
branch: string;
path: string;
}
export interface GitHubTree extends GitHubNav {
items: TreeItem[];
overviewHtml?: Record<string, string | null>;
}
export interface RepoInfo {
description: string | null;
website: string | null;
stars: string | null;
forks: string | null;
numReleases?: number;
}
export interface GitHubRepo extends GitHubTree {
info: RepoInfo;
}
export interface GitHubBlob extends GitHubNav {
language: string | null;
size: string;
image: boolean;
renderFileType: string | null;
textLines: string[] | null;
htmlLines: string[] | null;
htmlContent: string | null;
}
interface Issue {
number: number;
title: string;
state: string;
createdAt: string;
numReplies?: number;
numPRs?: number;
}
export interface GitHubIssues extends GitHubCommon {
issues: Issue[];
q?: string;
}
export interface GitHubPulls extends GitHubCommon {
pulls: Issue[];
q?: string;
}
export interface GitHubCommits extends GitHubNav {
commitGroups: CommitGroup[];
}
export interface LatestCommit {
oid: string;
date: string;
shortMessageHtml: string;
authors: {
login: string;
displayName: string;
avatarUrl: string;
}[];
}
export interface GitHubLatestCommit extends GitHubCommon {
commit: LatestCommit;
}
export interface Language {
// "Other" for the aggregate bucket GitHub reports without a name.
name: string;
percentage: number;
color: string | null;
}
export interface LatestRelease {
tag: string;
publishedAt: string | null;
}
export interface GitHubSidebar extends GitHubCommon {
languages: Language[];
latestRelease: LatestRelease | null;
}
export interface Ref {
name: string;
isDefault?: boolean;
date?: string;
authorLogin?: string;
}
export interface GitHubRefs extends GitHubCommon {
type: "branches" | "tags";
refs: Ref[];
hasMore: boolean;
}
interface Release {
tagName: string;
title: string;
publishedAt: string;
bodyHtml: string;
assets?: ReleaseAsset[];
}
export interface ReleaseAsset {
name: string;
url: string;
size?: string;
publishedAt?: string;
}
export interface GitHubReleases extends GitHubCommon {
releases: Release[];
}
export interface GitHubRelease extends GitHubCommon {
release: Release;
}
export interface WikiPage {
name: string;
href: string;
updatedAt?: string;
}
export interface GitHubWiki extends GitHubCommon {
title: string;
updatedBy: string | null;
updatedAt: string | null;
bodyHtml: string;
sidebarHtml: string | null;
footerHtml: string | null;
pages: WikiPage[];
}
export interface GitHubWikiPages extends GitHubCommon {
pages: WikiPage[];
}
interface OwnerRepo {
owner: string;
name: string;
description?: string;
language?: string;
stars?: string;
forks?: string;
}
interface OwnerNavItem {
text: string;
href: string;
}
export interface GitHubOwner {
type: "user" | "organization";
login: string;
name: string | null;
bio: string | null;
avatarUrl: string | null;
followers: string | null;
// Users only; always null for organizations.
following: string | null;
company: string | null;
location: string | null;
links: { text: string; href: string }[];
nav: OwnerNavItem[];
pinned: OwnerRepo[];
// The repository list on organization overviews; empty for users.
repos: OwnerRepo[];
}
// GitHub throttles/blocks requests without realistic browser headers.
// These headers make the request appear as a standard browser visit.
function browserHeaders(accept: string): Record<string, string> {
return {
"User-Agent":
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
Accept: accept,
"Accept-Language": "en-US,en;q=0.5",
};
}
async function fetchGitHubResponse(path: string, accept: string): Promise<Response> {
const headers = browserHeaders(accept);
const response = await fetch(`https://github.com/${path}`, { headers, redirect: "manual" });
if (response.status === 301 || response.status === 302) {
const location = response.headers.get("location");
if (location) {
const url = new URL(location);
if (url.hostname === "github.com") {
throw new InternalRedirectError(url.pathname);
}
}
}
if (!response.ok) throw new GitHubHTTPError(response.status, response.statusText);
return response;
}
async function fetchGitHubPage(path: string): Promise<string> {
const response = await fetchGitHubResponse(
path,
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
);
return response.text();
}
// Some routes (e.g. latest-commit) are the same endpoints the React UI itself
// calls to lazily fill in data; they 406 without an explicit JSON Accept.
async function fetchGitHubJson<T>(path: string): Promise<T> {
const response = await fetchGitHubResponse(path, "application/json");
return (await response.json()) as T;
}
function parseEmbeddedPayload<T>(html: string): T | null {
const regex = new RegExp(
`<script type="application/json" data-target="react-app.embeddedData">([^<]+)</script>`,
"g",
);
match: for (const match of html.matchAll(regex)) {
let data;
try {
data = JSON.parse(match[1]);
} catch {
continue;
}
if (data.payload === undefined) continue match;
return data.payload;
}
return null;
}
function parsePayload(html: string): AppPayload | null {
return parseEmbeddedPayload<AppPayload>(html);
}
function extractOverviewHtml(
overviewFiles: OverviewFile[] = [],
): Record<string, string | null> | undefined {
const result: Record<string, string | null> = {};
for (const file of overviewFiles) {
if (file.richText !== undefined) {
result[file.displayName] = file.richText;
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
function extractGitHub<T>(payload: AppPayload | null, extra: T): GitHubNav & T {
const layoutRoute = payload?.codeViewLayoutRoute;
if (layoutRoute === undefined) {
throw new Error("Missing codeViewLayoutRoute");
}
return {
repo: {
owner: layoutRoute.repo.ownerLogin,
name: layoutRoute.repo.name,
},
branch: layoutRoute.refInfo.name,
path: layoutRoute.path,
...extra,
};
}
export async function getGitHubRepo(owner: string, repo: string): Promise<GitHubRepo> {
const html = await fetchGitHubPage(`${owner}/${repo}`);
const payload = parsePayload(html);
const repoRoute = payload?.codeViewRepoRoute;
if (repoRoute?.tree === undefined) {
throw new Error("Could not find tree data in embedded JSON");
}
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 });
}
export async function getGitHubTree(
owner: string,
repo: string,
branch: string,
path: string,
): Promise<GitHubTree> {
const html = await fetchGitHubPage(`${owner}/${repo}/tree/${branch}/${path}`);
const payload = parsePayload(html);
const treeRoute = payload?.codeViewTreeRoute;
if (treeRoute?.tree === undefined) {
throw new Error("Could not find tree data in embedded JSON");
}
const overviewHtml = extractOverviewHtml(treeRoute.tree.readme ? [treeRoute.tree.readme] : []);
return extractGitHub(payload, { items: treeRoute.tree.items, overviewHtml });
}
export async function getGitHubBlob(
owner: string,
repo: string,
branch: string,
path: string,
): Promise<GitHubBlob> {
const html = await fetchGitHubPage(`${owner}/${repo}/blob/${branch}/${path}`);
const payload = parsePayload(html);
const blob = payload?.codeViewBlobLayoutRoute?.blob;
const styledBlob = payload?.["codeViewBlobLayoutRoute.StyledBlob"];
if (blob === undefined) {
throw new Error("Could not find blob data in embedded JSON");
}
let htmlLines = styledBlob?.colorizedLines ?? null;
const stylingDirectives = styledBlob?.stylingDirectives;
if (!htmlLines && stylingDirectives && styledBlob?.rawLines) {
htmlLines = styledBlob.rawLines.map((line, i) => applyStyling(line, stylingDirectives[i]));
}
let size = blob.headerInfo.blobSize;
if (blob.headerInfo.lineInfo?.truncatedLoc) {
size += ` / ${blob.headerInfo.lineInfo.truncatedLoc} lines`;
}
if (blob.headerInfo.lineInfo?.truncatedSloc) {
size += ` / ${blob.headerInfo.lineInfo.truncatedSloc} loc`;
}
return extractGitHub(payload, {
language: blob.language || null,
size,
image: blob.image,
renderFileType: payload?.codeViewBlobRoute?.renderedFileInfo?.renderFileType ?? null,
textLines: styledBlob?.rawLines ?? null,
htmlLines,
htmlContent: payload?.codeViewBlobRoute?.richText ?? null,
});
}
// For proxying embeds.
export async function getGitHubRaw(
owner: string,
repo: string,
branch: string,
path: string,
): Promise<Response> {
const url = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`;
const response = await fetch(url, { headers: browserHeaders("*/*") });
if (!response.ok) throw new GitHubHTTPError(response.status, response.statusText);
return response;
}
export async function getGitHubIssues(
owner: string,
repo: string,
// We only support is:issue searches (no PR results).
{ q }: { q?: string } = {},
): Promise<GitHubIssues> {
const suffix = q ? `?q=${encodeURIComponent(q)}` : "";
const html = await fetchGitHubPage(`${owner}/${repo}/issues${suffix}`);
const payload = parsePayload(html);
let issuesQuery: IssueIndexPageQuery | undefined;
for (const query of payload?.preloadedQueries ?? []) {
if (query.queryName === "IssueIndexPageQuery") {
issuesQuery = query;
}
}
if (issuesQuery === undefined) {
throw new Error("Could not find IssueIndexPageQuery in embedded JSON");
}
const issues: Issue[] = issuesQuery.result.data.repository.search.edges.map((edge) => {
const { number, title, titleHtml, state, createdAt } = edge.node;
const finalTitle = (title || titleHtml || "").replace(/<[^>]+>/g, "");
return { number, title: finalTitle, state, createdAt };
});
return { repo: { owner, name: repo }, issues, q };
}
export async function getGitHubPulls(
owner: string,
repo: string,
{ q }: { q?: string } = {},
): Promise<GitHubPulls> {
const suffix = q ? `?q=${encodeURIComponent(q)}` : "";
const html = await fetchGitHubPage(`${owner}/${repo}/pulls${suffix}`);
const document = parseDocument(html);
const pulls: Issue[] = [];
for (const row of cssSelect.selectAll(".js-issue-row", document) as unknown as Element[]) {
const id = getAttributeValue(row, "id"); // "issue_NNN"
const number = id ? parseInt(id.replace("issue_", ""), 10) : 0;
if (!number) continue;
const titleLink = cssSelect.selectOne("a.markdown-title", row);
const title = titleLink ? textContent(titleLink).trim() : "";
const timeElem = cssSelect.selectOne("relative-time", row);
const createdAt = timeElem ? getAttributeValue(timeElem, "datetime") || "" : "";
const stateSpan = cssSelect.selectOne(".tooltipped[aria-label]", row) as Element | null;
const ariaLabel = stateSpan ? (getAttributeValue(stateSpan, "aria-label") ?? "") : "";
const state = ariaLabel.includes("Merged")
? "MERGED"
: ariaLabel.includes("Closed")
? "CLOSED"
: "OPEN";
pulls.push({ number, title, state, createdAt });
}
return { repo: { owner, name: repo }, pulls, q };
}
export async function getGitHubCommits(
owner: string,
repo: string,
branch: string,
path: string,
): Promise<GitHubCommits> {
const urlPath = path
? `${owner}/${repo}/commits/${branch}/${path}`
: `${owner}/${repo}/commits/${branch}`;
const html = await fetchGitHubPage(urlPath);
const payload = parsePayload(html);
const commitsRoute = payload?.commitsRefRoute;
if (commitsRoute === undefined) {
throw new Error("Could not find commit history in embedded JSON");
}
return {
repo: {
owner,
name: repo,
},
branch,
path,
commitGroups: commitsRoute.commitGroups,
};
}
interface LatestCommitPayload {
oid: string;
date: string;
shortMessageHtmlLink: string;
authors: {
login: string;
displayName: string;
avatarUrl: string;
}[];
}
// This is the same endpoint the React UI itself calls to lazily fill in the
// commit bar above a file listing, rather than the much heavier /commits
// history page: it returns just the single latest commit as JSON.
export async function getGitHubLatestCommit(
owner: string,
repo: string,
branch: string,
path: string,
): Promise<GitHubLatestCommit> {
const urlPath = path
? `${owner}/${repo}/latest-commit/${branch}/${path}`
: `${owner}/${repo}/latest-commit/${branch}`;
const payload = await fetchGitHubJson<LatestCommitPayload>(urlPath);
return {
repo: { owner, name: repo },
commit: {
oid: payload.oid,
date: payload.date,
shortMessageHtml: payload.shortMessageHtmlLink,
authors: payload.authors,
},
};
}
interface SidebarPayload {
languages?: {
languages: {
// Absent for the "Other" bucket.
name?: string;
percentage: number;
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, 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[] = [];
for (const entry of payload.languages?.languages ?? []) {
languages.push({
name: entry.name ?? "Other",
percentage: entry.percentage,
color: entry.color ?? null,
});
}
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 {
name: string;
isDefault: boolean;
authoredDate?: string;
author?: { login: string };
}
interface BranchesPayload {
branches: {
default?: BranchRefEntry;
yours?: BranchRefEntry[];
active?: BranchRefEntry[];
stale?: BranchRefEntry[];
};
hasMore?: Record<string, boolean>;
}
async function getGitHubBranchRefs(owner: string, repo: string): Promise<GitHubRefs> {
const html = await fetchGitHubPage(`${owner}/${repo}/branches`);
const payload = parseEmbeddedPayload<BranchesPayload>(html);
if (payload?.branches === undefined) {
throw new Error("Could not find branches data in embedded JSON");
}
const { default: defaultBranch, yours = [], active = [], stale = [] } = payload.branches;
const entries = [...(defaultBranch ? [defaultBranch] : []), ...yours, ...active, ...stale];
const seen = new Set<string>();
const refs: Ref[] = [];
for (const entry of entries) {
if (seen.has(entry.name)) continue;
seen.add(entry.name);
refs.push({
name: entry.name,
isDefault: entry.isDefault || undefined,
date: entry.authoredDate,
authorLogin: entry.author?.login,
});
}
const hasMore = Object.values(payload.hasMore ?? {}).some(Boolean);
return { repo: { owner, name: repo }, type: "branches", refs, hasMore };
}
async function getGitHubTagRefs(owner: string, repo: string): Promise<GitHubRefs> {
const html = await fetchGitHubPage(`${owner}/${repo}/tags`);
const document = parseDocument(html);
const refs: Ref[] = [];
for (const row of cssSelect.selectAll(".Box-row", document) as unknown as Element[]) {
const nameElem = cssSelect.selectOne("h2 a", row);
if (!nameElem) continue;
const name = textContent(nameElem).trim();
if (!name) continue;
// The row has two <relative-time> elements: a release/tag creation
// timestamp, then the tagged commit's authored date. We want the latter.
const timeElems = cssSelect.selectAll("relative-time", row);
const timeElem = timeElems[1] as Element | undefined;
const date = timeElem ? getAttributeValue(timeElem, "datetime") || undefined : undefined;
refs.push({ name, date });
}
const hasMore = cssSelect.selectOne(".pagination a", document) !== null;
return { repo: { owner, name: repo }, type: "tags", refs, hasMore };
}
export async function getGitHubRefs(
owner: string,
repo: string,
type: "branches" | "tags",
): Promise<GitHubRefs> {
return type === "branches" ? getGitHubBranchRefs(owner, repo) : getGitHubTagRefs(owner, repo);
}
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<GitHubReleases> {
const html = await fetchGitHubPage(`${owner}/${repo}/releases`);
const document = parseDocument(html);
const releases: Release[] = [];
// Not really sure the right way to express this type.
const sections = cssSelect.selectAll("section", document) as unknown as Element[];
for (const section of sections) {
const h2 = cssSelect.selectOne("h2[id]", section);
if (!h2) continue;
const title = textContent(h2).trim();
releases.push(parseRelease(section, title));
}
return {
repo: {
owner,
name: repo,
},
releases,
};
}
export async function getGitHubReleaseBase(
owner: string,
repo: string,
tag: string,
): Promise<GitHubRelease> {
const html = await fetchGitHubPage(`${owner}/${repo}/releases/tag/${tag}`);
const document = parseDocument(html);
const box = cssSelect.selectOne(".Box:has(h1)", document) as Element | null;
if (!box) throw new Error("Could not find release box");
const h1 = cssSelect.selectOne("h1", box);
const title = h1 ? textContent(h1).trim() : "";
const release = parseRelease(box, title);
return {
repo: {
owner,
name: repo,
},
release,
};
}
export async function getGitHubReleaseAssets(
owner: string,
repo: string,
tag: string,
): Promise<ReleaseAsset[]> {
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<GitHubRelease> {
const [base, assets] = await Promise.all([
getGitHubReleaseBase(owner, repo, tag),
getGitHubReleaseAssets(owner, repo, tag),
]);
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;
}
function parseRepoItems(items: Element[]): OwnerRepo[] {
const repos: OwnerRepo[] = [];
for (const item of items) {
const link = cssSelect.selectOne(
"a:has(span.repo), a[itemprop~='codeRepository']",
item,
) as Element | null;
const href = link ? getAttributeValue(link, "href") : undefined;
if (!href) continue;
const [owner, name] = href.replace(/^\//, "").split("/");
if (!owner || !name) continue;
repos.push({
owner,
name,
description: selectText("p.pinned-item-desc, [itemprop='description']", item) ?? undefined,
language: selectText("[itemprop='programmingLanguage']", item) ?? undefined,
stars: selectText("a[href$='/stargazers']", item) ?? undefined,
forks: selectText("a[href$='/forks']", item) ?? undefined,
});
}
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<string>();
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 {
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 });
}
return {
type: "user",
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,
nav: parseOwnerNav(document),
pinned: parsePinnedRepos(document),
repos: [],
};
}
function parseOrganization(document: AnyNode, owner: string): GitHubOwner {
const avatarElem = cssSelect.selectOne(
".orghead img[itemprop='image']",
document,
) as Element | null;
const avatarUrl = avatarElem ? getAttributeValue(avatarElem, "src") || null : null;
// The login only appears in the avatar's "@login" alt text.
const alt = avatarElem ? getAttributeValue(avatarElem, "alt") : undefined;
const login = alt?.replace(/^@/, "") || owner;
const followers =
selectText("a[href$='/followers']", document)?.replace(/ followers?$/, "") ?? null;
const links: { text: string; href: string }[] = [];
const anchors = cssSelect.selectAll(
".orghead a[itemprop='url'], .orghead [itemprop='email'] a",
document,
) as unknown as Element[];
for (const anchor of anchors) {
const href = getAttributeValue(anchor, "href");
if (!href) continue;
links.push({ text: textContent(anchor).replace(/\s+/g, " ").trim(), href });
}
return {
type: "organization",
login,
name: selectText(".orghead h1", document),
bio: selectText(".orghead h1 + div", document),
avatarUrl,
followers,
following: null,
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[],
),
};
}
export async function getGitHubOwner(owner: string): Promise<GitHubOwner> {
const html = await fetchGitHubPage(owner);
const document = parseDocument(html);
const login = selectText(".p-nickname.vcard-username", document);
if (login) {
return parseUser(document, login);
}
if (cssSelect.selectOne(".orghead", document)) {
return parseOrganization(document, owner);
}
// Not a profile page (marketing pages like github.com/features, etc.).
throw new ExternalRedirectError(`https://github.com/${owner}`);
}
// The text directly inside an element, ignoring any nested elements' text.
function directText(elem: Element): string {
return elem.children
.filter(isText)
.map((node) => node.data)
.join("")
.replace(/\s+/g, " ")
.trim();
}
// The sidebar holds every wiki page, which are revealed by JavaScript.
function parseWikiSidebarPages(document: AnyNode): WikiPage[] {
const pages: WikiPage[] = [];
const anchors = cssSelect.selectAll(
".js-wiki-sidebar-page-container > div > a[href]",
document,
) as unknown as Element[];
for (const anchor of anchors) {
const href = getAttributeValue(anchor, "href");
const name = textContent(anchor).replace(/\s+/g, " ").trim();
if (href && name) pages.push({ name, href });
}
return pages;
}
function wikiHtml(selector: string, document: AnyNode): string | null {
const elem = cssSelect.selectOne(selector, document);
if (!elem) return null;
const html = getInnerHTML(elem).trim();
return html || null;
}
export async function getGitHubWiki(
owner: string,
repo: string,
// Empty for the wiki's home page, which GitHub serves off /wiki itself.
page = "",
// A revision SHA renders that older version of the page.
oid = "",
): Promise<GitHubWiki> {
// Page names keep characters that are not path-safe (C#, [DEV] ...), so the
// slug has to be re-encoded rather than interpolated the way a ref is.
const path = [`${owner}/${repo}/wiki`, page && encodeURIComponent(page), oid]
.filter(Boolean)
.join("/");
const html = await fetchGitHubPage(path);
const document = parseDocument(html);
const heading = cssSelect.selectOne("h1.gh-header-title", document) as Element | null;
if (!heading) {
throw new Error("Could not find the wiki page heading");
}
// "<author> edited this page <date> · <n> revisions", where the author is the
// only part not wrapped in an element of its own.
const meta = cssSelect.selectOne(".gh-header-meta", document) as Element | null;
const updatedBy = meta ? (/^(.+?) edited this page\b/.exec(directText(meta))?.[1] ?? null) : null;
const time = meta ? cssSelect.selectOne("relative-time", meta) : null;
return {
repo: { owner, name: repo },
title: directText(heading),
updatedBy,
updatedAt: time ? getAttributeValue(time, "datetime") || null : null,
bodyHtml: wikiHtml("#wiki-body .markdown-body", document) ?? "",
// Wikis can define _Sidebar and _Footer pages, rendered alongside every page.
sidebarHtml: wikiHtml(".wiki-custom-sidebar", document),
footerHtml: wikiHtml("#wiki-footer .markdown-body", document),
pages: parseWikiSidebarPages(document),
};
}
export async function getGitHubWikiPages(owner: string, repo: string): Promise<GitHubWikiPages> {
const html = await fetchGitHubPage(`${owner}/${repo}/wiki/_pages`);
const document = parseDocument(html);
const pages: WikiPage[] = [];
for (const row of cssSelect.selectAll(
"#wiki-content li.Box-row",
document,
) as unknown as Element[]) {
const anchor = cssSelect.selectOne("a[href]", row) as Element | null;
const href = anchor ? getAttributeValue(anchor, "href") : undefined;
const name = anchor ? textContent(anchor).replace(/\s+/g, " ").trim() : "";
if (!href || !name) continue;
const time = cssSelect.selectOne("relative-time", row) as Element | null;
pages.push({ name, href, updatedAt: time ? getAttributeValue(time, "datetime") : undefined });
}
return { repo: { owner, name: repo }, pages };
}