import { readdirSync, statSync, readFileSync, mkdirSync, writeFileSync, rmSync } from "node:fs" import { join } from "node:path" 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\.(.+)$/ function evalCondition(expr: string, context: Record): boolean { const eqMatch = expr.match(EQ_RE) if (eqMatch) { return resolve(context, eqMatch[1]!) === eqMatch[2] } const pathMatch = expr.match(PATH_RE) if (pathMatch) { return !!resolve(context, pathMatch[1]!) } throw new Error(`Invalid condition expression: ${expr}`) } function resolve(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 } // Each stack frame tracks: did any branch match yet, is the current branch active type IfFrame = { matched: boolean; active: 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 }) pos = re.lastIndex } else if (directive === "elseif") { if (stack.length === 0) throw new Error("Unexpected <@elseif> without <@if>") const top = stack[stack.length - 1]! // 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 (isEmitting()) result += content.slice(pos, match.index) top.active = !top.matched top.matched = 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 { const processed = processIfBlocks(content, context) return processed.replace(VAR_RE, (_match, path: string) => { return String(resolve(context, path) ?? "") }) } function renderDir( srcDir: string, destDir: string, context: Record, ) { rmSync(destDir, { recursive: true, force: true }) renderDirInner(srcDir, destDir, context) } function renderDirInner( srcDir: string, destDir: string, context: Record, ) { mkdirSync(destDir, { recursive: true }) const entries = readdirSync(srcDir).sort() for (const entry of entries) { const srcPath = join(srcDir, entry) const stat = statSync(srcPath) // Check if path segment has an @if directive const ifMatch = entry.match(IF_PATH_RE) let outputName = entry if (ifMatch) { if (!evalCondition(ifMatch[1]!, context)) continue outputName = ifMatch[2]! } // Process @var in the output name outputName = outputName.replace(VAR_RE, (_match, path: string) => { return String(resolve(context, path) ?? "") }) const destPath = join(destDir, outputName) if (stat.isDirectory()) { renderDirInner(srcPath, destPath, context) } else { mkdirSync(destDir, { recursive: true }) const content = readFileSync(srcPath, "utf-8") writeFileSync(destPath, renderContent(content, context)) } } } export { renderDir, renderContent }