import { readdirSync, statSync, readFileSync } from "node:fs" import { join } from "node:path" import { TextDecoder } from "node:util" export type TemplateVariable = { path: string type: string } const IF_RE = /<@(?:if|elseif)\((.+?)\)>/g const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/g const STRING_COMPARE_RE = /^(?:eq|neq)\(context\.(.+?),\s*"(.*)"\)$/ const PATH_RE = /^context\.(.+)$/ type DirectiveToken = { type: "if" | "elseif" | "else" | "endif" condition?: string } function readCondition(text: string, start: number): { condition: string; end: number } | null { let depth = 0 let inString = false let escaped = false for (let i = start; i < text.length; i++) { const char = text[i]! if (escaped) { escaped = false continue } if (char === "\\") { escaped = true continue } if (char === "\"") { inString = !inString continue } if (inString) continue if (char === "(") { depth += 1 continue } if (char === ")") { depth -= 1 if (depth === 0 && text[i + 1] === ">") { return { condition: text.slice(start + 1, i), end: i + 2 } } } } return null } function getDirectiveTokens(text: string): DirectiveToken[] { const tokens: DirectiveToken[] = [] for (let i = 0; i < text.length; i++) { if (text[i] !== "<" || text[i + 1] !== "@") continue const rest = text.slice(i + 2) const type = ["elseif", "endif", "else", "if"].find(name => rest.startsWith(name)) as DirectiveToken["type"] | undefined if (!type) continue const afterName = i + 2 + type.length if ((type === "if" || type === "elseif") && text[afterName] === "(") { const parsed = readCondition(text, afterName) if (!parsed) continue tokens.push({ type, condition: parsed.condition }) i = parsed.end - 1 } else if ((type === "else" || type === "endif") && text[afterName] === ">") { tokens.push({ type }) i = afterName } } return tokens } function splitArgs(args: string): string[] { const result: string[] = [] let current = "" let depth = 0 let inString = false let escaped = false for (const char of args) { if (escaped) { current += char escaped = false continue } if (char === "\\") { current += char escaped = true continue } if (char === "\"") { current += char inString = !inString continue } if (!inString && char === "(") depth += 1 if (!inString && char === ")") depth -= 1 if (!inString && depth === 0 && char === ",") { result.push(current.trim()) current = "" continue } current += char } if (current.trim() !== "") result.push(current.trim()) return result } function extractCondition(expr: string | undefined, vars: TemplateVariable[]) { if (!expr) throw new Error("Missing condition expression") for (const operator of ["and", "or"]) { const prefix = `${operator}(` if (expr.startsWith(prefix) && expr.endsWith(")")) { const args = splitArgs(expr.slice(prefix.length, -1)) if (args.length === 0) throw new Error(`Invalid condition expression: ${expr}`) for (const arg of args) extractCondition(arg, vars) return } } const stringCompareMatch = expr.match(STRING_COMPARE_RE) if (stringCompareMatch) { vars.push({ path: stringCompareMatch[1]!, type: "string" }) return } const pathMatch = expr.match(PATH_RE) if (pathMatch) { vars.push({ path: pathMatch[1]!, type: "boolean" }) return } throw new Error(`Invalid condition expression: ${expr}`) } function extractFromString(text: string, vars: TemplateVariable[]) { for (const token of getDirectiveTokens(text)) { if (token.type === "if" || token.type === "elseif") { extractCondition(token.condition, vars) } } for (const match of text.matchAll(VAR_RE)) { vars.push({ path: match[1]!, type: match[2] ?? "string" }) } } function validateIfBlocks(content: string, vars: TemplateVariable[]) { const stack: { sawElse: boolean }[] = [] for (const token of getDirectiveTokens(content)) { const directive = token.type const condition = token.condition if (directive === "if") { extractCondition(condition!, vars) stack.push({ sawElse: false }) } else if (directive === "elseif") { const frame = stack[stack.length - 1] if (!frame) throw new Error("Unexpected <@elseif> without <@if>") if (frame.sawElse) throw new Error("Unexpected <@elseif> after <@else>") extractCondition(condition!, vars) } else if (directive === "else") { const frame = stack[stack.length - 1] if (!frame) throw new Error("Unexpected <@else> without <@if>") if (frame.sawElse) throw new Error("Unexpected duplicate <@else>") frame.sawElse = true } else if (directive === "endif") { if (stack.length === 0) throw new Error("Unexpected <@endif> without <@if>") stack.pop() } } if (stack.length > 0) { throw new Error("Unmatched <@if> without <@endif>") } } 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 } } function walkDir(dirPath: string, vars: TemplateVariable[]) { const entries = readdirSync(dirPath).sort() for (const entry of entries) { const fullPath = join(dirPath, entry) extractFromString(entry, vars) const stat = statSync(fullPath) if (stat.isDirectory()) { walkDir(fullPath, vars) } else if (stat.isFile()) { const content = readFileSync(fullPath) if (isUtf8Text(content)) { const text = content.toString("utf-8") extractFromString(text, vars) validateIfBlocks(text, vars) } } } } export function parse(dirPath: string): TemplateVariable[] { const vars: TemplateVariable[] = [] walkDir(dirPath, vars) const seen = new Set() return vars.filter(v => { const key = `${v.path}:${v.type}` if (seen.has(key)) return false seen.add(key) return true }) }