import type { Eta } from "eta";
import { type Context, Hono } from "hono";
import type { StatusCode } from "hono/utils/http-status";
import { commit } from "./commit.generated.ts";
import {
getGitHubBlob,
getGitHubCommits,
getGitHubIssues,
getGitHubSidebar,
getGitHubLatestCommit,
getGitHubOwner,
getGitHubPulls,
getGitHubRefs,
getGitHubRelease,
getGitHubReleases,
getGitHubRepo,
getGitHubTree,
GitHubHTTPError,
RedirectError,
} from "./scraper.ts";
// Our URLs mirror GitHub's, so the same path there is the page we scraped.
function githubUrlFor(c: Context) {
const { pathname, search } = new URL(c.req.url);
return `https://github.com${pathname}${search}`;
}
export function createApp(eta: Eta) {
const app = new Hono();
// Use a meta refresh to avoid redirect loops in certain situations; we become
// the initiator origin even if we are the target of a redirection. Requests
// coming from one of our own pages are already past that hazard, and are
// often subresources (an
in a rendered README) that can't do anything
// with an HTML page, so those get a real HTTP redirect.
function redirectToGitHub(c: Context, location: string) {
const referer = c.req.header("Referer");
if (referer && URL.parse(referer)?.origin === new URL(c.req.url).origin) {
return c.redirect(location);
}
c.header("Referrer-Policy", "no-referrer");
return c.html(eta.render("redirect.eta", { location }));
}
app.get("/", async (c) => {
return c.html(eta.render("home.eta", {}));
});
async function tryRender(c: Context, template: string, promise: Promise) {
let data: T;
try {
data = await promise;
} catch (e) {
if (e instanceof RedirectError) {
if (e.location.startsWith("https://")) {
return redirectToGitHub(c, e.location);
}
return c.redirect(e.location);
} else if (e instanceof GitHubHTTPError) {
c.status(e.status as StatusCode);
const message = `GitHub responded with HTTP ${e.status} ${e.message}`;
return c.html(eta.render("error.eta", { title: e.message, message }));
} else {
c.status(500);
return c.html(eta.render("error.eta", { message: "" + e }));
}
}
return c.html(eta.render(template, { ...data, githubUrl: githubUrlFor(c), commit }));
}
// For fragments fetched asynchronously by client-side JS (see public/static/refs.js):
// errors render as an empty body rather than the full error.eta page.
async function tryRenderFragment(
c: Context,
template: string,
promise: Promise,
) {
let data: T;
try {
data = await promise;
} catch (e) {
const status =
e instanceof GitHubHTTPError
? (e.status as StatusCode)
: e instanceof RedirectError
? 404
: 500;
c.status(status);
return c.body(null);
}
return c.html(eta.render(template, data));
}
app.get("/:owner", async (c) => {
const { owner } = c.req.param();
return tryRender(c, "owner.eta", getGitHubOwner(owner));
});
app.get("/:owner/:repo", async (c) => {
const { owner, repo } = c.req.param();
return tryRender(c, "repo.eta", getGitHubRepo(owner, repo));
});
app.get("/:owner/:repo/tree/:branch/:path{.*}?", async (c) => {
const { owner, repo, branch, path = "" } = c.req.param();
return tryRender(c, "tree.eta", getGitHubTree(owner, repo, branch, path));
});
app.get("/:owner/:repo/blob/:branch/:path{.*}", async (c) => {
const { owner, repo, branch, path } = c.req.param();
return tryRender(c, "blob.eta", getGitHubBlob(owner, repo, branch, path));
});
app.get("/:owner/:repo/raw/:branch/:path{.*}", async (c) => {
const { owner, repo, branch, path } = c.req.param();
return redirectToGitHub(
c,
`https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`,
);
});
app.get("/:owner/:repo/issues", async (c) => {
const { owner, repo } = c.req.param();
const q = c.req.query("q");
// GitHub supports combined issue/PR search, apparently for legacy. We just
// redirect to issue-only search.
if (q && !/\bis:issue\b/.test(q)) {
return c.redirect(`/${owner}/${repo}/issues?q=${encodeURIComponent(`is:issue ${q}`)}`);
}
return tryRender(c, "issues.eta", getGitHubIssues(owner, repo, { q }));
});
app.get("/:owner/:repo/pulls", async (c) => {
const { owner, repo } = c.req.param();
const q = c.req.query("q");
return tryRender(c, "pulls.eta", getGitHubPulls(owner, repo, { q }));
});
app.get("/:owner/:repo/search", async (c) => {
const { owner, repo } = c.req.param();
const q = c.req.query("q") ?? "";
const type = c.req.query("type") ?? "issues";
if (type === "issues") {
return c.redirect(`/${owner}/${repo}/issues?q=${encodeURIComponent(q)}`);
} else if (type === "pullrequests") {
return c.redirect(`/${owner}/${repo}/pulls?q=${encodeURIComponent(q)}`);
}
// Redirect unhandled search types (like code, which requires sign in anyway).
return redirectToGitHub(c, githubUrlFor(c));
});
app.get("/:owner/:repo/commits/:branch/:path{.*}?", async (c) => {
const { owner, repo, branch, path = "" } = c.req.param();
return tryRender(c, "commits.eta", getGitHubCommits(owner, repo, branch, path));
});
// Internal endpoints fetched asynchronously by client-side JS, not meant to
// be visited directly. Keep the site's initial page loads plain server-
// rendered HTML while still surfacing data (branches/tags, latest commit)
// that would otherwise require an extra scrape on every page view.
// The view and path are where the picker was opened, so each ref can link to
// the same page on another ref.
app.get("/api/:owner/:repo/refs/:type/:view/:path{.*}?", async (c) => {
const { owner, repo, type, view, path = "" } = c.req.param();
if (type !== "branches" && type !== "tags") {
return c.notFound();
}
if (view !== "tree" && view !== "blob" && view !== "commits") {
return c.notFound();
}
const refs = getGitHubRefs(owner, repo, type).then((data) => ({ ...data, view, path }));
return tryRenderFragment(c, "_refs.eta", refs);
});
app.get("/api/:owner/:repo/latest-commit/:branch/:path{.*}?", async (c) => {
const { owner, repo, branch, path = "" } = c.req.param();
return tryRenderFragment(
c,
"_latestCommit.eta",
getGitHubLatestCommit(owner, repo, branch, path),
);
});
app.get("/api/:owner/:repo/sidebar", async (c) => {
const { owner, repo } = c.req.param();
return tryRenderFragment(c, "_sidebar.eta", getGitHubSidebar(owner, repo));
});
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));
});
app.all("*", async (c) => {
return redirectToGitHub(c, githubUrlFor(c));
});
return app;
}