1import { extname, join, relative, resolve } from "node:path";2import { expectedPrecacheUrls, sourcePwaIssues } from "./pwa-validation.ts";34const ignoredDirectories = new Set([5 ".astro",6 ".git",7 ".lofi",8 ".playwright-cli",9 ".vite",10 "coverage",11 "node_modules",12 "playwright-report",13 "test-results",14]);1516export type ProjectCheck = {17 name: string;18 status: "ok" | "blocker";19 detail: string;20 remediation?: string;21};2223export async function projectChecks(24 root = Deno.cwd(),25 deploymentBase = "/",26): Promise<ProjectCheck[]> {27 // The generic local-first scaffold every lofi project carries, independent of28 // the app's domain. Islands, schema tables, and stores are author content and29 // are intentionally not required here — a project stays valid after the starter30 // example is replaced.31 const required = [32 "deno.json",33 "src/schema.ts",34 "src/permissions.ts",35 "src/app.ts",36 "src/pages/index.astro",37 "src/layouts/Shell.astro",38 "public/manifest.webmanifest",39 ];40 const missing: string[] = [];41 for (const path of required) {42 try {43 if (!(await Deno.stat(join(root, path))).isFile) missing.push(path);44 } catch (error) {45 if (error instanceof Deno.errors.NotFound) missing.push(path);46 else throw error;47 }48 }49 if (missing.length > 0) {50 return [{51 name: "Project",52 status: "blocker",53 detail: `missing ${missing.join(", ")}`,54 remediation: "restore the generated files or create a fresh project and move author files",55 }];56 }57 const pwaIssues = await sourcePwaIssues(root, deploymentBase);58 return [59 { name: "Project", status: "ok", detail: "generated layout complete" },60 ...(pwaIssues.length === 061 ? [{62 name: "PWA source",63 status: "ok" as const,64 detail: "manifest and assets are installable",65 }]66 : pwaIssues.map((pwaIssue) => ({67 name: "PWA source",68 status: "blocker" as const,69 detail: pwaIssue.detail,70 remediation: pwaIssue.remediation,71 }))),72 ];73}7475export async function walkFiles(76 root = Deno.cwd(),77 options: { includeDist?: boolean; extensions?: ReadonlySet<string> } = {},78): Promise<string[]> {79 const absoluteRoot = resolve(root);80 const files: string[] = [];81 async function visit(path: string) {82 const entries = [...Deno.readDirSync(path)].sort((left, right) =>83 left.name.localeCompare(right.name)84 );85 for (const entry of entries) {86 const child = join(path, entry.name);87 if (88 entry.name === ".env" || (entry.name.startsWith(".env.") && entry.name !== ".env.example")89 ) {90 continue;91 }92 if (entry.isDirectory) {93 if (ignoredDirectories.has(entry.name) && !(entry.name === "dist" && options.includeDist)) {94 continue;95 }96 await visit(join(path, entry.name));97 } else if (entry.isFile) {98 if (!options.extensions || options.extensions.has(extname(child))) {99 files.push(relative(absoluteRoot, child));100 }101 } else {102 throw new Error(103 `project contains unsupported symbolic link: ${relative(absoluteRoot, child)}; ` +104 "replace it with a regular file or directory, then rerun the command",105 );106 }107 }108 }109 await visit(absoluteRoot);110 return files.sort();111}112113export async function sourceFingerprint(root = Deno.cwd()): Promise<string> {114 const files = await walkFiles(root, {115 extensions: new Set([116 ".astro",117 ".css",118 ".js",119 ".json",120 ".png",121 ".svg",122 ".ts",123 ".tsx",124 ".webmanifest",125 ]),126 });127 const encoder = new TextEncoder();128 const chunks: Uint8Array[] = [];129 let length = 0;130 for (const path of files) {131 const header = encoder.encode(`${path}\0`);132 const content = await Deno.readFile(join(root, path));133 chunks.push(header, content);134 length += header.length + content.length;135 }136 const input = new Uint8Array(length);137 let offset = 0;138 for (const chunk of chunks) {139 input.set(chunk, offset);140 offset += chunk.length;141 }142 const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input));143 return Array.from(digest).map((byte) => byte.toString(16).padStart(2, "0")).join("");144}145146export function precacheUrls(147 paths: readonly string[],148 presentationPaths: readonly string[] = [],149): string[] {150 return expectedPrecacheUrls(paths, presentationPaths);151}152153/**154 * Byte-identical groups among build-emitted assets (`_astro/`). The same155 * payload under two names means two build contexts disagreed on an asset156 * naming template, and every copy ships and precaches. User-authored files157 * outside `_astro/` are copied verbatim and are not the build's to judge.158 */159export async function duplicateBuildAssets(160 paths: readonly string[],161 root = "dist",162): Promise<string[][]> {163 const bySize = new Map<number, string[]>();164 for (const path of paths) {165 const portable = path.replaceAll("\\", "/");166 if (!portable.startsWith("_astro/")) continue;167 const { size } = await Deno.stat(join(root, path));168 bySize.set(size, [...(bySize.get(size) ?? []), portable]);169 }170 const groups: string[][] = [];171 for (const candidates of bySize.values()) {172 if (candidates.length < 2) continue;173 const byDigest = new Map<string, string[]>();174 for (const path of candidates) {175 const digest = new Uint8Array(176 await crypto.subtle.digest("SHA-256", await Deno.readFile(join(root, path))),177 );178 const key = Array.from(digest).map((byte) => byte.toString(16).padStart(2, "0")).join("");179 byDigest.set(key, [...(byDigest.get(key) ?? []), path]);180 }181 for (const group of byDigest.values()) {182 if (group.length > 1) groups.push(group.sort());183 }184 }185 return groups.sort((left, right) => left[0].localeCompare(right[0]));186}187188/** Total bytes a fresh install downloads for the app shell (the precached set). */189export async function shellWeightBytes(190 paths: readonly string[],191 root = "dist",192): Promise<number> {193 let total = 0;194 for (const path of paths) total += (await Deno.stat(join(root, path))).size;195 return total;196}197198/** The heaviest shell asset — the one first-visit download time hinges on. */199export async function heaviestShellAsset(200 paths: readonly string[],201 root = "dist",202): Promise<{ path: string; bytes: number } | null> {203 let heaviest: { path: string; bytes: number } | null = null;204 for (const path of paths) {205 const { size } = await Deno.stat(join(root, path));206 if (!heaviest || size > heaviest.bytes) {207 heaviest = { path: path.replaceAll("\\", "/"), bytes: size };208 }209 }210 return heaviest;211}212213/** Build-emitted Jazz engine binaries among dist paths; one after deduplication. */214export function engineWasmAssets(paths: readonly string[]): string[] {215 return paths216 .map((path) => path.replaceAll("\\", "/"))217 .filter((path) => /^_astro\/jazz_wasm_bg\.[^/]+\.wasm$/.test(path))218 .sort();219}220221/**222 * The head tag that starts the engine download while the module graph is still223 * arriving. `crossorigin` makes the preload match the engine glue's plain224 * `fetch()` credentials mode; without it the browser fetches the binary twice.225 * The stamped byte size (uncompressed, unlike a Content-Length over a226 * compressed transfer) lets the runtime report download progress.227 */228export function engineWasmPreloadTag(path: string, basePath: string, bytes: number): string {229 const base = basePath.endsWith("/") ? basePath : `${basePath}/`;230 return `<link rel="preload" href="${base}${path}" as="fetch" crossorigin data-lofi-engine="${bytes}">`;231}232233/** Inserts tags before an HTML document's `</head>`; unchanged when it has none. */234export function injectHeadTags(html: string, tags: readonly string[]): string {235 if (tags.length === 0) return html;236 const headEnd = html.indexOf("</head>");237 if (headEnd === -1) return html;238 return `${html.slice(0, headEnd)}${tags.join("")}${html.slice(headEnd)}`;239}240241function contains(haystack: Uint8Array, needle: Uint8Array): boolean {242 if (needle.length === 0 || haystack.length < needle.length) return false;243 outer:244 for (let offset = 0; offset <= haystack.length - needle.length; offset++) {245 for (let index = 0; index < needle.length; index++) {246 if (haystack[offset + index] !== needle[index]) continue outer;247 }248 return true;249 }250 return false;251}252253export async function scanSecrets(254 secrets: Readonly<Record<string, string>>,255 root = Deno.cwd(),256): Promise<{ scanned: number; leaks: Array<{ name: string; path: string }> }> {257 const values = Object.entries(secrets).filter(([, value]) => value.trim().length > 0);258 const files = await walkFiles(root, { includeDist: true });259 const encoder = new TextEncoder();260 const leaks: Array<{ name: string; path: string }> = [];261 for (const path of files) {262 const content = await Deno.readFile(join(root, path));263 for (const [name, value] of values) {264 if (contains(content, encoder.encode(value))) leaks.push({ name, path });265 }266 }267 return { scanned: files.length, leaks };268}