Files
tdir/reverse.ts
Gregor Lohaus af0c25e64b
Some checks failed
Publish npm package / publish (push) Failing after 21s
full template restoreablity
2026-05-24 12:17:12 +02:00

257 lines
7.1 KiB
TypeScript

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, ReverseMapStoredTemplate, 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 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
}
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
}
for (const skipped of manifest.skipped ?? []) {
filesWritten += writeSkippedTemplate(templateRoot, skipped)
}
return { filesWritten, warnings }
}