Files
tdir/render.ts
Gregor Lohaus 0fd19254e9 eq directive
2026-04-15 22:37:13 +02:00

146 lines
4.6 KiB
TypeScript

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<string, unknown>): 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<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
}
// 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, 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 })
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, unknown>): 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<string, unknown>,
) {
rmSync(destDir, { recursive: true, force: true })
renderDirInner(srcDir, destDir, context)
}
function renderDirInner(
srcDir: string,
destDir: string,
context: Record<string, unknown>,
) {
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 }