import type { Context, Hono } from "hono"; import { isSafePath } from "../bucket/repo.ts"; import { isText, SHOWN_BYTES } from "../describe/describe.ts"; import { type FileEntry, fileAt, headId, objectBytes, saveByPrefix } from "../mirror/queries.ts"; import { highlightLines } from "./highlight.ts"; import { type Html, html } from "./html.ts"; import { cacheFor, formatSize, notFound, openPublicRepo, type PageContext, page, pathUrl, } from "./layout.ts"; import { renderMarkdown } from "./markdown.ts"; const IMAGE = /\.(png|jpe?g|gif|webp|avif|ico|svg)$/i; const MARKDOWN = /\.(md|markdown)$/i; // Binary types a browser can show or save as they are; anything else textual stays // text/plain (§21.8). const PASS_THROUGH = /^(image|audio|video|font)\/|^application\/(pdf|zip|gzip|wasm)$/; type Found = { repo: string; path: string; at: string | null; file: FileEntry; bytes: Uint8Array; }; export function registerFileRoutes(app: Hono, ctx: PageContext): void { app.get("/:repo/files/:path{.+}", async (c) => { const found = findFile(c, ctx); if (!found) return notFound(c, ctx); cacheFor(c, found.at !== null); const title = found.path.split("/").pop() as string; const body = html`${breadcrumb(found)}

${title}

${await fileBody(found, c.req.query("view") === "source")}`; return c.html( page(ctx, { title: `${title} · ${found.repo}`, description: found.path, path: c.req.path, body, }), ); }); app.get("/:repo/raw/:path{.+}", (c) => { const found = findFile(c, ctx); if (!found) return notFound(c, ctx); cacheFor(c, found.at !== null); const type = contentType(found.path, found.bytes); c.header("Content-Type", type); return c.body(found.bytes); }); } /** The file named by the route at `?at=` or HEAD, behind the visibility gate; null means 404. */ function findFile(c: Context, ctx: PageContext): Found | null { const name = c.req.param("repo") as string; const path = c.req.param("path") as string; const repo = openPublicRepo(ctx, name); if (!repo || !isSafePath(path)) return null; const at = c.req.query("at") ?? null; const saveId = at === null ? headId(repo.mirror) : (saveByPrefix(repo.mirror, at)?.id ?? null); const file = saveId ? fileAt(repo.mirror, saveId, path) : null; const bytes = file && objectBytes(repo.mirror, file.hash); return file && bytes ? { repo: name, path, at: at && saveId, file, bytes } : null; } function contentType(path: string, bytes: Uint8Array): string { const type = Bun.file(path).type; if (type.startsWith("image/")) return type; if (isText(bytes)) return "text/plain; charset=utf-8"; return PASS_THROUGH.test(type) ? type : "application/octet-stream"; } function rawHref(found: Found): string { return `/${found.repo}/raw/${pathUrl(found.path)}${found.at ? `?at=${found.at}` : ""}`; } // Each folder opens the repo's Atlas with that folder unfolded (kepr.js reads `open`). function breadcrumb(found: Found): Html { const dirs = found.path.split("/").slice(0, -1); const folderHref = (depth: number) => { const query = new URLSearchParams(found.at ? { at: found.at } : {}); query.set("open", dirs.slice(0, depth + 1).join("/")); return `/${found.repo}?${query}#atlas`; }; return html``; } async function fileBody(found: Found, source: boolean): Promise { const size = formatSize(found.file.size); const raw = rawHref(found); if (IMAGE.test(found.path)) { return html`

${ size } · raw

${found.path}
`; } if (!isText(found.bytes)) { return html`

binary file · ${ size } · download

`; } const truncated = found.bytes.length > SHOWN_BYTES; const text = new TextDecoder().decode(found.bytes.subarray(0, SHOWN_BYTES)); const meta = html`

${ size }${truncated && " · showing the first 1 MB"} · raw

`; if (MARKDOWN.test(found.path)) { const at = found.at ? `at=${found.at}&` : ""; const toggle = html`

rendered · source

`; if (!source) { const dir = found.path.split("/").slice(0, -1).join("/"); const rendered = renderMarkdown(text, { repo: found.repo, dir, at: found.at }); return html`${meta}${toggle}
${rendered}
`; } return html`${meta}${toggle}${await codeView(text, found.path)}`; } return html`${meta}${await codeView(text, found.path)}`; } // Each line is its own anchor target, so `#L42` scrolls and highlights through CSS alone; the // script only adds ranges (`#L42-L50`). async function codeView(text: string, path: string): Promise { const lines = await highlightLines(text, path); return html`
${lines.map(
		(line, i) =>
			html`${i + 1}${line}`,
	)}
`; }