import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, } from "node:fs" import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path" import { TextDecoder } from "node:util" import type { ReverseMapFile, ReverseMapManifest, ReverseMapStoredTemplate, ReverseMapToken } from "./render" export type ReverseOptions = { mapPath?: string include?: string | 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 normalizePath(path: string): string { return path.split("\\").join("/") } function escapeRegExp(text: string): string { return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&") } function globToRegExp(glob: string): RegExp { let source = "^" const pattern = normalizePath(glob) for (let i = 0; i < pattern.length; i++) { const char = pattern[i]! const next = pattern[i + 1] if (char === "*" && next === "*") { if (pattern[i + 2] === "/") { source += "(?:.*/)?" i += 2 } else { source += ".*" i += 1 } } else if (char === "*") { source += "[^/]*" } else if (char === "?") { source += "[^/]" } else { source += escapeRegExp(char) } } return new RegExp(`${source}$`) } function getIncludeMatchers(include: ReverseOptions["include"]): RegExp[] { if (!include) return [] return (Array.isArray(include) ? include : [include]).map(globToRegExp) } function matchesAny(path: string, matchers: RegExp[]): boolean { const normalized = normalizePath(path) return matchers.some(matcher => matcher.test(normalized)) } 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 applyActiveBranch(token: ReverseMapToken, branchContent: string): string { if (!token.activeRange) return token.token return [ token.token.slice(0, token.activeRange.start), branchContent, token.token.slice(token.activeRange.end), ].join("") } function findPrefixEnd(content: string, before: string): number { if (before === "") return 0 let candidate = before while (candidate.length >= 8) { const index = content.indexOf(candidate) if (index !== -1) return index + candidate.length candidate = candidate.slice(-Math.max(1, Math.floor(candidate.length / 2))) } return -1 } function findSuffixStart(content: string, after: string, from: number): number { if (after === "") return content.length let candidate = after while (candidate.length >= 8) { const index = content.indexOf(candidate, from) if (index !== -1) return index candidate = candidate.slice(0, Math.floor(candidate.length / 2)) } return -1 } function replaceConditional(content: string, token: ReverseMapToken): string | null { const exactIndex = content.indexOf(token.result) if (exactIndex !== -1) { return [ content.slice(0, exactIndex), token.token, content.slice(exactIndex + token.result.length), ].join("") } if (token.before === undefined || token.after === undefined) return null const branchStart = findPrefixEnd(content, token.before) if (branchStart === -1) return null const afterIndex = findSuffixStart(content, token.after, branchStart) if (afterIndex === -1) return null return [ content.slice(0, branchStart), applyActiveBranch(token, content.slice(branchStart, afterIndex)), content.slice(afterIndex), ].join("") } function reverseContent( content: string, file: ReverseMapFile, warnings: ReverseWarning[], ): string { const contentTokens = 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 contentTokens) { 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", }) } const conditionalTokens = file.tokens .filter(token => token.kind === "conditional") .sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1)) for (const token of conditionalTokens) { const result = replaceConditional(reversed, token) if (result !== null) { reversed = result continue } warnings.push({ outputPath: file.outputPath, token: token.token, result: token.result, message: "Rendered conditional block was not found; block was not restored", }) } return reversed } function writeSkippedTemplate( templateRoot: string, skipped: ReverseMapStoredTemplate, ): number { const templatePath = resolveInside(templateRoot, skipped.templatePath) if (skipped.kind === "directory") { mkdirSync(templatePath, { recursive: true }) return 0 } mkdirSync(dirname(templatePath), { recursive: true }) const content = skipped.encoding === "base64" ? Buffer.from(skipped.content ?? "", "base64") : skipped.content ?? "" writeFileSync(templatePath, content) return 1 } function replaceFlatTokens(content: string, manifest: ReverseMapManifest): string { const entries = Object.entries(manifest.tokens) .filter(([, tokens]) => tokens.length > 0) .sort(([a], [b]) => b.length - a.length) let result = content for (const [rendered, tokens] of entries) { if (rendered === "") continue result = result.split(rendered).join(tokens[0]!) } return result } function dirnamePath(path: string): string { const normalized = normalizePath(path) const index = normalized.lastIndexOf("/") return index === -1 ? "" : normalized.slice(0, index) } function basenamePath(path: string): string { const normalized = normalizePath(path) const index = normalized.lastIndexOf("/") return index === -1 ? normalized : normalized.slice(index + 1) } function joinPath(...parts: string[]): string { return parts .filter(part => part !== "") .join("/") } function buildDirectoryMap(manifest: ReverseMapManifest): Map { const mappings = new Map([["", ""]]) function addMapping(outputDir: string, templateDir: string) { const outputParts = normalizePath(outputDir).split("/").filter(Boolean) const templateParts = normalizePath(templateDir).split("/").filter(Boolean) for (let i = 1; i <= outputParts.length; i++) { if (i <= templateParts.length) { mappings.set(outputParts.slice(0, i).join("/"), templateParts.slice(0, i).join("/")) } } mappings.set(normalizePath(outputDir), normalizePath(templateDir)) } for (const file of manifest.files) { const output = normalizePath(file.outputPath) const template = normalizePath(file.templatePath) addMapping(dirnamePath(output), dirnamePath(template)) if (file.tokens.some(token => token.kind === "path")) addMapping(output, template) } for (const skipped of manifest.skipped ?? []) { if (skipped.kind === "directory") continue addMapping(dirnamePath(skipped.templatePath), dirnamePath(skipped.templatePath)) } return mappings } function inferTemplatePath(outputPath: string, directoryMap: Map): string { const normalized = normalizePath(outputPath) const outputDir = dirnamePath(normalized) let bestOutputDir = "" let bestTemplateDir = "" for (const [mappedOutputDir, mappedTemplateDir] of directoryMap) { if ( mappedOutputDir.length >= bestOutputDir.length && (outputDir === mappedOutputDir || outputDir.startsWith(`${mappedOutputDir}/`)) ) { bestOutputDir = mappedOutputDir bestTemplateDir = mappedTemplateDir } } const suffix = bestOutputDir === "" ? outputDir : outputDir.slice(bestOutputDir.length).replace(/^\//, "") return joinPath(bestTemplateDir, suffix, basenamePath(normalized)) } function walkFiles(root: string, excludedRoots: string[] = []): string[] { const files: string[] = [] const pending = [root] while (pending.length > 0) { const current = pending.pop()! for (const entry of readdirSync(current).sort()) { const path = resolvePath(current, entry) if (excludedRoots.some(excluded => isInsidePath(excluded, path))) continue const stat = statSync(path) if (stat.isDirectory()) { pending.push(path) } else if (stat.isFile()) { files.push(normalizePath(relative(root, path))) } } } return files } function copyIncludedRenderedFiles( renderedRoot: string, templateRoot: string, includedOutputPaths: string[], directoryMap: Map, manifest: ReverseMapManifest, ): number { let filesWritten = 0 for (const outputPath of includedOutputPaths) { const renderedPath = resolveInside(renderedRoot, outputPath) const templatePath = resolveInside(templateRoot, inferTemplatePath(outputPath, directoryMap)) mkdirSync(dirname(templatePath), { recursive: true }) const content = readFileSync(renderedPath) if (isUtf8Text(content)) { writeFileSync(templatePath, replaceFlatTokens(content.toString("utf-8"), manifest)) } else { copyFileSync(renderedPath, templatePath) } filesWritten += 1 } return filesWritten } function getIncludedRenderedFiles( renderedRoot: string, templateRoot: string, mapPath: string, manifest: ReverseMapManifest, include: ReverseOptions["include"], ): string[] { const matchers = getIncludeMatchers(include) if (matchers.length === 0) return [] const mappedOutputPaths = new Set(manifest.files.map(file => normalizePath(file.outputPath))) const mapRelativePath = normalizePath(relative(renderedRoot, mapPath)) return walkFiles(renderedRoot, [templateRoot]).filter(outputPath => { return outputPath !== mapRelativePath && !mappedOutputPaths.has(outputPath) && matchesAny(outputPath, matchers) }) } 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 directoryMap = buildDirectoryMap(manifest) const includedOutputPaths = getIncludedRenderedFiles( renderedRoot, templateRoot, mapPath, manifest, options.include, ) 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 } for (const skipped of manifest.skipped ?? []) { filesWritten += writeSkippedTemplate(templateRoot, skipped) } filesWritten += copyIncludedRenderedFiles( renderedRoot, templateRoot, includedOutputPaths, directoryMap, manifest, ) return { filesWritten, warnings } }