1import type { Context, Hono } from "hono";2import { isSafePath } from "../bucket/repo.ts";3import { isText, SHOWN_BYTES } from "../describe/describe.ts";4import { type FileEntry, fileAt, headId, objectBytes, saveByPrefix } from "../mirror/queries.ts";5import { highlightLines } from "./highlight.ts";6import { type Html, html } from "./html.ts";7import {8 cacheFor,9 formatSize,10 notFound,11 openPublicRepo,12 type PageContext,13 page,14 pathUrl,15} from "./layout.ts";16import { renderMarkdown } from "./markdown.ts";1718const IMAGE = /\.(png|jpe?g|gif|webp|avif|ico|svg)$/i;19const MARKDOWN = /\.(md|markdown)$/i;202122const PASS_THROUGH = /^(image|audio|video|font)\/|^application\/(pdf|zip|gzip|wasm)$/;2324type Found = {25 repo: string;26 path: string;27 at: string | null;28 file: FileEntry;29 bytes: Uint8Array<ArrayBuffer>;30};3132export function registerFileRoutes(app: Hono, ctx: PageContext): void {33 app.get("/:repo/files/:path{.+}", async (c) => {34 const found = findFile(c, ctx);35 if (!found) return notFound(c, ctx);36 cacheFor(c, found.at !== null);37 const title = found.path.split("/").pop() as string;38 const body = html`${breadcrumb(found)}39<h1 class="file-page-title">${title}</h1>40${await fileBody(found, c.req.query("view") === "source")}`;41 return c.html(42 page(ctx, {43 title: `${title} · ${found.repo}`,44 description: found.path,45 path: c.req.path,46 body,47 }),48 );49 });5051 app.get("/:repo/raw/:path{.+}", (c) => {52 const found = findFile(c, ctx);53 if (!found) return notFound(c, ctx);54 cacheFor(c, found.at !== null);55 const type = contentType(found.path, found.bytes);56 c.header("Content-Type", type);57 return c.body(found.bytes);58 });59}606162function findFile(c: Context, ctx: PageContext): Found | null {63 const name = c.req.param("repo") as string;64 const path = c.req.param("path") as string;65 const repo = openPublicRepo(ctx, name);66 if (!repo || !isSafePath(path)) return null;67 const at = c.req.query("at") ?? null;68 const saveId = at === null ? headId(repo.mirror) : (saveByPrefix(repo.mirror, at)?.id ?? null);69 const file = saveId ? fileAt(repo.mirror, saveId, path) : null;70 const bytes = file && objectBytes(repo.mirror, file.hash);71 return file && bytes ? { repo: name, path, at: at && saveId, file, bytes } : null;72}7374function contentType(path: string, bytes: Uint8Array): string {75 const type = Bun.file(path).type;76 if (type.startsWith("image/")) return type;77 if (isText(bytes)) return "text/plain; charset=utf-8";78 return PASS_THROUGH.test(type) ? type : "application/octet-stream";79}8081function rawHref(found: Found): string {82 return `/${found.repo}/raw/${pathUrl(found.path)}${found.at ? `?at=${found.at}` : ""}`;83}848586function breadcrumb(found: Found): Html {87 const dirs = found.path.split("/").slice(0, -1);88 const folderHref = (depth: number) => {89 const query = new URLSearchParams(found.at ? { at: found.at } : {});90 query.set("open", dirs.slice(0, depth + 1).join("/"));91 return `/${found.repo}?${query}#atlas`;92 };93 return html`<p class="breadcrumb mono"><a href="/${found.repo}">${found.repo}</a>${dirs.map(94 (dir, depth) => html` / <a href="${folderHref(depth)}">${dir}</a>`,95 )}${found.at && html` @ <a href="/${found.repo}/saves/${found.at}">${found.at}</a>`}</p>`;96}9798async function fileBody(found: Found, source: boolean): Promise<Html> {99 const size = formatSize(found.file.size);100 const raw = rawHref(found);101 if (IMAGE.test(found.path)) {102 return html`<p class="file-meta mono">${103 size104 } · <a href="${raw}">raw</a></p><div class="file-image"><img src="${raw}" alt="${found.path}"></div>`;105 }106 if (!isText(found.bytes)) {107 return html`<p class="file-meta mono">binary file · ${108 size109 } · <a href="${raw}" download>download</a></p>`;110 }111 const truncated = found.bytes.length > SHOWN_BYTES;112 const text = new TextDecoder().decode(found.bytes.subarray(0, SHOWN_BYTES));113 const meta = html`<p class="file-meta mono">${114 size115 }${truncated && " · showing the first 1 MB"} · <a href="${raw}">raw</a></p>`;116 if (MARKDOWN.test(found.path)) {117 const at = found.at ? `at=${found.at}&` : "";118 const toggle = html`<p class="file-toggle mono"><a href="?${119 found.at ? `at=${found.at}` : ""120 }"${!source && html` class="active"`}>rendered</a> · <a href="?${at}view=source"${source && html` class="active"`}>source</a></p>`;121 if (!source) {122 const dir = found.path.split("/").slice(0, -1).join("/");123 const rendered = renderMarkdown(text, { repo: found.repo, dir, at: found.at });124 return html`${meta}${toggle}<div class="markdown file-markdown">${rendered}</div>`;125 }126 return html`${meta}${toggle}${await codeView(text, found.path)}`;127 }128 return html`${meta}${await codeView(text, found.path)}`;129}130131132133async function codeView(text: string, path: string): Promise<Html> {134 const lines = await highlightLines(text, path);135 return html`<div class="file-view"><pre class="code"><code>${lines.map(136 (line, i) =>137 html`<span class="line" id="L${138 i + 1139 }"><a class="ln" href="#L${i + 1}">${i + 1}</a><span class="lc">${line}</span></span>`,140 )}</code></pre></div>`;141}