Files
ditto.site/packages/api/src/files.ts
T
2026-06-29 15:11:48 -07:00

98 lines
3.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { FileFacet } from "./backend.js";
export type FileMeta = { path: string; type: "text" | "binary"; bytes: number; sha256: string };
/** Minimal glob → RegExp: `**` = any (incl. `/`), `*` = any except `/`, `?` = one
* non-`/`. Sufficient for `**/*.tsx`, `src/app/*.css`, etc. */
export function globToRegExp(glob: string): RegExp {
let re = "";
for (let i = 0; i < glob.length; i++) {
const c = glob[i]!;
if (c === "*") {
if (glob[i + 1] === "*") {
re += ".*";
i++;
} else {
re += "[^/]*";
}
} else if (c === "?") {
re += "[^/]";
} else {
re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
}
}
return new RegExp("^" + re + "$");
}
export function metaOf(facets: FileFacet[]): FileMeta[] {
return facets.map((f) => ({ path: f.path, type: f.kind, bytes: f.bytes, sha256: f.sha256 }));
}
/** Filter file metadata by glob and/or route (Next = `src/app/<seg>`, Vite = `src/routes/<key>`). */
export function filterMetas(metas: FileMeta[], opts: { glob?: string; route?: string }): FileMeta[] {
let out = metas;
if (opts.glob) {
const re = globToRegExp(opts.glob);
out = out.filter((m) => re.test(m.path));
}
if (opts.route && opts.route !== "/") {
const seg = opts.route.replace(/^\/+/, "").replace(/\/+$/, "");
const viteKey = seg.replace(/\//g, "__") || "home";
out = out.filter((m) =>
m.path === `src/app/${seg}` || m.path.startsWith(`src/app/${seg}/`) ||
m.path === `src/routes/${viteKey}` || m.path.startsWith(`src/routes/${viteKey}/`)
);
}
return out;
}
/** Opaque numeric-offset pagination over a sorted list. */
export function paginate<T>(arr: T[], cursor: string | undefined, limit: number): { items: T[]; nextCursor?: string } {
const offset = cursor ? Math.max(0, parseInt(cursor, 10) || 0) : 0;
const items = arr.slice(offset, offset + limit);
const next = offset + limit;
return next < arr.length ? { items, nextCursor: String(next) } : { items };
}
export type ReadFileEntry =
| { path: string; type: "text"; bytes: number; sha256: string; content: string }
| { path: string; type: "text"; bytes: number; sha256: string; skipped: true; reason: string }
| { path: string; type: "binary"; bytes: number; sha256: string; url: string }
| { path: string; error: string };
/** Read the requested paths with a per-call text-size budget. Binaries are always
* returned as URLs (never bytes); text over budget is flagged skipped, so a careless
* `["**"]` (no exact match anyway) can't flood the consumer's context. */
export async function readFiles(
facets: FileFacet[],
paths: string[],
opts: { maxBytes?: number; resolveUrl?: (u: string) => string },
): Promise<{ files: ReadFileEntry[]; totalBytes: number; truncated: boolean }> {
const budget = opts.maxBytes ?? 256 * 1024;
const byPath = new Map(facets.map((f) => [f.path, f]));
const out: ReadFileEntry[] = [];
let used = 0;
let truncated = false;
for (const p of paths) {
const f = byPath.get(p);
if (!f) {
out.push({ path: p, error: "not found" });
continue;
}
if (f.kind === "binary") {
let url = f.binaryUrl ? await f.binaryUrl() : "";
if (opts.resolveUrl) url = opts.resolveUrl(url);
out.push({ path: p, type: "binary", bytes: f.bytes, sha256: f.sha256, url });
continue;
}
if (used + f.bytes > budget) {
out.push({ path: p, type: "text", bytes: f.bytes, sha256: f.sha256, skipped: true, reason: "per-call size budget exceeded" });
truncated = true;
continue;
}
used += f.bytes;
out.push({ path: p, type: "text", bytes: f.bytes, sha256: f.sha256, content: f.content ?? "" });
}
return { files: out, totalBytes: used, truncated };
}