From bda6e8cc40bd0c32bac7dea63f08e52626d4b03b Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Fri, 22 May 2026 09:05:02 +0200 Subject: [PATCH] map rendered strings to template token source --- README.md | 45 +++++++++++++++ index.test.ts | 58 +++++++++++++++++++ index.ts | 8 ++- render.ts | 157 ++++++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 255 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 3791a3c..a87d91d 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/index.test.ts b/index.test.ts index 749df38..34ae9b1 100644 --- a/index.test.ts +++ b/index.test.ts @@ -55,6 +55,64 @@ test("renders with web=true, header rendered", () => { expect(content).toContain("") }) +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({ diff --git a/index.ts b/index.ts index f43dd69..fe11462 100644 --- a/index.ts +++ b/index.ts @@ -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 = (userSchema: S) => { validateSchemaMatchesTemplates(userSchema, variables) - return (targetPath: string, context: z.infer) => { + return (targetPath: string, context: z.infer, options?: RenderOptions) => { userSchema.parse(context) - renderDir(dirPath, targetPath, context as Record) + renderDir(dirPath, targetPath, context as Record, options) } } diff --git a/render.ts b/render.ts index 44532ae..653c1a8 100644 --- a/render.ts +++ b/render.ts @@ -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 +} + +export type RenderOptions = { + reverseMap?: boolean | string +} + +type RenderState = { + sourceRoot: string + destRoot: string + manifest?: ReverseMapManifest +} + function evalCondition(expr: string | undefined, context: Record): 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 | 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, + 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): str } function renderContent(content: string, context: Record): string { + return renderContentWithMap(content, context) +} + +function renderContentWithMap( + content: string, + context: Record, + 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, + 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, + 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) } } }