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

View File

@@ -125,6 +125,51 @@ render("./output", { web: "not a boolean", header: { show: true, title: "Hi" } }
For safety, tdir refuses to render into the filesystem root, the current working directory, the home directory, or any directory that overlaps the template source. Dynamic file and directory names are also resolved against the output directory and cannot write outside it.
## Reverse maps
Pass `{ reverseMap: true }` as the third render argument to write `.tdir-map.json` into the output directory:
```ts
render("./output", {
web: true,
header: { show: true, title: "Hello" }
}, { reverseMap: true })
```
Pass a string to choose a custom JSON path inside the output directory:
```ts
render("./output", context, { reverseMap: "meta/reverse-map.json" })
```
The map contains a flat lookup from rendered strings to template tokens plus per-file occurrences with path/range context:
```json
{
"version": 1,
"files": [
{
"outputPath": "web/index.html",
"templatePath": "<@if(context.web)>web/index.html",
"tokens": [
{
"kind": "content",
"result": "Hello",
"token": "<@var(context.header.title)>",
"contextPath": "header.title",
"outputPath": "web/index.html",
"templatePath": "<@if(context.web)>web/index.html",
"range": { "start": 16, "end": 21 }
}
]
}
],
"tokens": {
"Hello": ["<@var(context.header.title)>"]
}
}
```
## Unmatched directives
A `<@if>` without a matching `<@endif>` throws when the renderer is initialized:

View File

@@ -55,6 +55,64 @@ test("renders with web=true, header rendered", () => {
expect(content).toContain("<body>")
})
test("render can write a reverse map", () => {
const createRenderer = initRenderer("./testdata/if_example")
const render = createRenderer(ifExampleSchema)
render(tmp, { web: true, header: { render: true, text: "My Title" } }, { reverseMap: true })
const manifest = JSON.parse(readFileSync(join(tmp, ".tdir-map.json"), "utf-8"))
expect(manifest.version).toBe(1)
expect(manifest.tokens["My Title"]).toContain("<@var(context.header.text)>")
const file = manifest.files.find((entry: any) => entry.outputPath === join("web", "if_example.html"))
expect(file).toBeDefined()
expect(file.tokens).toContainEqual({
kind: "content",
result: "My Title",
token: "<@var(context.header.text)>",
contextPath: "header.text",
outputPath: join("web", "if_example.html"),
templatePath: join("<@if(context.web)>web", "if_example.html"),
range: expect.any(Object),
})
})
test("reverse map records path variable tokens", () => {
const createRenderer = initRenderer("./testdata/var_in_path")
const render = createRenderer(z.object({
web: z.object({
create: z.boolean(),
dir: z.string()
}),
header: z.object({
render: z.boolean(),
text: z.string()
})
}))
render(tmp,{
web: {
create: true,
dir: "web"
},
header: {
render: false,
text: "test"
}
}, { reverseMap: "meta/reverse-map.json" })
const manifest = JSON.parse(readFileSync(join(tmp, "meta", "reverse-map.json"), "utf-8"))
expect(manifest.tokens["web"]).toContain("<@var(context.web.dir)>")
const pathFile = manifest.files.find((entry: any) => entry.outputPath === "web")
expect(pathFile.tokens).toContainEqual({
kind: "path",
result: "web",
token: "<@var(context.web.dir)>",
contextPath: "web.dir",
outputPath: "web",
templatePath: "<@if(context.web.create)><@var(context.web.dir)>",
})
})
test("wrong schema throws error",() => {
const createRenderer = initRenderer("./testdata/if_example")
expect(() => createRenderer(z.object({

View File

@@ -1,6 +1,8 @@
import { z } from "zod"
import { parse, type TemplateVariable } from "./parser"
import { renderDir } from "./render"
import { renderDir, type RenderOptions } from "./render"
export type { RenderOptions, ReverseMapFile, ReverseMapManifest, ReverseMapToken } from "./render"
interface Stringable {
toString: () => string
@@ -85,9 +87,9 @@ export const initRenderer = (dirPath: string) => {
const createRenderer = <S extends z.ZodType>(userSchema: S) => {
validateSchemaMatchesTemplates(userSchema, variables)
return (targetPath: string, context: z.infer<S>) => {
return (targetPath: string, context: z.infer<S>, options?: RenderOptions) => {
userSchema.parse(context)
renderDir(dirPath, targetPath, context as Record<string, unknown>)
renderDir(dirPath, targetPath, context as Record<string, unknown>, options)
}
}

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)
}
}
}