diff --git a/README.md b/README.md index 9c2d04e..c82ad32 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ Pass a string to choose a custom JSON path inside the output directory: 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: +The map contains a flat lookup from rendered strings to template tokens, per-file occurrences with path/range context, inline conditional blocks, and template files skipped by path conditionals: ```json { @@ -160,10 +160,27 @@ The map contains a flat lookup from rendered strings to template tokens plus per "outputPath": "web/index.html", "templatePath": "<@if(context.web)>web/index.html", "range": { "start": 16, "end": 21 } + }, + { + "kind": "conditional", + "result": "<@var(context.header.title)>", + "token": "<@if(context.header.show)><@var(context.header.title)><@endif>", + "outputPath": "web/index.html", + "templatePath": "<@if(context.web)>web/index.html", + "range": { "start": 9, "end": 53 }, + "activeRange": { "start": 27, "end": 71 } } ] } ], + "skipped": [ + { + "kind": "file", + "templatePath": "<@if(context.docs)>docs/readme.md", + "encoding": "utf8", + "content": "# Docs" + } + ], "tokens": { "Hello": ["<@var(context.header.title)>"] } @@ -191,7 +208,7 @@ tdir reverse ./output ./templates --map meta/reverse-map.json bunx @gregorlohaus/tdir reverse ./output ./templates --map meta/reverse-map.json ``` -The command writes files at their original template paths and restores recorded `<@var(...)>` tokens in file contents and file paths. It does not infer conditional blocks that were removed during rendering; keep the original template structure when those blocks need to be preserved. +The command writes files at their original template paths, restores recorded `<@var(...)>` tokens, wraps edited inline conditional output back in the original conditional block, and restores template files that were skipped by path conditionals. ## Unmatched directives diff --git a/index.test.ts b/index.test.ts index bf319ac..5123f47 100644 --- a/index.test.ts +++ b/index.test.ts @@ -66,6 +66,17 @@ test("render can write a reverse map", () => { const file = manifest.files.find((entry: any) => entry.outputPath === join("web", "if_example.html")) expect(file).toBeDefined() + expect(file.tokens).toContainEqual({ + kind: "conditional", + result: expect.stringContaining("<@var(context.header.text)>"), + token: expect.stringContaining("<@if(context.header.render)>"), + outputPath: join("web", "if_example.html"), + templatePath: join("<@if(context.web)>web", "if_example.html"), + range: expect.any(Object), + activeRange: expect.any(Object), + before: expect.any(String), + after: expect.any(String), + }) expect(file.tokens).toContainEqual({ kind: "content", result: "My Title", @@ -134,10 +145,37 @@ test("reverseDir rebuilds templates from rendered output and map", () => { expect(result.warnings).toEqual([]) const reversed = readFileSync(join(templateOut, "<@if(context.web)>web", "if_example.html"), "utf-8") + expect(reversed).toContain("<@if(context.header.render)>") + expect(reversed).toContain("<@endif>") expect(reversed).toContain("<@var(context.header.text)>") expect(reversed).toContain("Edited rendered output") }) +test("reverseDir restores files skipped by path conditionals", () => { + const createRenderer = initRenderer("./testdata/file_if") + const render = createRenderer(z.object({ + web: z.boolean(), + file: z.boolean(), + text: z.string() + })) + const renderedOut = join(tmp, "rendered") + const templateOut = join(tmp, "template") + + render(renderedOut,{ + web: true, + file: false, + text: "test" + }, { reverseMap: true }) + + expect(existsSync(join(renderedOut, "web", "example.txt"))).toBe(false) + + const result = reverseDir(renderedOut, templateOut) + expect(result.filesWritten).toBe(1) + const restoredPath = join(templateOut, "<@if(context.web)>web", "<@if(context.file)>example.txt") + expect(existsSync(restoredPath)).toBe(true) + expect(readFileSync(restoredPath, "utf-8")).toContain("<@var(context.text)>") +}) + test("reverseDir supports custom map paths", () => { const createRenderer = initRenderer("./testdata/var_in_path") const render = createRenderer(z.object({ diff --git a/index.ts b/index.ts index 033c854..cce5d76 100644 --- a/index.ts +++ b/index.ts @@ -2,7 +2,7 @@ import { z } from "zod" import { parse, type TemplateVariable } from "./parser" import { renderDir, type RenderOptions } from "./render" -export type { RenderOptions, ReverseMapFile, ReverseMapManifest, ReverseMapToken } from "./render" +export type { RenderOptions, ReverseMapFile, ReverseMapManifest, ReverseMapStoredTemplate, ReverseMapToken } from "./render" export { reverseDir, type ReverseOptions, type ReverseResult, type ReverseWarning } from "./reverse" interface Stringable { diff --git a/render.ts b/render.ts index 05e9b20..280f8f8 100644 --- a/render.ts +++ b/render.ts @@ -18,7 +18,7 @@ const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/ const PATH_RE = /^context\.(.+)$/ export type ReverseMapToken = { - kind: "path" | "content" + kind: "path" | "content" | "conditional" result: string token: string contextPath?: string @@ -28,6 +28,12 @@ export type ReverseMapToken = { start: number end: number } + activeRange?: { + start: number + end: number + } + before?: string + after?: string } export type ReverseMapFile = { @@ -36,9 +42,17 @@ export type ReverseMapFile = { 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 } @@ -110,7 +124,7 @@ function resolveOutputPath(destRoot: string, outputName: string): string { } function createReverseMapManifest(): ReverseMapManifest { - return { version: 1, files: [], tokens: {} } + return { version: 1, files: [], skipped: [], tokens: {} } } function addReverseMapToken( @@ -176,70 +190,190 @@ function isUtf8Text(buffer: Buffer): boolean { } } -// Each stack frame tracks: did any branch match yet, is the current branch active -type IfFrame = { matched: boolean; active: boolean; sawElse: boolean } +type DirectiveToken = { + type: "if" | "elseif" | "else" | "endif" + condition?: string + index: number + end: number +} -function processIfBlocks(content: string, context: Record): string { - let result = "" - let pos = 0 - const stack: IfFrame[] = [] +type TextNode = { + type: "text" + text: string +} - const re = new RegExp(DIRECTIVE_RE.source, "g") - let match: RegExpExecArray | null +type IfBranch = { + type: "if" | "elseif" | "else" + condition?: string + nodes: TemplateNode[] + contentStart: number + contentEnd: number +} - function isEmitting(): boolean { - return stack.every(f => f.active) +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 + } +} - while ((match = re.exec(content)) !== null) { - const directive = match[1]! - const condPath = match[2] +function getDirectiveTokens(content: string): DirectiveToken[] { + return Array.from(content.matchAll(DIRECTIVE_RE), match => ({ + type: match[1] as DirectiveToken["type"], + condition: match[2], + index: match.index!, + end: match.index! + match[0].length, + })) +} - if (directive === "if") { - if (isEmitting()) result += content.slice(pos, match.index) - const truthy = evalCondition(condPath, context) - stack.push({ matched: truthy, active: truthy, sawElse: false }) - pos = re.lastIndex - } else if (directive === "elseif") { - if (stack.length === 0) throw new Error("Unexpected <@elseif> without <@if>") - const top = stack[stack.length - 1]! - if (top.sawElse) throw new Error("Unexpected <@elseif> after <@else>") - // Emit text before this directive if current branch was active - if (isEmitting()) result += content.slice(pos, match.index) - if (top.matched) { - // A previous branch already matched — skip this one - top.active = false - } else { - const truthy = evalCondition(condPath, context) - top.matched = truthy - top.active = truthy - } - pos = re.lastIndex - } else if (directive === "else") { - if (stack.length === 0) throw new Error("Unexpected <@else> without <@if>") - const top = stack[stack.length - 1]! - if (top.sawElse) throw new Error("Unexpected duplicate <@else>") - if (isEmitting()) result += content.slice(pos, match.index) - top.active = !top.matched - top.matched = true - top.sawElse = true - pos = re.lastIndex - } else if (directive === "endif") { - if (stack.length === 0) throw new Error("Unexpected <@endif> without <@if>") - if (isEmitting()) result += content.slice(pos, match.index) - stack.pop() - pos = re.lastIndex +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 (stack.length > 0) { - throw new Error("Unmatched <@if> without <@endif>") + 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): 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, + 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 }, + }) } - result += content.slice(pos) return result } +function processIfBlocksWithMap(content: string, context: Record) { + 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 { return renderContentWithMap(content, context) } @@ -250,7 +384,22 @@ function renderContentWithMap( state?: RenderState, file?: ReverseMapFile, ): string { - const processed = processIfBlocks(content, context) + 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 @@ -321,6 +470,39 @@ function validateOutputPaths( } } +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, @@ -340,7 +522,10 @@ function renderDirInner( tokens: [], } const outputName = getOutputName(entry, context, state, tempFile) - if (outputName === null) continue + if (outputName === null) { + storeSkippedTemplate(srcPath, state) + continue + } const destPath = resolveOutputPath(destDir, outputName) const outputPath = relative(state.destRoot, destPath) @@ -362,7 +547,7 @@ function renderDirInner( } else { copyFileSync(srcPath, destPath) } - if (tempFile.tokens.length > 0) state.manifest?.files.push(tempFile) + if (state.manifest) state.manifest.files.push(tempFile) } } } diff --git a/reverse.ts b/reverse.ts index ab26a2a..c0bb00e 100644 --- a/reverse.ts +++ b/reverse.ts @@ -8,7 +8,7 @@ import { } from "node:fs" import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path" import { TextDecoder } from "node:util" -import type { ReverseMapFile, ReverseMapManifest, ReverseMapToken } from "./render" +import type { ReverseMapFile, ReverseMapManifest, ReverseMapStoredTemplate, ReverseMapToken } from "./render" export type ReverseOptions = { mapPath?: string @@ -71,17 +71,73 @@ function replaceFirst(content: string, token: ReverseMapToken): string | null { return `${content.slice(0, index)}${token.token}${content.slice(index + token.result.length)}` } +function applyActiveBranch(token: ReverseMapToken, branchContent: string): string { + if (!token.activeRange) return token.token + return [ + token.token.slice(0, token.activeRange.start), + branchContent, + token.token.slice(token.activeRange.end), + ].join("") +} + +function findPrefixEnd(content: string, before: string): number { + if (before === "") return 0 + let candidate = before + while (candidate.length >= 8) { + const index = content.indexOf(candidate) + if (index !== -1) return index + candidate.length + candidate = candidate.slice(-Math.max(1, Math.floor(candidate.length / 2))) + } + return -1 +} + +function findSuffixStart(content: string, after: string, from: number): number { + if (after === "") return content.length + let candidate = after + while (candidate.length >= 8) { + const index = content.indexOf(candidate, from) + if (index !== -1) return index + candidate = candidate.slice(0, Math.floor(candidate.length / 2)) + } + return -1 +} + +function replaceConditional(content: string, token: ReverseMapToken): string | null { + const exactIndex = content.indexOf(token.result) + if (exactIndex !== -1) { + return [ + content.slice(0, exactIndex), + token.token, + content.slice(exactIndex + token.result.length), + ].join("") + } + + if (token.before === undefined || token.after === undefined) return null + + const branchStart = findPrefixEnd(content, token.before) + if (branchStart === -1) return null + + const afterIndex = findSuffixStart(content, token.after, branchStart) + if (afterIndex === -1) return null + + return [ + content.slice(0, branchStart), + applyActiveBranch(token, content.slice(branchStart, afterIndex)), + content.slice(afterIndex), + ].join("") +} + function reverseContent( content: string, file: ReverseMapFile, warnings: ReverseWarning[], ): string { - const tokens = file.tokens + const contentTokens = file.tokens .filter(token => token.kind === "content") .sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1)) let reversed = content - for (const token of tokens) { + for (const token of contentTokens) { const rangeResult = replaceAtRange(reversed, token) if (rangeResult !== null) { reversed = rangeResult @@ -101,9 +157,47 @@ function reverseContent( message: "Rendered value was not found; token was not restored", }) } + + const conditionalTokens = file.tokens + .filter(token => token.kind === "conditional") + .sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1)) + + for (const token of conditionalTokens) { + const result = replaceConditional(reversed, token) + if (result !== null) { + reversed = result + continue + } + + warnings.push({ + outputPath: file.outputPath, + token: token.token, + result: token.result, + message: "Rendered conditional block was not found; block was not restored", + }) + } + return reversed } +function writeSkippedTemplate( + templateRoot: string, + skipped: ReverseMapStoredTemplate, +): number { + const templatePath = resolveInside(templateRoot, skipped.templatePath) + if (skipped.kind === "directory") { + mkdirSync(templatePath, { recursive: true }) + return 0 + } + + mkdirSync(dirname(templatePath), { recursive: true }) + const content = skipped.encoding === "base64" + ? Buffer.from(skipped.content ?? "", "base64") + : skipped.content ?? "" + writeFileSync(templatePath, content) + return 1 +} + export function reverseDir( renderedDir: string, templateDir: string, @@ -154,5 +248,9 @@ export function reverseDir( filesWritten += 1 } + for (const skipped of manifest.skipped ?? []) { + filesWritten += writeSkippedTemplate(templateRoot, skipped) + } + return { filesWritten, warnings } }