220 lines
6.1 KiB
TypeScript
220 lines
6.1 KiB
TypeScript
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<string>()
|
|
return vars.filter(v => {
|
|
const key = `${v.path}:${v.type}`
|
|
if (seen.has(key)) return false
|
|
seen.add(key)
|
|
return true
|
|
})
|
|
}
|