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