371 lines
11 KiB
TypeScript
371 lines
11 KiB
TypeScript
import {
|
|
copyFileSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
rmSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from "node:fs"
|
|
import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path"
|
|
import { homedir } from "node:os"
|
|
import { TextDecoder } from "node:util"
|
|
|
|
const IF_PATH_RE = /^<@if\((.+?)\)>(.*)$/
|
|
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g
|
|
const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g
|
|
const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
|
|
const PATH_RE = /^context\.(.+)$/
|
|
|
|
export type ReverseMapToken = {
|
|
kind: "path" | "content"
|
|
result: string
|
|
token: string
|
|
contextPath?: string
|
|
outputPath: string
|
|
templatePath: string
|
|
range?: {
|
|
start: number
|
|
end: number
|
|
}
|
|
}
|
|
|
|
export type ReverseMapFile = {
|
|
outputPath: string
|
|
templatePath: string
|
|
tokens: ReverseMapToken[]
|
|
}
|
|
|
|
export type ReverseMapManifest = {
|
|
version: 1
|
|
files: ReverseMapFile[]
|
|
tokens: Record<string, string[]>
|
|
}
|
|
|
|
export type RenderOptions = {
|
|
reverseMap?: boolean | string
|
|
}
|
|
|
|
type RenderState = {
|
|
sourceRoot: string
|
|
destRoot: string
|
|
manifest?: ReverseMapManifest
|
|
}
|
|
|
|
function evalCondition(expr: string | undefined, context: Record<string, unknown>): boolean {
|
|
if (!expr) throw new Error("Missing condition expression")
|
|
const eqMatch = expr.match(EQ_RE)
|
|
if (eqMatch) {
|
|
return resolveContext(context, eqMatch[1]!) === eqMatch[2]
|
|
}
|
|
const pathMatch = expr.match(PATH_RE)
|
|
if (pathMatch) {
|
|
return !!resolveContext(context, pathMatch[1]!)
|
|
}
|
|
throw new Error(`Invalid condition expression: ${expr}`)
|
|
}
|
|
|
|
function resolveContext(context: Record<string, unknown>, path: string): unknown {
|
|
const segments = path.split(".")
|
|
let current: unknown = context
|
|
for (const seg of segments) {
|
|
if (current === null || current === undefined || typeof current !== "object") return undefined
|
|
current = (current as Record<string, unknown>)[seg]
|
|
}
|
|
return current
|
|
}
|
|
|
|
function isInsidePath(parent: string, child: string): boolean {
|
|
const rel = relative(parent, child)
|
|
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))
|
|
}
|
|
|
|
function assertSafeRenderTarget(srcDir: string, destDir: string) {
|
|
const sourceRoot = resolvePath(srcDir)
|
|
const destRoot = resolvePath(destDir)
|
|
|
|
if (destRoot === resolvePath(destRoot, "..")) {
|
|
throw new Error("Refusing to render into filesystem root")
|
|
}
|
|
|
|
if (destRoot === resolvePath(process.cwd())) {
|
|
throw new Error("Refusing to render into the current working directory")
|
|
}
|
|
|
|
if (destRoot === resolvePath(homedir())) {
|
|
throw new Error("Refusing to render into the home directory")
|
|
}
|
|
|
|
if (isInsidePath(sourceRoot, destRoot) || isInsidePath(destRoot, sourceRoot)) {
|
|
throw new Error("Refusing to render when source and target directories overlap")
|
|
}
|
|
}
|
|
|
|
function resolveOutputPath(destRoot: string, outputName: string): string {
|
|
const outputPath = resolvePath(destRoot, outputName)
|
|
if (!isInsidePath(destRoot, outputPath)) {
|
|
throw new Error(`Refusing to write outside target directory: ${outputName}`)
|
|
}
|
|
return outputPath
|
|
}
|
|
|
|
function createReverseMapManifest(): ReverseMapManifest {
|
|
return { version: 1, files: [], tokens: {} }
|
|
}
|
|
|
|
function addReverseMapToken(
|
|
state: RenderState | undefined,
|
|
file: ReverseMapFile | undefined,
|
|
token: ReverseMapToken,
|
|
) {
|
|
if (!state?.manifest || !file) return
|
|
file.tokens.push(token)
|
|
const tokens = state.manifest.tokens[token.result] ?? []
|
|
if (!tokens.includes(token.token)) tokens.push(token.token)
|
|
state.manifest.tokens[token.result] = tokens
|
|
}
|
|
|
|
function getReverseMapPath(destRoot: string, reverseMap: true | string): string {
|
|
if (reverseMap === true) return resolveOutputPath(destRoot, ".tdir-map.json")
|
|
return resolveOutputPath(destRoot, reverseMap)
|
|
}
|
|
|
|
function getOutputName(
|
|
entry: string,
|
|
context: Record<string, unknown>,
|
|
state?: RenderState,
|
|
file?: ReverseMapFile,
|
|
): string | null {
|
|
const ifMatch = entry.match(IF_PATH_RE)
|
|
let outputName = entry
|
|
if (ifMatch) {
|
|
if (!evalCondition(ifMatch[1], context)) return null
|
|
outputName = ifMatch[2]!
|
|
if (ifMatch[0] !== outputName) {
|
|
addReverseMapToken(state, file, {
|
|
kind: "path",
|
|
result: outputName,
|
|
token: ifMatch[0],
|
|
outputPath: file?.outputPath ?? "",
|
|
templatePath: file?.templatePath ?? "",
|
|
})
|
|
}
|
|
}
|
|
|
|
return outputName.replace(VAR_RE, (_match, path: string) => {
|
|
const result = String(resolveContext(context, path) ?? "")
|
|
addReverseMapToken(state, file, {
|
|
kind: "path",
|
|
result,
|
|
token: _match,
|
|
contextPath: path,
|
|
outputPath: file?.outputPath ?? "",
|
|
templatePath: file?.templatePath ?? "",
|
|
})
|
|
return result
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// Each stack frame tracks: did any branch match yet, is the current branch active
|
|
type IfFrame = { matched: boolean; active: boolean; sawElse: boolean }
|
|
|
|
function processIfBlocks(content: string, context: Record<string, unknown>): string {
|
|
let result = ""
|
|
let pos = 0
|
|
const stack: IfFrame[] = []
|
|
|
|
const re = new RegExp(DIRECTIVE_RE.source, "g")
|
|
let match: RegExpExecArray | null
|
|
|
|
function isEmitting(): boolean {
|
|
return stack.every(f => f.active)
|
|
}
|
|
|
|
while ((match = re.exec(content)) !== null) {
|
|
const directive = match[1]!
|
|
const condPath = match[2]
|
|
|
|
if (directive === "if") {
|
|
if (isEmitting()) result += content.slice(pos, match.index)
|
|
const truthy = evalCondition(condPath, context)
|
|
stack.push({ matched: truthy, active: truthy, sawElse: false })
|
|
pos = re.lastIndex
|
|
} else if (directive === "elseif") {
|
|
if (stack.length === 0) throw new Error("Unexpected <@elseif> without <@if>")
|
|
const top = stack[stack.length - 1]!
|
|
if (top.sawElse) throw new Error("Unexpected <@elseif> after <@else>")
|
|
// Emit text before this directive if current branch was active
|
|
if (isEmitting()) result += content.slice(pos, match.index)
|
|
if (top.matched) {
|
|
// A previous branch already matched — skip this one
|
|
top.active = false
|
|
} else {
|
|
const truthy = evalCondition(condPath, context)
|
|
top.matched = truthy
|
|
top.active = truthy
|
|
}
|
|
pos = re.lastIndex
|
|
} else if (directive === "else") {
|
|
if (stack.length === 0) throw new Error("Unexpected <@else> without <@if>")
|
|
const top = stack[stack.length - 1]!
|
|
if (top.sawElse) throw new Error("Unexpected duplicate <@else>")
|
|
if (isEmitting()) result += content.slice(pos, match.index)
|
|
top.active = !top.matched
|
|
top.matched = true
|
|
top.sawElse = true
|
|
pos = re.lastIndex
|
|
} else if (directive === "endif") {
|
|
if (stack.length === 0) throw new Error("Unexpected <@endif> without <@if>")
|
|
if (isEmitting()) result += content.slice(pos, match.index)
|
|
stack.pop()
|
|
pos = re.lastIndex
|
|
}
|
|
}
|
|
|
|
if (stack.length > 0) {
|
|
throw new Error("Unmatched <@if> without <@endif>")
|
|
}
|
|
|
|
result += content.slice(pos)
|
|
return result
|
|
}
|
|
|
|
function renderContent(content: string, context: Record<string, unknown>): string {
|
|
return renderContentWithMap(content, context)
|
|
}
|
|
|
|
function renderContentWithMap(
|
|
content: string,
|
|
context: Record<string, unknown>,
|
|
state?: RenderState,
|
|
file?: ReverseMapFile,
|
|
): string {
|
|
const processed = processIfBlocks(content, context)
|
|
let result = ""
|
|
let pos = 0
|
|
|
|
for (const match of processed.matchAll(VAR_RE)) {
|
|
const token = match[0]
|
|
const path = match[1]!
|
|
const rendered = String(resolveContext(context, path) ?? "")
|
|
result += processed.slice(pos, match.index)
|
|
const start = result.length
|
|
result += rendered
|
|
addReverseMapToken(state, file, {
|
|
kind: "content",
|
|
result: rendered,
|
|
token,
|
|
contextPath: path,
|
|
outputPath: file?.outputPath ?? "",
|
|
templatePath: file?.templatePath ?? "",
|
|
range: { start, end: start + rendered.length },
|
|
})
|
|
pos = match.index! + token.length
|
|
}
|
|
|
|
result += processed.slice(pos)
|
|
return result
|
|
}
|
|
|
|
function renderDir(
|
|
srcDir: string,
|
|
destDir: string,
|
|
context: Record<string, unknown>,
|
|
options: RenderOptions = {},
|
|
) {
|
|
const destRoot = resolvePath(destDir)
|
|
const sourceRoot = resolvePath(srcDir)
|
|
const state: RenderState = {
|
|
sourceRoot,
|
|
destRoot,
|
|
manifest: options.reverseMap ? createReverseMapManifest() : undefined,
|
|
}
|
|
const mapPath = options.reverseMap ? getReverseMapPath(destRoot, options.reverseMap) : undefined
|
|
assertSafeRenderTarget(srcDir, destDir)
|
|
validateOutputPaths(srcDir, destRoot, context)
|
|
rmSync(destDir, { recursive: true, force: true })
|
|
renderDirInner(srcDir, destRoot, context, state)
|
|
if (state.manifest && mapPath) {
|
|
mkdirSync(dirname(mapPath), { recursive: true })
|
|
writeFileSync(mapPath, `${JSON.stringify(state.manifest, null, 2)}\n`)
|
|
}
|
|
}
|
|
|
|
function validateOutputPaths(
|
|
srcDir: string,
|
|
destDir: string,
|
|
context: Record<string, unknown>,
|
|
) {
|
|
const entries = readdirSync(srcDir).sort()
|
|
|
|
for (const entry of entries) {
|
|
const srcPath = resolvePath(srcDir, entry)
|
|
const stat = statSync(srcPath)
|
|
const outputName = getOutputName(entry, context)
|
|
if (outputName === null) continue
|
|
|
|
const destPath = resolveOutputPath(destDir, outputName)
|
|
if (stat.isDirectory()) {
|
|
validateOutputPaths(srcPath, destPath, context)
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderDirInner(
|
|
srcDir: string,
|
|
destDir: string,
|
|
context: Record<string, unknown>,
|
|
state: RenderState,
|
|
) {
|
|
mkdirSync(destDir, { recursive: true })
|
|
const entries = readdirSync(srcDir).sort()
|
|
|
|
for (const entry of entries) {
|
|
const srcPath = resolvePath(srcDir, entry)
|
|
const stat = statSync(srcPath)
|
|
const templatePath = relative(state.sourceRoot, srcPath)
|
|
const tempFile: ReverseMapFile = {
|
|
outputPath: "",
|
|
templatePath,
|
|
tokens: [],
|
|
}
|
|
const outputName = getOutputName(entry, context, state, tempFile)
|
|
if (outputName === null) continue
|
|
|
|
const destPath = resolveOutputPath(destDir, outputName)
|
|
const outputPath = relative(state.destRoot, destPath)
|
|
tempFile.outputPath = outputPath
|
|
for (const token of tempFile.tokens) {
|
|
token.outputPath = outputPath
|
|
token.templatePath = templatePath
|
|
}
|
|
|
|
if (stat.isDirectory()) {
|
|
if (tempFile.tokens.length > 0) state.manifest?.files.push(tempFile)
|
|
renderDirInner(srcPath, destPath, context, state)
|
|
} else {
|
|
mkdirSync(dirname(destPath), { recursive: true })
|
|
const content = readFileSync(srcPath)
|
|
if (isUtf8Text(content)) {
|
|
const rendered = renderContentWithMap(content.toString("utf-8"), context, state, tempFile)
|
|
writeFileSync(destPath, rendered)
|
|
} else {
|
|
copyFileSync(srcPath, destPath)
|
|
}
|
|
if (tempFile.tokens.length > 0) state.manifest?.files.push(tempFile)
|
|
}
|
|
}
|
|
}
|
|
|
|
export { renderDir, renderContent }
|