map rendered strings to template token source

This commit is contained in:
Gregor Lohaus
2026-05-22 09:05:02 +02:00
parent 3110eefcbd
commit bda6e8cc40
4 changed files with 255 additions and 13 deletions

157
render.ts
View File

@@ -17,6 +17,41 @@ const DIRECTIVE_RE = /<@(if|elseif|else|endif)(?:\((.+?)\))?>/g
const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
const PATH_RE = /^context\.(.+)$/
export type ReverseMapToken = {
kind: "path" | "content"
result: string
token: string
contextPath?: string
outputPath: string
templatePath: string
range?: {
start: number
end: number
}
}
export type ReverseMapFile = {
outputPath: string
templatePath: string
tokens: ReverseMapToken[]
}
export type ReverseMapManifest = {
version: 1
files: ReverseMapFile[]
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")
const eqMatch = expr.match(EQ_RE)
@@ -74,16 +109,60 @@ function resolveOutputPath(destRoot: string, outputName: string): string {
return outputPath
}
function getOutputName(entry: string, context: Record<string, unknown>): string | null {
function createReverseMapManifest(): ReverseMapManifest {
return { version: 1, files: [], 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 getReverseMapPath(destRoot: string, reverseMap: boolean | 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) => {
return String(resolveContext(context, path) ?? "")
const result = String(resolveContext(context, path) ?? "")
addReverseMapToken(state, file, {
kind: "path",
result,
token: _match,
contextPath: path,
outputPath: file?.outputPath ?? "",
templatePath: file?.templatePath ?? "",
})
return result
})
}
@@ -162,22 +241,64 @@ function processIfBlocks(content: string, context: Record<string, unknown>): str
}
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 {
const processed = processIfBlocks(content, context)
return processed.replace(VAR_RE, (_match, path: string) => {
return String(resolveContext(context, path) ?? "")
})
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)
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(
@@ -204,6 +325,7 @@ function renderDirInner(
srcDir: string,
destDir: string,
context: Record<string, unknown>,
state: RenderState,
) {
mkdirSync(destDir, { recursive: true })
const entries = readdirSync(srcDir).sort()
@@ -211,21 +333,36 @@ function renderDirInner(
for (const entry of entries) {
const srcPath = resolvePath(srcDir, entry)
const stat = statSync(srcPath)
const outputName = getOutputName(entry, context)
const templatePath = relative(state.sourceRoot, srcPath)
const tempFile: ReverseMapFile = {
outputPath: "",
templatePath,
tokens: [],
}
const outputName = getOutputName(entry, context, state, tempFile)
if (outputName === null) 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()) {
renderDirInner(srcPath, destPath, context)
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)) {
writeFileSync(destPath, renderContent(content.toString("utf-8"), context))
const rendered = renderContentWithMap(content.toString("utf-8"), context, state, tempFile)
writeFileSync(destPath, rendered)
} else {
copyFileSync(srcPath, destPath)
}
if (tempFile.tokens.length > 0) state.manifest?.files.push(tempFile)
}
}
}