1// Directory walking, hashing, and manifest building for `nzip push`.23import { globToRegExp, join, relative } from "@std/path";4import { contentTypeFor, sha256hex, validatePath } from "@nzip/shared";5import type { Manifest } from "@nzip/shared";67const SKIP_DIRS = new Set(["node_modules", ".git"]);89export interface Bundle {10 manifest: Manifest;11 /** blob hash → bytes (deduped) */12 blobs: Map<string, Uint8Array>;13 totalBytes: number;14 warnings: string[];15}1617async function loadIgnore(dir: string): Promise<RegExp[]> {18 try {19 const text = await Deno.readTextFile(join(dir, ".nzipignore"));20 return text.split("\n")21 .map((l) => l.trim())22 .filter((l) => l.length > 0 && !l.startsWith("#"))23 .map((glob) => globToRegExp(glob, { extended: true, globstar: true }));24 } catch {25 return [];26 }27}2829async function* walk(root: string, dir: string, ignore: RegExp[]): AsyncGenerator<string> {30 for await (const entry of Deno.readDir(dir)) {31 if (entry.name.startsWith(".")) continue;32 const full = join(dir, entry.name);33 const rel = relative(root, full);34 if (ignore.some((re) => re.test(rel))) continue;35 if (entry.isDirectory) {36 if (SKIP_DIRS.has(entry.name)) continue;37 yield* walk(root, full, ignore);38 } else if (entry.isFile) {39 yield rel;40 }41 }42}4344export async function buildBundle(path: string): Promise<Bundle> {45 const stat = await Deno.stat(path);46 const warnings: string[] = [];47 const manifest: Manifest = { v: 1, files: {} };48 const blobs = new Map<string, Uint8Array>();49 let totalBytes = 0;5051 const addFile = async (storedPath: string, filePath: string) => {52 const bytes = await Deno.readFile(filePath);53 const hash = await sha256hex(bytes);54 manifest.files[storedPath] = { h: hash, s: bytes.length, ct: contentTypeFor(storedPath) };55 if (!blobs.has(hash)) blobs.set(hash, bytes);56 totalBytes += bytes.length;57 };5859 if (stat.isFile) {60 // Single-file push: an HTML file becomes the site's index.html.61 const name = path.split("/").pop()!;62 const stored = /\.html?$/i.test(name) ? "index.html" : name;63 await addFile(stored, path);64 return { manifest, blobs, totalBytes, warnings };65 }6667 const ignore = await loadIgnore(path);68 for await (const rel of walk(path, path, ignore)) {69 const stored = rel.replaceAll("\\", "/");70 if (!validatePath(stored)) {71 warnings.push(`skipped (unsupported path): ${stored}`);72 continue;73 }74 await addFile(stored, join(path, rel));75 }7677 if (Object.keys(manifest.files).length === 0) {78 throw new Error(`no files to push in ${path}`);79 }80 if (!manifest.files["index.html"]) {81 warnings.push("no root index.html — the site URL will 404 until one exists");82 }83 return { manifest, blobs, totalBytes, warnings };84}8586export function formatBytes(n: number): string {87 if (n < 1024) return `${n} B`;88 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;89 return `${(n / (1024 * 1024)).toFixed(1)} MB`;90}