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 STRING_COMPARE_RE = /^(eq|neq)\(context\.(.+?),\s*"(.*)"\)$/ const PATH_RE = /^context\.(.+)$/ export type ReverseMapToken = { kind: "path" | "content" | "conditional" result: string token: string contextPath?: string outputPath: string templatePath: string range?: { start: number end: number } activeRange?: { start: number end: number } before?: string after?: string } export type ReverseMapFile = { outputPath: string templatePath: string tokens: ReverseMapToken[] } export type ReverseMapStoredTemplate = { kind: "directory" | "file" templatePath: string encoding?: "utf8" | "base64" content?: string } export type ReverseMapManifest = { version: 1 files: ReverseMapFile[] skipped: ReverseMapStoredTemplate[] 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 stringCompareMatch = expr.match(STRING_COMPARE_RE) if (stringCompareMatch) { const result = resolveContext(context, stringCompareMatch[2]!) === stringCompareMatch[3] return stringCompareMatch[1] === "eq" ? result : !result } 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: [], skipped: [], 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 addFlatToken( state: RenderState | undefined, result: string, token: string, ) { if (!state?.manifest) return const tokens = state.manifest.tokens[result] ?? [] if (!tokens.includes(token)) tokens.push(token) state.manifest.tokens[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 } } type DirectiveToken = { type: "if" | "elseif" | "else" | "endif" condition?: string index: number end: number } type TextNode = { type: "text" text: string } type IfBranch = { type: "if" | "elseif" | "else" condition?: string nodes: TemplateNode[] contentStart: number contentEnd: number } type IfNode = { type: "if" sourceStart: number sourceEnd: number source: string branches: IfBranch[] } type TemplateNode = TextNode | IfNode type ConditionalRender = { result: string token: string range: { start: number end: number } activeRange: { start: number end: number } } function getDirectiveTokens(content: string): DirectiveToken[] { return Array.from(content.matchAll(DIRECTIVE_RE), match => ({ type: match[1] as DirectiveToken["type"], condition: match[2], index: match.index!, end: match.index! + match[0].length, })) } function parseNodes( content: string, tokens: DirectiveToken[], tokenIndex: number, pos: number, stopTypes: DirectiveToken["type"][], ): { nodes: TemplateNode[] pos: number tokenIndex: number stop?: DirectiveToken } { const nodes: TemplateNode[] = [] while (tokenIndex < tokens.length) { const token = tokens[tokenIndex]! if (stopTypes.includes(token.type)) { if (token.index > pos) nodes.push({ type: "text", text: content.slice(pos, token.index) }) return { nodes, pos: token.index, tokenIndex, stop: token } } if (token.type !== "if") { throw new Error(`Unexpected <@${token.type}> without <@if>`) } if (token.index > pos) nodes.push({ type: "text", text: content.slice(pos, token.index) }) const parsed = parseIfNode(content, tokens, tokenIndex) nodes.push(parsed.node) tokenIndex = parsed.tokenIndex pos = parsed.pos } if (pos < content.length) nodes.push({ type: "text", text: content.slice(pos) }) return { nodes, pos: content.length, tokenIndex } } function parseIfNode(content: string, tokens: DirectiveToken[], tokenIndex: number) { const firstToken = tokens[tokenIndex]! const sourceStart = firstToken.index const branches: IfBranch[] = [] let branchType: IfBranch["type"] = "if" let branchCondition = firstToken.condition let branchContentStart = firstToken.end tokenIndex += 1 while (true) { const parsed = parseNodes(content, tokens, tokenIndex, branchContentStart, ["elseif", "else", "endif"]) if (!parsed.stop) throw new Error("Unmatched <@if> without <@endif>") branches.push({ type: branchType, condition: branchCondition, nodes: parsed.nodes, contentStart: branchContentStart, contentEnd: parsed.pos, }) if (parsed.stop.type === "endif") { const sourceEnd = parsed.stop.end return { node: { type: "if" as const, sourceStart, sourceEnd, source: content.slice(sourceStart, sourceEnd), branches, }, pos: sourceEnd, tokenIndex: parsed.tokenIndex + 1, } } branchType = parsed.stop.type branchCondition = parsed.stop.condition branchContentStart = parsed.stop.end tokenIndex = parsed.tokenIndex + 1 } } function getActiveBranch(node: IfNode, context: Record): IfBranch | undefined { for (const branch of node.branches) { if (branch.type === "else" || evalCondition(branch.condition, context)) return branch } return undefined } function renderNodes( nodes: TemplateNode[], context: Record, conditionalTokens: ConditionalRender[], outputStart = 0, ): string { let result = "" for (const node of nodes) { if (node.type === "text") { result += node.text continue } const activeBranch = getActiveBranch(node, context) const start = outputStart + result.length const renderedBranch = activeBranch ? renderNodes(activeBranch.nodes, context, conditionalTokens, start) : "" result += renderedBranch conditionalTokens.push({ result: renderedBranch, token: node.source, range: { start, end: start + renderedBranch.length }, activeRange: activeBranch ? { start: activeBranch.contentStart - node.sourceStart, end: activeBranch.contentEnd - node.sourceStart, } : { start: node.source.length, end: node.source.length }, }) } return result } function processIfBlocksWithMap(content: string, context: Record) { const tokens = getDirectiveTokens(content) const parsed = parseNodes(content, tokens, 0, 0, []) const conditionalTokens: ConditionalRender[] = [] return { content: renderNodes(parsed.nodes, context, conditionalTokens), conditionalTokens, } } function renderContent(content: string, context: Record): string { return renderContentWithMap(content, context) } function renderContentWithMap( content: string, context: Record, state?: RenderState, file?: ReverseMapFile, ): string { for (const match of content.matchAll(VAR_RE)) { const token = match[0] const path = match[1]! addFlatToken(state, String(resolveContext(context, path) ?? ""), token) } const processedResult = processIfBlocksWithMap(content, context) const processed = processedResult.content for (const token of processedResult.conditionalTokens) { addReverseMapToken(state, file, { kind: "conditional", result: token.result, token: token.token, outputPath: file?.outputPath ?? "", templatePath: file?.templatePath ?? "", range: token.range, activeRange: token.activeRange, before: processed.slice(0, token.range.start), after: processed.slice(token.range.end), }) } 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 storeSkippedTemplate(srcPath: string, state: RenderState) { if (!state.manifest) return const stat = statSync(srcPath) const templatePath = relative(state.sourceRoot, srcPath) if (stat.isDirectory()) { state.manifest.skipped.push({ kind: "directory", templatePath }) for (const entry of readdirSync(srcPath).sort()) { storeSkippedTemplate(resolvePath(srcPath, entry), state) } return } if (!stat.isFile()) return const content = readFileSync(srcPath) if (isUtf8Text(content)) { state.manifest.skipped.push({ kind: "file", templatePath, encoding: "utf8", content: content.toString("utf-8"), }) } else { state.manifest.skipped.push({ kind: "file", templatePath, encoding: "base64", content: content.toString("base64"), }) } } 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) { storeSkippedTemplate(srcPath, state) 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 (state.manifest) state.manifest.files.push(tempFile) } } } export { renderDir, renderContent }