568 lines
16 KiB
TypeScript
568 lines
16 KiB
TypeScript
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"
|
|
import { getDirectiveTokens, splitArgs, type DirectiveToken } from "./scanner"
|
|
|
|
const IF_PATH_RE = /^<@if\((.+?)\)>(.*)$/
|
|
const VAR_RE = /<@var\(context\.(.+?)(?::(\w+))?\)>/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<string, string[]>
|
|
}
|
|
|
|
export type RenderOptions = {
|
|
reverseMap?: boolean | string
|
|
}
|
|
|
|
type RenderState = {
|
|
sourceRoot: string
|
|
destRoot: string
|
|
manifest?: ReverseMapManifest
|
|
}
|
|
|
|
function evalCondition(expr: string | undefined, context: Record<string, unknown>): boolean {
|
|
if (!expr) throw new Error("Missing condition expression")
|
|
for (const operator of ["and", "or"] as const) {
|
|
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}`)
|
|
return operator === "and"
|
|
? args.every(arg => evalCondition(arg, context))
|
|
: args.some(arg => evalCondition(arg, context))
|
|
}
|
|
}
|
|
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<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
|
|
}
|
|
|
|
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<string, unknown>,
|
|
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 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 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<string, unknown>): 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<string, unknown>,
|
|
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<string, unknown>) {
|
|
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, unknown>): string {
|
|
return renderContentWithMap(content, context)
|
|
}
|
|
|
|
function renderContentWithMap(
|
|
content: string,
|
|
context: Record<string, unknown>,
|
|
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<string, unknown>,
|
|
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<string, unknown>,
|
|
) {
|
|
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<string, unknown>,
|
|
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 }
|