clone

push a directory of html from the terminal and get a four-character url back. a deno cli on cloudflare

main
nzip / cli / lib / bundle.ts
bundle.ts 3.5K
1// Directory walking, hashing, and manifest building for `nzip site push`.23import { globToRegExp, join, relative } from "@std/path";4import {5 contentTypeFor,6 MAX_BLOB_BYTES,7 MAX_UNIQUE_BLOBS,8 sha256hex,9 validateManifest,10 validatePath,11} from "@nzip/shared";12import type { Manifest } from "@nzip/shared";1314const SKIP_DIRS = new Set(["node_modules", ".git"]);1516export interface Bundle {17 manifest: Manifest;18 /** blob hash → bytes (deduped) */19 blobs: Map<string, Uint8Array>;20 totalBytes: number;21 warnings: string[];22}2324async function loadIgnore(dir: string): Promise<RegExp[]> {25 try {26 const text = await Deno.readTextFile(join(dir, ".nzipignore"));27 return text.split("\n")28 .map((l) => l.trim())29 .filter((l) => l.length > 0 && !l.startsWith("#"))30 .map((glob) => globToRegExp(glob, { extended: true, globstar: true }));31 } catch {32 return [];33 }34}3536async function* walk(37 root: string,38 dir: string,39 ignore: RegExp[],40): AsyncGenerator<string> {41 for await (const entry of Deno.readDir(dir)) {42 if (entry.name.startsWith(".")) continue;43 const full = join(dir, entry.name);44 const rel = relative(root, full);45 if (ignore.some((re) => re.test(rel))) continue;46 if (entry.isDirectory) {47 if (SKIP_DIRS.has(entry.name)) continue;48 yield* walk(root, full, ignore);49 } else if (entry.isFile) {50 yield rel;51 }52 }53}5455export async function buildBundle(path: string): Promise<Bundle> {56 const stat = await Deno.stat(path);57 const warnings: string[] = [];58 const manifest: Manifest = { v: 1, files: {} };59 const blobs = new Map<string, Uint8Array>();60 let totalBytes = 0;6162 const addFile = async (storedPath: string, filePath: string) => {63 const fileStat = await Deno.stat(filePath);64 if (fileStat.size > MAX_BLOB_BYTES) {65 throw new Error(`file too large (max 50 MiB): ${storedPath}`);66 }67 const bytes = await Deno.readFile(filePath);68 if (bytes.length > MAX_BLOB_BYTES) {69 throw new Error(`file too large (max 50 MiB): ${storedPath}`);70 }71 const hash = await sha256hex(bytes);72 manifest.files[storedPath] = {73 h: hash,74 s: bytes.length,75 ct: contentTypeFor(storedPath),76 };77 if (!blobs.has(hash)) {78 if (blobs.size >= MAX_UNIQUE_BLOBS) {79 throw new Error(`too many unique blobs (max ${MAX_UNIQUE_BLOBS})`);80 }81 blobs.set(hash, bytes);82 }83 totalBytes += bytes.length;84 };8586 if (stat.isFile) {87 // Single-file push: an HTML file becomes the site's index.html.88 const name = path.split("/").pop()!;89 const stored = /\.html?$/i.test(name) ? "index.html" : name;90 await addFile(stored, path);91 validateManifest(manifest);92 return { manifest, blobs, totalBytes, warnings };93 }9495 const ignore = await loadIgnore(path);96 for await (const rel of walk(path, path, ignore)) {97 const stored = rel.replaceAll("\\", "/");98 if (!validatePath(stored)) {99 warnings.push(`skipped (unsupported path): ${stored}`);100 continue;101 }102 await addFile(stored, join(path, rel));103 }104105 if (Object.keys(manifest.files).length === 0) {106 throw new Error(`no files to push in ${path}`);107 }108 if (!manifest.files["index.html"]) {109 warnings.push(110 "no root index.html — the site URL will 404 until one exists",111 );112 }113 validateManifest(manifest);114 return { manifest, blobs, totalBytes, warnings };115}116117export function formatBytes(n: number): string {118 if (n < 1024) return `${n} B`;119 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;120 return `${(n / (1024 * 1024)).toFixed(1)} MB`;121}