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 } export type RenderOptions = { reverseMap?: boolean | string } type RenderState = { sourceRoot: string destRoot: string manifest?: ReverseMapManifest } function evalCondition(expr: string | undefined, context: Record): 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, 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)[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, 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 { 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 { return renderContentWithMap(content, context) } function renderContentWithMap( content: string, context: Record, 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, 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, ) { 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, 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 }