447 lines
13 KiB
TypeScript
447 lines
13 KiB
TypeScript
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 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<string, string> {
|
|
const mappings = new Map<string, string>([["", ""]])
|
|
|
|
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, string>): 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<string, string>,
|
|
): 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 })
|
|
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,
|
|
)
|
|
|
|
return { filesWritten, warnings }
|
|
}
|