4 Commits

Author SHA1 Message Date
Gregor Lohaus
2971c87618 version bump
All checks were successful
Publish npm package / publish (push) Successful in 23s
2026-05-24 12:21:57 +02:00
Gregor Lohaus
af0c25e64b full template restoreablity
Some checks failed
Publish npm package / publish (push) Failing after 21s
2026-05-24 12:17:12 +02:00
Gregor Lohaus
8ad2545310 fix build error
All checks were successful
Publish npm package / publish (push) Successful in 24s
2026-05-22 10:05:34 +02:00
Gregor Lohaus
4d25d07687 run on pc
Some checks failed
Publish npm package / publish (push) Failing after 3m9s
2026-05-22 09:59:36 +02:00
7 changed files with 402 additions and 64 deletions

View File

@@ -7,7 +7,7 @@ on:
jobs: jobs:
publish: publish:
runs-on: local runs-on: x86
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View File

@@ -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" }) 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 ```json
{ {
@@ -160,10 +160,27 @@ The map contains a flat lookup from rendered strings to template tokens plus per
"outputPath": "web/index.html", "outputPath": "web/index.html",
"templatePath": "<@if(context.web)>web/index.html", "templatePath": "<@if(context.web)>web/index.html",
"range": { "start": 16, "end": 21 } "range": { "start": 16, "end": 21 }
},
{
"kind": "conditional",
"result": "<head><@var(context.header.title)></head>",
"token": "<@if(context.header.show)><head><@var(context.header.title)></head><@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": { "tokens": {
"Hello": ["<@var(context.header.title)>"] "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 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 ## Unmatched directives

View File

@@ -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")) const file = manifest.files.find((entry: any) => entry.outputPath === join("web", "if_example.html"))
expect(file).toBeDefined() 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({ expect(file.tokens).toContainEqual({
kind: "content", kind: "content",
result: "My Title", result: "My Title",
@@ -134,10 +145,37 @@ test("reverseDir rebuilds templates from rendered output and map", () => {
expect(result.warnings).toEqual([]) expect(result.warnings).toEqual([])
const reversed = readFileSync(join(templateOut, "<@if(context.web)>web", "if_example.html"), "utf-8") 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("<@var(context.header.text)>")
expect(reversed).toContain("Edited rendered output") 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", () => { test("reverseDir supports custom map paths", () => {
const createRenderer = initRenderer("./testdata/var_in_path") const createRenderer = initRenderer("./testdata/var_in_path")
const render = createRenderer(z.object({ const render = createRenderer(z.object({

View File

@@ -2,7 +2,7 @@ import { z } from "zod"
import { parse, type TemplateVariable } from "./parser" import { parse, type TemplateVariable } from "./parser"
import { renderDir, type RenderOptions } from "./render" 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" export { reverseDir, type ReverseOptions, type ReverseResult, type ReverseWarning } from "./reverse"
interface Stringable { interface Stringable {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@gregorlohaus/tdir", "name": "@gregorlohaus/tdir",
"version": "0.1.3", "version": "0.1.4",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",

297
render.ts
View File

@@ -18,7 +18,7 @@ const EQ_RE = /^eq\(context\.(.+?),\s*"(.*)"\)$/
const PATH_RE = /^context\.(.+)$/ const PATH_RE = /^context\.(.+)$/
export type ReverseMapToken = { export type ReverseMapToken = {
kind: "path" | "content" kind: "path" | "content" | "conditional"
result: string result: string
token: string token: string
contextPath?: string contextPath?: string
@@ -28,6 +28,12 @@ export type ReverseMapToken = {
start: number start: number
end: number end: number
} }
activeRange?: {
start: number
end: number
}
before?: string
after?: string
} }
export type ReverseMapFile = { export type ReverseMapFile = {
@@ -36,9 +42,17 @@ export type ReverseMapFile = {
tokens: ReverseMapToken[] tokens: ReverseMapToken[]
} }
export type ReverseMapStoredTemplate = {
kind: "directory" | "file"
templatePath: string
encoding?: "utf8" | "base64"
content?: string
}
export type ReverseMapManifest = { export type ReverseMapManifest = {
version: 1 version: 1
files: ReverseMapFile[] files: ReverseMapFile[]
skipped: ReverseMapStoredTemplate[]
tokens: Record<string, string[]> tokens: Record<string, string[]>
} }
@@ -110,7 +124,7 @@ function resolveOutputPath(destRoot: string, outputName: string): string {
} }
function createReverseMapManifest(): ReverseMapManifest { function createReverseMapManifest(): ReverseMapManifest {
return { version: 1, files: [], tokens: {} } return { version: 1, files: [], skipped: [], tokens: {} }
} }
function addReverseMapToken( function addReverseMapToken(
@@ -125,7 +139,7 @@ function addReverseMapToken(
state.manifest.tokens[token.result] = tokens state.manifest.tokens[token.result] = tokens
} }
function getReverseMapPath(destRoot: string, reverseMap: boolean | string): string { function getReverseMapPath(destRoot: string, reverseMap: true | string): string {
if (reverseMap === true) return resolveOutputPath(destRoot, ".tdir-map.json") if (reverseMap === true) return resolveOutputPath(destRoot, ".tdir-map.json")
return resolveOutputPath(destRoot, reverseMap) return resolveOutputPath(destRoot, reverseMap)
} }
@@ -176,70 +190,190 @@ function isUtf8Text(buffer: Buffer): boolean {
} }
} }
// Each stack frame tracks: did any branch match yet, is the current branch active type DirectiveToken = {
type IfFrame = { matched: boolean; active: boolean; sawElse: boolean } type: "if" | "elseif" | "else" | "endif"
condition?: string
index: number
end: number
}
function processIfBlocks(content: string, context: Record<string, unknown>): string { type TextNode = {
let result = "" type: "text"
let pos = 0 text: string
const stack: IfFrame[] = [] }
const re = new RegExp(DIRECTIVE_RE.source, "g") type IfBranch = {
let match: RegExpExecArray | null type: "if" | "elseif" | "else"
condition?: string
nodes: TemplateNode[]
contentStart: number
contentEnd: number
}
function isEmitting(): boolean { type IfNode = {
return stack.every(f => f.active) 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) { function getDirectiveTokens(content: string): DirectiveToken[] {
const directive = match[1]! return Array.from(content.matchAll(DIRECTIVE_RE), match => ({
const condPath = match[2] type: match[1] as DirectiveToken["type"],
condition: match[2],
index: match.index!,
end: match.index! + match[0].length,
}))
}
if (directive === "if") { function parseNodes(
if (isEmitting()) result += content.slice(pos, match.index) content: string,
const truthy = evalCondition(condPath, context) tokens: DirectiveToken[],
stack.push({ matched: truthy, active: truthy, sawElse: false }) tokenIndex: number,
pos = re.lastIndex pos: number,
} else if (directive === "elseif") { stopTypes: DirectiveToken["type"][],
if (stack.length === 0) throw new Error("Unexpected <@elseif> without <@if>") ): {
const top = stack[stack.length - 1]! nodes: TemplateNode[]
if (top.sawElse) throw new Error("Unexpected <@elseif> after <@else>") pos: number
// Emit text before this directive if current branch was active tokenIndex: number
if (isEmitting()) result += content.slice(pos, match.index) stop?: DirectiveToken
if (top.matched) { } {
// A previous branch already matched — skip this one const nodes: TemplateNode[] = []
top.active = false
} else { while (tokenIndex < tokens.length) {
const truthy = evalCondition(condPath, context) const token = tokens[tokenIndex]!
top.matched = truthy if (stopTypes.includes(token.type)) {
top.active = truthy if (token.index > pos) nodes.push({ type: "text", text: content.slice(pos, token.index) })
} return { nodes, pos: token.index, tokenIndex, stop: token }
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
} }
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) { if (pos < content.length) nodes.push({ type: "text", text: content.slice(pos) })
throw new Error("Unmatched <@if> without <@endif>") 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<string, unknown>): 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<string, unknown>,
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 return result
} }
function processIfBlocksWithMap(content: string, context: Record<string, unknown>) {
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, unknown>): string { function renderContent(content: string, context: Record<string, unknown>): string {
return renderContentWithMap(content, context) return renderContentWithMap(content, context)
} }
@@ -250,7 +384,22 @@ function renderContentWithMap(
state?: RenderState, state?: RenderState,
file?: ReverseMapFile, file?: ReverseMapFile,
): string { ): 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 result = ""
let pos = 0 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( function renderDirInner(
srcDir: string, srcDir: string,
destDir: string, destDir: string,
@@ -340,7 +522,10 @@ function renderDirInner(
tokens: [], tokens: [],
} }
const outputName = getOutputName(entry, context, state, tempFile) const outputName = getOutputName(entry, context, state, tempFile)
if (outputName === null) continue if (outputName === null) {
storeSkippedTemplate(srcPath, state)
continue
}
const destPath = resolveOutputPath(destDir, outputName) const destPath = resolveOutputPath(destDir, outputName)
const outputPath = relative(state.destRoot, destPath) const outputPath = relative(state.destRoot, destPath)
@@ -362,7 +547,7 @@ function renderDirInner(
} else { } else {
copyFileSync(srcPath, destPath) copyFileSync(srcPath, destPath)
} }
if (tempFile.tokens.length > 0) state.manifest?.files.push(tempFile) if (state.manifest) state.manifest.files.push(tempFile)
} }
} }
} }

View File

@@ -8,7 +8,7 @@ import {
} from "node:fs" } from "node:fs"
import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path" import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path"
import { TextDecoder } from "node:util" import { TextDecoder } from "node:util"
import type { ReverseMapFile, ReverseMapManifest, ReverseMapToken } from "./render" import type { ReverseMapFile, ReverseMapManifest, ReverseMapStoredTemplate, ReverseMapToken } from "./render"
export type ReverseOptions = { export type ReverseOptions = {
mapPath?: string 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)}` 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( function reverseContent(
content: string, content: string,
file: ReverseMapFile, file: ReverseMapFile,
warnings: ReverseWarning[], warnings: ReverseWarning[],
): string { ): string {
const tokens = file.tokens const contentTokens = file.tokens
.filter(token => token.kind === "content") .filter(token => token.kind === "content")
.sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1)) .sort((a, b) => (b.range?.start ?? -1) - (a.range?.start ?? -1))
let reversed = content let reversed = content
for (const token of tokens) { for (const token of contentTokens) {
const rangeResult = replaceAtRange(reversed, token) const rangeResult = replaceAtRange(reversed, token)
if (rangeResult !== null) { if (rangeResult !== null) {
reversed = rangeResult reversed = rangeResult
@@ -101,9 +157,47 @@ function reverseContent(
message: "Rendered value was not found; token was not restored", 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 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( export function reverseDir(
renderedDir: string, renderedDir: string,
templateDir: string, templateDir: string,
@@ -154,5 +248,9 @@ export function reverseDir(
filesWritten += 1 filesWritten += 1
} }
for (const skipped of manifest.skipped ?? []) {
filesWritten += writeSkippedTemplate(templateRoot, skipped)
}
return { filesWritten, warnings } return { filesWritten, warnings }
} }