reverse cli command
This commit is contained in:
158
reverse.ts
Normal file
158
reverse.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user