import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync, } from "node:fs" import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path" import { TextDecoder } from "node:util" import type { ReverseMapFile, ReverseMapManifest, ReverseMapToken } from "./render" export type ReverseOptions = { mapPath?: string } export type ReverseWarning = { outputPath: string token: string result: string message: string } export type ReverseResult = { filesWritten: number warnings: ReverseWarning[] } function isInsidePath(parent: string, child: string): boolean { const rel = relative(parent, child) return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)) } function resolveInside(root: string, path: string): string { const resolved = resolvePath(root, path) if (!isInsidePath(root, resolved)) { throw new Error(`Refusing to write outside target directory: ${path}`) } return resolved } function isUtf8Text(buffer: Buffer): boolean { if (buffer.indexOf(0) !== -1) return false try { new TextDecoder("utf-8", { fatal: true }).decode(buffer) return true } catch { return false } } function readManifest(mapPath: string): ReverseMapManifest { const manifest = JSON.parse(readFileSync(mapPath, "utf-8")) as ReverseMapManifest if (manifest.version !== 1 || !Array.isArray(manifest.files)) { throw new Error(`Unsupported reverse map: ${mapPath}`) } return manifest } function replaceAtRange(content: string, token: ReverseMapToken): string | null { if (!token.range) return null const { start, end } = token.range if (start < 0 || end < start || end > content.length) return null if (content.slice(start, end) !== token.result) return null return `${content.slice(0, start)}${token.token}${content.slice(end)}` } function replaceFirst(content: string, token: ReverseMapToken): string | null { const index = content.indexOf(token.result) if (index === -1) return null return `${content.slice(0, index)}${token.token}${content.slice(index + token.result.length)}` } function reverseContent( content: string, file: ReverseMapFile, warnings: ReverseWarning[], ): string { const tokens = file.tokens .filter(token => token.kind === "content") .sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1)) let reversed = content for (const token of tokens) { const rangeResult = replaceAtRange(reversed, token) if (rangeResult !== null) { reversed = rangeResult continue } const fallbackResult = replaceFirst(reversed, token) if (fallbackResult !== null) { reversed = fallbackResult continue } warnings.push({ outputPath: file.outputPath, token: token.token, result: token.result, message: "Rendered value was not found; token was not restored", }) } return reversed } export function reverseDir( renderedDir: string, templateDir: string, options: ReverseOptions = {}, ): ReverseResult { const renderedRoot = resolvePath(renderedDir) const templateRoot = resolvePath(templateDir) const mapPath = options.mapPath ? resolvePath(renderedRoot, options.mapPath) : resolvePath(renderedRoot, ".tdir-map.json") const manifest = readManifest(mapPath) const warnings: ReverseWarning[] = [] let filesWritten = 0 for (const file of manifest.files) { const renderedPath = resolveInside(renderedRoot, file.outputPath) const templatePath = resolveInside(templateRoot, file.templatePath) if (!existsSync(renderedPath)) { warnings.push({ outputPath: file.outputPath, token: "", result: "", message: "Rendered path does not exist; skipped", }) continue } const stat = statSync(renderedPath) if (stat.isDirectory()) { mkdirSync(templatePath, { recursive: true }) continue } if (!stat.isFile()) continue mkdirSync(dirname(templatePath), { recursive: true }) const content = readFileSync(renderedPath) if (!isUtf8Text(content)) { copyFileSync(renderedPath, templatePath) filesWritten += 1 continue } writeFileSync( templatePath, reverseContent(content.toString("utf-8"), file, warnings), ) filesWritten += 1 } return { filesWritten, warnings } }